mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-08-26 05:55:38 +02:00
android: refactor... stuff (2, see description)
implement buddy (head tracking and heartrate, finally!), added swipe anywhere to go back, and cleaned up app settings a bit.
This commit is contained in:
@@ -9,6 +9,7 @@ plugins {
|
|||||||
alias(libs.plugins.ksp)
|
alias(libs.plugins.ksp)
|
||||||
alias(libs.plugins.kotlin.serialization)
|
alias(libs.plugins.kotlin.serialization)
|
||||||
alias(libs.plugins.room3)
|
alias(libs.plugins.room3)
|
||||||
|
alias(libs.plugins.protobuf)
|
||||||
// alias(libs.plugins.hilt)
|
// alias(libs.plugins.hilt)
|
||||||
id("kotlin-parcelize")
|
id("kotlin-parcelize")
|
||||||
}
|
}
|
||||||
@@ -31,13 +32,30 @@ room3 {
|
|||||||
schemaDirectory("$projectDir/schemas")
|
schemaDirectory("$projectDir/schemas")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protobuf {
|
||||||
|
protoc {
|
||||||
|
artifact = libs.protobuf.protoc.get().toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
generateProtoTasks {
|
||||||
|
all().forEach { task ->
|
||||||
|
task.builtins {
|
||||||
|
create("java")
|
||||||
|
create("kotlin")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
optIn.addAll(
|
optIn.addAll(
|
||||||
"androidx.compose.material3.ExperimentalMaterial3ExpressiveApi",
|
"androidx.compose.material3.ExperimentalMaterial3ExpressiveApi",
|
||||||
"kotlin.uuid.ExperimentalUuidApi",
|
"kotlin.uuid.ExperimentalUuidApi",
|
||||||
"kotlinx.coroutines.FlowPreview",
|
"kotlinx.coroutines.FlowPreview",
|
||||||
"kotlinx.serialization.ExperimentalSerializationApi"
|
"kotlinx.serialization.ExperimentalSerializationApi",
|
||||||
|
"kotlinx.coroutines.ExperimentalCoroutinesApi",
|
||||||
|
"dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,6 +154,7 @@ dependencies {
|
|||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.accompanist.permissions)
|
implementation(libs.accompanist.permissions)
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.foundation.layout)
|
||||||
implementation(libs.androidx.lifecycle.process)
|
implementation(libs.androidx.lifecycle.process)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
@@ -169,8 +188,9 @@ dependencies {
|
|||||||
implementation(libs.androidx.room3.runtime)
|
implementation(libs.androidx.room3.runtime)
|
||||||
ksp(libs.androidx.room3.compiler)
|
ksp(libs.androidx.room3.compiler)
|
||||||
implementation(libs.kotlinx.serialization.cbor)
|
implementation(libs.kotlinx.serialization.cbor)
|
||||||
|
implementation(libs.protobuf.kotlin)
|
||||||
// compileOnly(files("../../../framework-classes.jar"))
|
// compileOnly(files("../../../framework-classes.jar"))
|
||||||
|
implementation(libs.androidx.healthconnect.client)
|
||||||
}
|
}
|
||||||
|
|
||||||
aboutLibraries {
|
aboutLibraries {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"formatVersion": 1,
|
"formatVersion": 1,
|
||||||
"database": {
|
"database": {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"identityHash": "ae2e348e0198b8d871e9e3ed6959ab30",
|
"identityHash": "7a502eac34ab57e1c12ba8f849e172cf",
|
||||||
"entities": [
|
"entities": [
|
||||||
{
|
{
|
||||||
"tableName": "AppleEntity",
|
"tableName": "AppleEntity",
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"tableName": "AppSettingsEntity",
|
"tableName": "AppSettingsEntity",
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `nightMode` TEXT NOT NULL, `designSystem` TEXT NOT NULL, `debugMode` INTEGER NOT NULL, `bleScanMode` INTEGER NOT NULL, `bleReportDelay` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `nightMode` TEXT NOT NULL, `designSystem` TEXT NOT NULL, `useHighestRefreshRate` INTEGER NOT NULL, `debugMode` INTEGER NOT NULL, `bleScanMode` INTEGER NOT NULL, `bleReportDelay` INTEGER NOT NULL, `swipeAnywhereForBack` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"fieldPath": "id",
|
"fieldPath": "id",
|
||||||
@@ -62,6 +62,12 @@
|
|||||||
"affinity": "TEXT",
|
"affinity": "TEXT",
|
||||||
"notNull": true
|
"notNull": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "useHighestRefreshRate",
|
||||||
|
"columnName": "useHighestRefreshRate",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"fieldPath": "debugMode",
|
"fieldPath": "debugMode",
|
||||||
"columnName": "debugMode",
|
"columnName": "debugMode",
|
||||||
@@ -79,6 +85,12 @@
|
|||||||
"columnName": "bleReportDelay",
|
"columnName": "bleReportDelay",
|
||||||
"affinity": "INTEGER",
|
"affinity": "INTEGER",
|
||||||
"notNull": true
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "swipeAnywhereForBack",
|
||||||
|
"columnName": "swipeAnywhereForBack",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"primaryKey": {
|
"primaryKey": {
|
||||||
@@ -163,11 +175,41 @@
|
|||||||
"appWidgetId"
|
"appWidgetId"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "HeartRateSampleEntity",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `bpm` INTEGER NOT NULL)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "timestamp",
|
||||||
|
"columnName": "timestamp",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "bpm",
|
||||||
|
"columnName": "bpm",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"setupQueries": [
|
"setupQueries": [
|
||||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ae2e348e0198b8d871e9e3ed6959ab30')"
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7a502eac34ab57e1c12ba8f849e172cf')"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,8 @@
|
|||||||
android:name="android.permission.INTERACT_ACROSS_USERS"
|
android:name="android.permission.INTERACT_ACROSS_USERS"
|
||||||
tools:ignore="ProtectedPermissions" />
|
tools:ignore="ProtectedPermissions" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.MODIFY_AUDIO_ROUTING"/>
|
android:name="android.permission.MODIFY_AUDIO_ROUTING"
|
||||||
|
tools:ignore="ProtectedPermissions" />
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||||
|
|
||||||
@@ -48,6 +49,8 @@
|
|||||||
<!-- android:maxSdkVersion="30" />-->
|
<!-- android:maxSdkVersion="30" />-->
|
||||||
<uses-permission android:name="com.android.vending.BILLING" />
|
<uses-permission android:name="com.android.vending.BILLING" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".LibrePodsApplication"
|
android:name=".LibrePodsApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
@@ -154,6 +157,28 @@
|
|||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/file_paths" />
|
android:resource="@xml/file_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
</application>
|
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".presentation.activities.PrivacyPolicyActivity"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<activity-alias
|
||||||
|
android:name="ViewPermissionUsageActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:targetActivity=".presentation.activities.PrivacyPolicyActivity"
|
||||||
|
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
|
||||||
|
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
<queries>
|
||||||
|
<package android:name="com.google.android.apps.healthdata" />
|
||||||
|
</queries>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package me.kavishdevar.librepods
|
package me.kavishdevar.librepods
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import androidx.health.connect.client.HealthConnectClient
|
||||||
import androidx.lifecycle.DefaultLifecycleObserver
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.lifecycle.ProcessLifecycleOwner
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
@@ -12,7 +13,10 @@ import me.kavishdevar.librepods.billing.BillingProviderFactory
|
|||||||
import me.kavishdevar.librepods.database.LibrePodsDatabase
|
import me.kavishdevar.librepods.database.LibrePodsDatabase
|
||||||
import me.kavishdevar.librepods.repository.AppDataRepository
|
import me.kavishdevar.librepods.repository.AppDataRepository
|
||||||
import me.kavishdevar.librepods.repository.AppleRepository
|
import me.kavishdevar.librepods.repository.AppleRepository
|
||||||
|
import me.kavishdevar.librepods.repository.HeartRateRepository
|
||||||
|
import me.kavishdevar.librepods.repository.RecordingRepository
|
||||||
import me.kavishdevar.librepods.repository.WidgetConfigRepository
|
import me.kavishdevar.librepods.repository.WidgetConfigRepository
|
||||||
|
import me.kavishdevar.librepods.utils.GestureFeedback
|
||||||
import me.kavishdevar.librepods.utils.XposedServiceHolder
|
import me.kavishdevar.librepods.utils.XposedServiceHolder
|
||||||
import me.kavishdevar.librepods.utils.XposedState
|
import me.kavishdevar.librepods.utils.XposedState
|
||||||
|
|
||||||
@@ -24,6 +28,18 @@ class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener
|
|||||||
val appDataRepository by lazy { AppDataRepository(database.appSettingsDao(), database.appStateDao()) }
|
val appDataRepository by lazy { AppDataRepository(database.appSettingsDao(), database.appStateDao()) }
|
||||||
val widgetConfigRepository by lazy { WidgetConfigRepository(database.widgetConfigDao()) }
|
val widgetConfigRepository by lazy { WidgetConfigRepository(database.widgetConfigDao()) }
|
||||||
|
|
||||||
|
val recordingRepository by lazy { RecordingRepository(applicationContext) }
|
||||||
|
val heartRateRepository by lazy { HeartRateRepository(database.heartRateDao()) }
|
||||||
|
|
||||||
|
val healthConnectClient: HealthConnectClient? by lazy {
|
||||||
|
val status = HealthConnectClient.getSdkStatus(this)
|
||||||
|
if (status == HealthConnectClient.SDK_AVAILABLE) {
|
||||||
|
HealthConnectClient.getOrCreate(this)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
System.loadLibrary("hiddenapi")
|
System.loadLibrary("hiddenapi")
|
||||||
|
|
||||||
@@ -37,6 +53,8 @@ class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener
|
|||||||
BillingManager.provider = BillingProviderFactory.create(this)
|
BillingManager.provider = BillingProviderFactory.create(this)
|
||||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||||
|
|
||||||
|
GestureFeedback.init(this)
|
||||||
|
|
||||||
super<Application>.onCreate()
|
super<Application>.onCreate()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+126
-51
@@ -37,8 +37,11 @@ import me.kavishdevar.librepods.bluetooth.aacp.packet.CustomEqPacket
|
|||||||
import me.kavishdevar.librepods.bluetooth.aacp.packet.EarDetectionResponsePacket
|
import me.kavishdevar.librepods.bluetooth.aacp.packet.EarDetectionResponsePacket
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.packet.InformationPacket
|
import me.kavishdevar.librepods.bluetooth.aacp.packet.InformationPacket
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.packet.MagicKeyResponsePacket
|
import me.kavishdevar.librepods.bluetooth.aacp.packet.MagicKeyResponsePacket
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.packet.RTBuddyPacket
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.packet.RenamePacket
|
import me.kavishdevar.librepods.bluetooth.aacp.packet.RenamePacket
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.packet.StemPressPacket
|
import me.kavishdevar.librepods.bluetooth.aacp.packet.StemPressPacket
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto.SensorDataWX
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto.SensorServiceType
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.AppleEvent
|
import me.kavishdevar.librepods.bluetooth.aacp.types.AppleEvent
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.Capability
|
import me.kavishdevar.librepods.bluetooth.aacp.types.Capability
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.CapabilityEntry
|
import me.kavishdevar.librepods.bluetooth.aacp.types.CapabilityEntry
|
||||||
@@ -48,12 +51,18 @@ import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
|
|||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.CustomEq
|
import me.kavishdevar.librepods.bluetooth.aacp.types.CustomEq
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType
|
import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
|
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.types.RTBuddyDescriptor
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.types.SensorDataWxBuddyPayload
|
||||||
import me.kavishdevar.librepods.data.audio.MicrophoneFrame
|
import me.kavishdevar.librepods.data.audio.MicrophoneFrame
|
||||||
|
import me.kavishdevar.librepods.data.heartrate.HeartRateSample
|
||||||
import me.kavishdevar.librepods.devices.AppleDevice
|
import me.kavishdevar.librepods.devices.AppleDevice
|
||||||
import me.kavishdevar.librepods.devices.BatteryStatus
|
import me.kavishdevar.librepods.devices.BatteryStatus
|
||||||
|
import me.kavishdevar.librepods.utils.HeadTracking
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class AACPManager(private val device: AppleDevice) {
|
class AACPManager(private val device: AppleDevice) {
|
||||||
@@ -62,6 +71,8 @@ class AACPManager(private val device: AppleDevice) {
|
|||||||
|
|
||||||
private var socket: BluetoothSocket? = null
|
private var socket: BluetoothSocket? = null
|
||||||
|
|
||||||
|
private val rtBuddyManager = RTBuddyManager(::sendPacket)
|
||||||
|
|
||||||
fun connect(): Boolean {
|
fun connect(): Boolean {
|
||||||
if (socket != null && socket!!.isConnected) {
|
if (socket != null && socket!!.isConnected) {
|
||||||
Log.i(TAG, "Already connected")
|
Log.i(TAG, "Already connected")
|
||||||
@@ -192,7 +203,7 @@ class AACPManager(private val device: AppleDevice) {
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Log.d(TAG, "received packet: ${packet.toHexString()}")
|
// Log.d(TAG, "received packet: ${packet.toHexString()}")
|
||||||
val opcode = packet[4]
|
val opcode = packet[4]
|
||||||
when (MessageOpcode.fromByte(opcode)) {
|
when (MessageOpcode.fromByte(opcode)) {
|
||||||
MessageOpcode.BUD_ROLE -> {
|
MessageOpcode.BUD_ROLE -> {
|
||||||
@@ -314,7 +325,28 @@ class AACPManager(private val device: AppleDevice) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
MessageOpcode.BUDDY_COMMAND -> {
|
MessageOpcode.BUDDY_COMMAND -> {
|
||||||
Log.w(TAG, "BUDDY not implemented")
|
val packet = RTBuddyPacket.parse(packet)
|
||||||
|
|
||||||
|
val rtBuddyPayload = packet.rtBuddyPayload
|
||||||
|
|
||||||
|
rtBuddyManager.handlePacket(packet)
|
||||||
|
|
||||||
|
when (rtBuddyPayload.descriptor) {
|
||||||
|
RTBuddyDescriptor.SENSOR_DATA_WX -> {
|
||||||
|
val sensorDataWxBuddyPayload = rtBuddyPayload as SensorDataWxBuddyPayload
|
||||||
|
val data = sensorDataWxBuddyPayload.data
|
||||||
|
|
||||||
|
handleSensorData(data)
|
||||||
|
} else -> {
|
||||||
|
Log.d(TAG, "Unhandled descriptor: ${rtBuddyPayload.descriptor}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// device.updateState {
|
||||||
|
// it.copy(
|
||||||
|
// aacpPackets = it.aacpPackets + packet
|
||||||
|
// )
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
MessageOpcode.MAGIC_KEYS_RESPONSE -> {
|
MessageOpcode.MAGIC_KEYS_RESPONSE -> {
|
||||||
@@ -625,55 +657,7 @@ class AACPManager(private val device: AppleDevice) {
|
|||||||
return sendPacket(packet)
|
return sendPacket(packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendStartHeadTracking(): Boolean {
|
fun setSensorServiceReportInterval(sensorServiceType: SensorServiceType, interval: Duration): Boolean = rtBuddyManager.setSensorServiceReportInterval(sensorServiceType, interval)
|
||||||
val payload = byteArrayOf(
|
|
||||||
0x00, 0x00, 0x10, 0x00,
|
|
||||||
0x10, 0x00, 0x08, 0xA1.toByte(), 0x02, 0x42, 0x0B, 0x08, 0x0E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x40, 0x9C.toByte(), 0x00, 0x00
|
|
||||||
)
|
|
||||||
val packet = AACPPacket.createUnknownPacket(
|
|
||||||
MessageOpcode.BUDDY_COMMAND,
|
|
||||||
payload
|
|
||||||
)
|
|
||||||
return sendPacket(packet)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendStartAlternateHeadTracking(): Boolean {
|
|
||||||
val payload = byteArrayOf(
|
|
||||||
0x00, 0x00, 0x10, 0x00,
|
|
||||||
0x0F, 0x00, 0x08, 0x73, 0x42, 0x0B, 0x08, 0x10, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x40, 0x9C.toByte(), 0x00, 0x00
|
|
||||||
)
|
|
||||||
val packet = AACPPacket.createUnknownPacket(
|
|
||||||
MessageOpcode.BUDDY_COMMAND,
|
|
||||||
payload
|
|
||||||
)
|
|
||||||
return sendPacket(packet)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendStopHeadTracking(): Boolean {
|
|
||||||
val payload = byteArrayOf(
|
|
||||||
0x00, 0x00, 0x10, 0x00,
|
|
||||||
0x11, 0x00, 0x08, 0x7E, 0x10, 0x02, 0x42, 0x0B, 0x08, 0x4E, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00
|
|
||||||
)
|
|
||||||
val packet = AACPPacket.createUnknownPacket(
|
|
||||||
MessageOpcode.BUDDY_COMMAND,
|
|
||||||
payload
|
|
||||||
)
|
|
||||||
return sendPacket(packet)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendStopAlternateHeadTracking(): Boolean {
|
|
||||||
val payload = byteArrayOf(
|
|
||||||
0x00, 0x00, 0x10, 0x00,
|
|
||||||
0x0F, 0x00, 0x08, 0x75, 0x42, 0x0B, 0x08, 0x10, 0x10, 0x02, 0x1A, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00
|
|
||||||
)
|
|
||||||
|
|
||||||
val packet = AACPPacket.createUnknownPacket(
|
|
||||||
opcode = MessageOpcode.BUDDY_COMMAND,
|
|
||||||
payload = payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
return sendPacket(packet)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendRename(name: String): Boolean {
|
fun sendRename(name: String): Boolean {
|
||||||
val packet = RenamePacket.create(name)
|
val packet = RenamePacket.create(name)
|
||||||
@@ -1045,6 +1029,8 @@ class AACPManager(private val device: AppleDevice) {
|
|||||||
connectedDevices = emptyList(),
|
connectedDevices = emptyList(),
|
||||||
microphoneFrames = emptyList(),
|
microphoneFrames = emptyList(),
|
||||||
controlStates = emptyMap(),
|
controlStates = emptyMap(),
|
||||||
|
aacpPackets = emptyList(),
|
||||||
|
currentHeartRate = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1174,4 +1160,93 @@ class AACPManager(private val device: AppleDevice) {
|
|||||||
|
|
||||||
return capabilities
|
return capabilities
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handleSensorData(data: SensorDataWX) {
|
||||||
|
if (data.hasCommand()) {
|
||||||
|
when (data.command.service) {
|
||||||
|
SensorServiceType.ACTIVITY, SensorServiceType.DEVMOTION6 -> {
|
||||||
|
val payload = data.command.payload.toByteArray()
|
||||||
|
|
||||||
|
if (payload.size != 58) {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"Unexpected payload size for ACTIVITY/DEVMOTION6: ${payload.size}, payload: ${payload.toHexString()}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fun i16(offset: Int): Int =
|
||||||
|
(payload[offset].toInt() and 0xFF) or
|
||||||
|
((payload[offset + 1].toInt() and 0xFF) shl 8)
|
||||||
|
.let { value ->
|
||||||
|
if (value and 0x8000 != 0) value - 0x10000 else value
|
||||||
|
}
|
||||||
|
|
||||||
|
HeadTracking.addAccel(i16(device.settings.value.headGesturesVerticalOffset).toFloat(), i16(device.settings.value.headGesturesHorizontalOffset).toFloat())
|
||||||
|
}
|
||||||
|
|
||||||
|
SensorServiceType.HEARTRATE -> {
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"Received sensor data for service: ${data.command.service}, payload: ${data.command.payload.toByteArray().toHexString()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SensorServiceType.HEARTRATEv2 -> {
|
||||||
|
val payload = data.command.payload.toByteArray()
|
||||||
|
val timestamp = Clock.System.now()
|
||||||
|
if (payload.size == 18) {
|
||||||
|
val heartRate = payload[1].toInt()
|
||||||
|
|
||||||
|
// same as healthconnect's datatype. 300 isn't possible anyway, but whatever
|
||||||
|
if (heartRate !in 1..300) {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"Invalid heart rate value: $heartRate"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!device.state.value.hrmActive) {
|
||||||
|
device.updateState {
|
||||||
|
it.copy(
|
||||||
|
hrmActive = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"hr: $heartRate bpm"
|
||||||
|
)
|
||||||
|
|
||||||
|
val heartRateSample = HeartRateSample(
|
||||||
|
bpm = heartRate,
|
||||||
|
timestamp = timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
device.updateState {
|
||||||
|
it.copy(
|
||||||
|
currentHeartRate = heartRateSample
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
Log.w(
|
||||||
|
TAG,
|
||||||
|
"Unexpected payload size for HEARTRATEv2: ${payload.size}, payload: ${payload.toHexString()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val payload = data.command.payload.toByteArray()
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"Unhandled sensor command service: ${data.command.service}, payload: ${payload.toHexString()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
package me.kavishdevar.librepods.bluetooth.aacp
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.google.protobuf.ByteString
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.packet.AACPPacket
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.packet.RTBuddyPacket
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto.SensorDataWX
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto.SensorServiceSetting
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto.SensorServiceType
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.types.SensorDataWxBuddyPayload
|
||||||
|
import me.kavishdevar.librepods.devices.PacketDestination
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
class RTBuddyManager(
|
||||||
|
private val sendPacket: (AACPPacket) -> Boolean,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "RTBuddyManager"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sequence = 0
|
||||||
|
|
||||||
|
private fun nextSequence(): Int {
|
||||||
|
val next = sequence
|
||||||
|
sequence = (sequence + 1) and 0x7FFFFFFF
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeSequence(data: SensorDataWX) {
|
||||||
|
Log.d(TAG, "Received SensorDataWX seq=${data.seq}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun handlePacket(packet: RTBuddyPacket) {
|
||||||
|
when (val payload = packet.rtBuddyPayload) {
|
||||||
|
is SensorDataWxBuddyPayload -> {
|
||||||
|
val data = payload.data
|
||||||
|
observeSequence(data)
|
||||||
|
|
||||||
|
// handleSensorData(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"Unhandled RTBuddy payload: ${payload.descriptor}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// private fun handleSensorData(data: SensorDataWX) {
|
||||||
|
// if (data.hasServiceSettings()) {
|
||||||
|
// when (data.serviceSettings.service) {
|
||||||
|
// SensorServiceType.ACTIVITY,
|
||||||
|
// SensorServiceType.DEVMOTION6 -> {
|
||||||
|
// // Head tracking data/configuration
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// SensorServiceType.HEARTRATE -> {
|
||||||
|
// // Heart rate
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// else -> {
|
||||||
|
// Log.d(
|
||||||
|
// TAG,
|
||||||
|
// "Unhandled sensor service: ${data.serviceSettings.service}"
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
fun sendSensorData(
|
||||||
|
data: SensorDataWX,
|
||||||
|
): Boolean {
|
||||||
|
val dataWithSequence = data.toBuilder()
|
||||||
|
.setSeq(nextSequence())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val packet = RTBuddyPacket.create(
|
||||||
|
rtBuddyPayload = SensorDataWxBuddyPayload(
|
||||||
|
data = dataWithSequence,
|
||||||
|
),
|
||||||
|
destination = PacketDestination.DEVICE,
|
||||||
|
)
|
||||||
|
|
||||||
|
return sendPacket(packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendSensorServiceSetting(
|
||||||
|
service: SensorServiceType,
|
||||||
|
configuration: ByteArray,
|
||||||
|
): Boolean {
|
||||||
|
val data = SensorDataWX.newBuilder()
|
||||||
|
.setServiceSettings(
|
||||||
|
SensorServiceSetting.newBuilder()
|
||||||
|
.setService(service)
|
||||||
|
.setSetting(2)
|
||||||
|
.setConfiguration(
|
||||||
|
ByteString.copyFrom(configuration)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return sendSensorData(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSensorServiceReportInterval(sensorServiceType: SensorServiceType, interval: Duration): Boolean = sendSensorServiceSetting(
|
||||||
|
service = sensorServiceType,
|
||||||
|
configuration = byteArrayOf(0x01) + interval.toMicrosUINT32LE()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Duration.toMicrosUINT32LE(): ByteArray {
|
||||||
|
val microseconds = this.inWholeMicroseconds
|
||||||
|
return byteArrayOf(
|
||||||
|
microseconds.toByte(),
|
||||||
|
(microseconds shr 8).toByte(),
|
||||||
|
(microseconds shr 16).toByte(),
|
||||||
|
(microseconds shr 24).toByte()
|
||||||
|
)
|
||||||
|
}
|
||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
package me.kavishdevar.librepods.bluetooth.aacp.packet
|
||||||
|
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto.SensorDataWX
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.types.Opcode
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.types.RTBuddyDescriptor
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.types.RTBuddyPayload
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.types.SensorDataWxBuddyPayload
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.types.UnknownBuddyPayload
|
||||||
|
import me.kavishdevar.librepods.devices.PacketDestination
|
||||||
|
|
||||||
|
data class RTBuddyPacket(
|
||||||
|
val rtBuddyPayload: RTBuddyPayload,
|
||||||
|
|
||||||
|
override val payload: ByteArray,
|
||||||
|
override val destination: PacketDestination,
|
||||||
|
) : AACPPacket {
|
||||||
|
|
||||||
|
override val type: AACPPacketType = AACPPacketType.MESSAGE
|
||||||
|
override val service: Byte = 0x04
|
||||||
|
override val opcode: Opcode = MessageOpcode.BUDDY_COMMAND
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun parse(
|
||||||
|
data: ByteArray,
|
||||||
|
destination: PacketDestination = PacketDestination.HOST,
|
||||||
|
): RTBuddyPacket {
|
||||||
|
val payload = if (
|
||||||
|
data.size >= 6 &&
|
||||||
|
data[0] == AACPPacketType.MESSAGE.value &&
|
||||||
|
data[4] == MessageOpcode.BUDDY_COMMAND.value
|
||||||
|
) {
|
||||||
|
data.copyOfRange(6, data.size)
|
||||||
|
} else {
|
||||||
|
data
|
||||||
|
}
|
||||||
|
|
||||||
|
require(payload.size >= 6) {
|
||||||
|
"RTBuddy packet is too short: ${payload.size}"
|
||||||
|
}
|
||||||
|
|
||||||
|
val descriptorValue =
|
||||||
|
(payload[0].toUInt() and 0xFFu) or
|
||||||
|
((payload[1].toUInt() and 0xFFu) shl 8) or
|
||||||
|
((payload[2].toUInt() and 0xFFu) shl 16) or
|
||||||
|
((payload[3].toUInt() and 0xFFu) shl 24)
|
||||||
|
|
||||||
|
val length =
|
||||||
|
(payload[4].toInt() and 0xFF) or
|
||||||
|
((payload[5].toInt() and 0xFF) shl 8)
|
||||||
|
|
||||||
|
require(payload.size >= 6 + length) {
|
||||||
|
"RTBuddy payload truncated: expected $length bytes, got ${payload.size - 6}"
|
||||||
|
}
|
||||||
|
|
||||||
|
val descriptor = RTBuddyDescriptor.fromValue(descriptorValue)
|
||||||
|
val data = payload.copyOfRange(6, 6 + length)
|
||||||
|
|
||||||
|
val rtBuddyPayload: RTBuddyPayload = when (descriptor) {
|
||||||
|
RTBuddyDescriptor.SENSOR_DATA_WX -> {
|
||||||
|
SensorDataWxBuddyPayload(
|
||||||
|
data = SensorDataWX.parseFrom(data),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
UnknownBuddyPayload(
|
||||||
|
descriptor = descriptor,
|
||||||
|
descriptorValue = descriptorValue,
|
||||||
|
data = data,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return RTBuddyPacket(
|
||||||
|
rtBuddyPayload = rtBuddyPayload,
|
||||||
|
payload = payload,
|
||||||
|
destination = destination,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fun create(
|
||||||
|
rtBuddyPayload: RTBuddyPayload,
|
||||||
|
destination: PacketDestination = PacketDestination.DEVICE,
|
||||||
|
): RTBuddyPacket {
|
||||||
|
val data = when (rtBuddyPayload) {
|
||||||
|
is SensorDataWxBuddyPayload ->
|
||||||
|
rtBuddyPayload.data.toByteArray()
|
||||||
|
|
||||||
|
is UnknownBuddyPayload ->
|
||||||
|
rtBuddyPayload.data
|
||||||
|
}
|
||||||
|
|
||||||
|
val descriptorValue = when (rtBuddyPayload) {
|
||||||
|
is SensorDataWxBuddyPayload ->
|
||||||
|
rtBuddyPayload.descriptor.value
|
||||||
|
|
||||||
|
is UnknownBuddyPayload ->
|
||||||
|
rtBuddyPayload.descriptorValue
|
||||||
|
}
|
||||||
|
|
||||||
|
require(data.size <= 0xFFFF) {
|
||||||
|
"RTBuddy payload too large: ${data.size}"
|
||||||
|
}
|
||||||
|
|
||||||
|
val payload = byteArrayOf(
|
||||||
|
(descriptorValue and 0xFFu).toByte(),
|
||||||
|
((descriptorValue shr 8) and 0xFFu).toByte(),
|
||||||
|
((descriptorValue shr 16) and 0xFFu).toByte(),
|
||||||
|
((descriptorValue shr 24) and 0xFFu).toByte(),
|
||||||
|
(data.size and 0xFF).toByte(),
|
||||||
|
((data.size shr 8) and 0xFF).toByte(),
|
||||||
|
) + data
|
||||||
|
|
||||||
|
return RTBuddyPacket(
|
||||||
|
rtBuddyPayload = rtBuddyPayload,
|
||||||
|
payload = payload,
|
||||||
|
destination = destination,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package me.kavishdevar.librepods.bluetooth.aacp.types
|
||||||
|
|
||||||
|
enum class RTBuddyDescriptor(val value: UInt) {
|
||||||
|
ACOUSTIC(0x00000001u),
|
||||||
|
SCP(0x00000002u),
|
||||||
|
BUDDY(0x00000004u),
|
||||||
|
VIRTUAL_CLI_PRIMARY(0x00000008u),
|
||||||
|
VIRTUAL_CLI_SECONDARY(0x00000010u),
|
||||||
|
APP_DIAGNOSTICS(0x00000020u),
|
||||||
|
LOGGING_TRIGGER(0x00000040u),
|
||||||
|
DEBUG_DATA(0x00000080u),
|
||||||
|
TOUCH(0x00000100u),
|
||||||
|
LOG_CONFIG(0x00000200u),
|
||||||
|
LOG_MSG(0x00000400u),
|
||||||
|
SENSOR(0x00000800u),
|
||||||
|
SWITCH_CONTROL(0x00001000u),
|
||||||
|
MISMATCHED_BUDS(0x00002000u),
|
||||||
|
UNKNOWN_4000(0x00004000u),
|
||||||
|
B2P(0x00008000u),
|
||||||
|
CONTINUITY(0x00010000u),
|
||||||
|
BATTERY_HEALTH(0x00020000u),
|
||||||
|
SENSOR_V2(0x00040000u),
|
||||||
|
OBC_V2(0x00080000u),
|
||||||
|
SENSOR_DATA_WX(0x00100000u),
|
||||||
|
UNKNOWN_00200000(0x00200000u),
|
||||||
|
UNKNOWN_00400000(0x00400000u),
|
||||||
|
DIGITAL_ENGRAVING_INFO(0x00800000u),
|
||||||
|
ACTIVE_MODE_DATA(0x01000000u),
|
||||||
|
|
||||||
|
UNKNOWN(0xFFFFFFFFu); // better way?
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromValue(value: UInt): RTBuddyDescriptor = entries.firstOrNull { it.value == value } ?: UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
package me.kavishdevar.librepods.bluetooth.aacp.types
|
||||||
|
|
||||||
|
sealed interface RTBuddyPayload {
|
||||||
|
val descriptor: RTBuddyDescriptor
|
||||||
|
}
|
||||||
|
|
||||||
|
data class UnknownBuddyPayload(
|
||||||
|
override val descriptor: RTBuddyDescriptor,
|
||||||
|
val descriptorValue: UInt,
|
||||||
|
val data: ByteArray,
|
||||||
|
) : RTBuddyPayload
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package me.kavishdevar.librepods.bluetooth.aacp.types
|
||||||
|
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto.SensorDataWX
|
||||||
|
|
||||||
|
data class SensorDataWxBuddyPayload(
|
||||||
|
val data: SensorDataWX,
|
||||||
|
): RTBuddyPayload {
|
||||||
|
override val descriptor: RTBuddyDescriptor = RTBuddyDescriptor.SENSOR_DATA_WX
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package me.kavishdevar.librepods.data.heartrate
|
||||||
|
|
||||||
|
import androidx.room3.Dao
|
||||||
|
import androidx.room3.Insert
|
||||||
|
import androidx.room3.Query
|
||||||
|
import me.kavishdevar.librepods.database.heartrate.HeartRateSampleEntity
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface HeartRateDao {
|
||||||
|
@Insert
|
||||||
|
suspend fun insert(sample: HeartRateSampleEntity)
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT * FROM HeartRateSampleEntity
|
||||||
|
WHERE timestamp >= :start
|
||||||
|
AND timestamp < :end
|
||||||
|
ORDER BY timestamp ASC
|
||||||
|
""")
|
||||||
|
suspend fun get(
|
||||||
|
start: Instant,
|
||||||
|
end: Instant,
|
||||||
|
): List<HeartRateSampleEntity>
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package me.kavishdevar.librepods.data.heartrate
|
||||||
|
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
data class HeartRateSample (
|
||||||
|
val bpm: Int,
|
||||||
|
val timestamp: Instant
|
||||||
|
)
|
||||||
|
// TODO: implement records (don't have a better place to put this)
|
||||||
@@ -2,7 +2,7 @@ package me.kavishdevar.librepods.data.updates
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.presentation.screens.apple.AirPodsSettingsScreenPreviewMaterial
|
import me.kavishdevar.librepods.presentation.screens.apple.AppleSettingsScreenPreviewMaterial
|
||||||
import me.kavishdevar.librepods.presentation.screens.apple.EqualizerScreenPreviewApple
|
import me.kavishdevar.librepods.presentation.screens.apple.EqualizerScreenPreviewApple
|
||||||
import me.kavishdevar.librepods.presentation.screens.apple.EqualizerScreenPreviewMaterial
|
import me.kavishdevar.librepods.presentation.screens.apple.EqualizerScreenPreviewMaterial
|
||||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||||
@@ -13,7 +13,7 @@ val update1_0_0 = listOf(
|
|||||||
titleRes = R.string.material3e,
|
titleRes = R.string.material3e,
|
||||||
descriptionRes = R.string.update_m3e_description,
|
descriptionRes = R.string.update_m3e_description,
|
||||||
demoComposeable = @Composable {
|
demoComposeable = @Composable {
|
||||||
AirPodsSettingsScreenPreviewMaterial()
|
AppleSettingsScreenPreviewMaterial()
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
UpdateItem(
|
UpdateItem(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import me.kavishdevar.librepods.bluetooth.MacAddress
|
|||||||
import me.kavishdevar.librepods.data.apple.AppleCache
|
import me.kavishdevar.librepods.data.apple.AppleCache
|
||||||
import me.kavishdevar.librepods.devices.AppleMetadata
|
import me.kavishdevar.librepods.devices.AppleMetadata
|
||||||
import me.kavishdevar.librepods.devices.AppleSettings
|
import me.kavishdevar.librepods.devices.AppleSettings
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
object Converters {
|
object Converters {
|
||||||
@ColumnTypeConverter
|
@ColumnTypeConverter
|
||||||
@@ -39,4 +40,12 @@ object Converters {
|
|||||||
@ColumnTypeConverter
|
@ColumnTypeConverter
|
||||||
fun bytesToAppleCache(bytes: ByteArray): AppleCache =
|
fun bytesToAppleCache(bytes: ByteArray): AppleCache =
|
||||||
Cbor.decodeFromByteArray(bytes)
|
Cbor.decodeFromByteArray(bytes)
|
||||||
|
|
||||||
|
@ColumnTypeConverter
|
||||||
|
fun kotlinInstantToLong(instant: Instant): Long =
|
||||||
|
instant.toEpochMilliseconds()
|
||||||
|
|
||||||
|
@ColumnTypeConverter
|
||||||
|
fun longToKotlinInstant(millis: Long): Instant =
|
||||||
|
Instant.fromEpochMilliseconds(millis)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ package me.kavishdevar.librepods.database
|
|||||||
import androidx.room3.ColumnTypeConverters
|
import androidx.room3.ColumnTypeConverters
|
||||||
import androidx.room3.Database
|
import androidx.room3.Database
|
||||||
import androidx.room3.RoomDatabase
|
import androidx.room3.RoomDatabase
|
||||||
|
import me.kavishdevar.librepods.data.heartrate.HeartRateDao
|
||||||
import me.kavishdevar.librepods.database.app.AppSettingsDao
|
import me.kavishdevar.librepods.database.app.AppSettingsDao
|
||||||
import me.kavishdevar.librepods.database.app.AppSettingsEntity
|
import me.kavishdevar.librepods.database.app.AppSettingsEntity
|
||||||
import me.kavishdevar.librepods.database.app.AppStateDao
|
import me.kavishdevar.librepods.database.app.AppStateDao
|
||||||
import me.kavishdevar.librepods.database.app.AppStateEntity
|
import me.kavishdevar.librepods.database.app.AppStateEntity
|
||||||
import me.kavishdevar.librepods.database.apple.AppleDao
|
import me.kavishdevar.librepods.database.apple.AppleDao
|
||||||
import me.kavishdevar.librepods.database.apple.AppleEntity
|
import me.kavishdevar.librepods.database.apple.AppleEntity
|
||||||
|
import me.kavishdevar.librepods.database.heartrate.HeartRateSampleEntity
|
||||||
import me.kavishdevar.librepods.database.widget.WidgetConfigDao
|
import me.kavishdevar.librepods.database.widget.WidgetConfigDao
|
||||||
import me.kavishdevar.librepods.database.widget.WidgetConfigEntity
|
import me.kavishdevar.librepods.database.widget.WidgetConfigEntity
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ import me.kavishdevar.librepods.database.widget.WidgetConfigEntity
|
|||||||
AppSettingsEntity::class,
|
AppSettingsEntity::class,
|
||||||
AppStateEntity::class,
|
AppStateEntity::class,
|
||||||
WidgetConfigEntity::class,
|
WidgetConfigEntity::class,
|
||||||
|
HeartRateSampleEntity::class
|
||||||
],
|
],
|
||||||
version = 1,
|
version = 1,
|
||||||
)
|
)
|
||||||
@@ -29,4 +32,6 @@ abstract class LibrePodsDatabase: RoomDatabase() {
|
|||||||
abstract fun appStateDao(): AppStateDao
|
abstract fun appStateDao(): AppStateDao
|
||||||
|
|
||||||
abstract fun widgetConfigDao(): WidgetConfigDao
|
abstract fun widgetConfigDao(): WidgetConfigDao
|
||||||
|
|
||||||
|
abstract fun heartRateDao(): HeartRateDao
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -13,12 +13,12 @@ data class AppSettingsEntity(
|
|||||||
|
|
||||||
val nightMode: NightTheme = NightTheme.System,
|
val nightMode: NightTheme = NightTheme.System,
|
||||||
val designSystem: DesignSystem = DesignSystem.Material,
|
val designSystem: DesignSystem = DesignSystem.Material,
|
||||||
|
val useHighestRefreshRate: Boolean = false,
|
||||||
|
|
||||||
/**
|
|
||||||
* Currently only shows the button for Debug screen.
|
|
||||||
*/
|
|
||||||
val debugMode: Boolean = false,
|
val debugMode: Boolean = false,
|
||||||
|
|
||||||
val bleScanMode: Int = ScanSettings.SCAN_MODE_BALANCED,
|
val bleScanMode: Int = ScanSettings.SCAN_MODE_BALANCED,
|
||||||
val bleReportDelay: Long = 0,
|
val bleReportDelay: Long = 0,
|
||||||
|
|
||||||
|
val swipeAnywhereForBack: Boolean = true,
|
||||||
)
|
)
|
||||||
|
|||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package me.kavishdevar.librepods.database.heartrate
|
||||||
|
|
||||||
|
import androidx.room3.Entity
|
||||||
|
import androidx.room3.PrimaryKey
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
data class HeartRateSampleEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
val id: Long = 0L,
|
||||||
|
|
||||||
|
val timestamp: Instant,
|
||||||
|
val bpm: Int,
|
||||||
|
)
|
||||||
@@ -18,12 +18,17 @@ import kotlinx.coroutines.flow.update
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.bluetooth.MacAddress
|
import me.kavishdevar.librepods.bluetooth.MacAddress
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.AACPManager
|
import me.kavishdevar.librepods.bluetooth.aacp.AACPManager
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto.SensorServiceType
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.AppleEvent
|
import me.kavishdevar.librepods.bluetooth.aacp.types.AppleEvent
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
|
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
|
||||||
import me.kavishdevar.librepods.bluetooth.att.ATTHandle
|
import me.kavishdevar.librepods.bluetooth.att.ATTHandle
|
||||||
import me.kavishdevar.librepods.bluetooth.att.ATTManager
|
import me.kavishdevar.librepods.bluetooth.att.ATTManager
|
||||||
import me.kavishdevar.librepods.data.StemAction
|
import me.kavishdevar.librepods.data.StemAction
|
||||||
|
import me.kavishdevar.librepods.utils.GestureDetector
|
||||||
|
import me.kavishdevar.librepods.utils.HeadTracking
|
||||||
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
private const val TAG = "AppleDevice"
|
private const val TAG = "AppleDevice"
|
||||||
|
|
||||||
@@ -77,6 +82,12 @@ class AppleDevice(
|
|||||||
val aacp = AACPManager(this)
|
val aacp = AACPManager(this)
|
||||||
val att = ATTManager(this)
|
val att = ATTManager(this)
|
||||||
|
|
||||||
|
|
||||||
|
// ik, probably wrong place. TODO
|
||||||
|
private val gestureDetector by lazy {
|
||||||
|
GestureDetector()
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
updateMetadata {
|
updateMetadata {
|
||||||
it.copy(
|
it.copy(
|
||||||
@@ -205,25 +216,35 @@ class AppleDevice(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun startHeadTracking() {
|
fun startHeadTracking() {
|
||||||
if (settings.value.alternateHeadTrackingPackets) {
|
aacp.setSensorServiceReportInterval(
|
||||||
aacp.sendStartAlternateHeadTracking()
|
sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 8) SensorServiceType.DEVMOTION6 else SensorServiceType.ACTIVITY,
|
||||||
} else {
|
interval = _settings.value.headTrackingInterval
|
||||||
aacp.sendStartHeadTracking()
|
)
|
||||||
}
|
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(headTrackingActive = true)
|
it.copy(headTrackingActive = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stopHeadTracking() {
|
fun stopHeadTracking() {
|
||||||
if (settings.value.alternateHeadTrackingPackets) {
|
aacp.setSensorServiceReportInterval(
|
||||||
aacp.sendStopAlternateHeadTracking()
|
sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 8) SensorServiceType.DEVMOTION6 else SensorServiceType.ACTIVITY,
|
||||||
} else {
|
interval = Duration.ZERO
|
||||||
aacp.sendStopHeadTracking()
|
)
|
||||||
}
|
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(headTrackingActive = false)
|
it.copy(headTrackingActive = false)
|
||||||
}
|
}
|
||||||
|
gestureDetector.stopDetection()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detectHeadGestures(callback: (Boolean) -> Unit) {
|
||||||
|
if (!state.value.headTrackingActive) startHeadTracking()
|
||||||
|
if (!state.value.headTrackingActive) {
|
||||||
|
aacp.setSensorServiceReportInterval(
|
||||||
|
sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 8) SensorServiceType.DEVMOTION6 else SensorServiceType.ACTIVITY,
|
||||||
|
interval = 40.milliseconds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
gestureDetector.startDetection(HeadTracking.acceleration, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setHeadGesturesEnabled(enabled: Boolean) {
|
fun setHeadGesturesEnabled(enabled: Boolean) {
|
||||||
@@ -232,6 +253,25 @@ class AppleDevice(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setHeadGesturesVerticalOffset(offset: Int) {
|
||||||
|
_settings.update {
|
||||||
|
it.copy(headGesturesVerticalOffset = offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setHeadGesturesHorizontalOffset(offset: Int) {
|
||||||
|
_settings.update {
|
||||||
|
it.copy(headGesturesHorizontalOffset = offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setHeadTrackingInterval(interval: Duration) {
|
||||||
|
startHeadTracking()
|
||||||
|
_settings.update {
|
||||||
|
it.copy(headTrackingInterval = interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun setCustomEqEnabled(enabled: Boolean) {
|
fun setCustomEqEnabled(enabled: Boolean) {
|
||||||
aacp.setCustomEq(_state.value.customEq.copy(state = if(enabled) 2 else 1))
|
aacp.setCustomEq(_state.value.customEq.copy(state = if(enabled) 2 else 1))
|
||||||
}
|
}
|
||||||
@@ -243,17 +283,24 @@ class AppleDevice(
|
|||||||
aacp.setCustomEq(_state.value.customEq.copy(low = low, mid = mid, high = high))
|
aacp.setCustomEq(_state.value.customEq.copy(low = low, mid = mid, high = high))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun testHeadGestures() {
|
// AACPManager sets hrmActive true when a valid reading is received
|
||||||
if (settings.value.alternateHeadTrackingPackets) {
|
fun startHr(): Boolean = aacp.setSensorServiceReportInterval(
|
||||||
aacp.sendStartAlternateHeadTracking()
|
sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 8) SensorServiceType.HEARTRATE_COMMAND else SensorServiceType.HEARTRATE,
|
||||||
} else {
|
interval = 1.seconds
|
||||||
aacp.sendStartHeadTracking()
|
)
|
||||||
}
|
|
||||||
_state.update {
|
fun stopHr(): Boolean {
|
||||||
|
val success = aacp.setSensorServiceReportInterval(
|
||||||
|
sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 8) SensorServiceType.HEARTRATE_COMMAND else SensorServiceType.HEARTRATE,
|
||||||
|
interval = Duration.ZERO
|
||||||
|
)
|
||||||
|
if (state.value.hrmActive && success) _state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
detectHeadGestures = true
|
hrmActive = false,
|
||||||
|
currentHeartRate = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
return success
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendRawPacket(data: ByteArray): Boolean = aacp.sendRawPacket(data)
|
fun sendRawPacket(data: ByteArray): Boolean = aacp.sendRawPacket(data)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package me.kavishdevar.librepods.devices
|
|||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import me.kavishdevar.librepods.data.StemAction
|
import me.kavishdevar.librepods.data.StemAction
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AppleSettings(
|
data class AppleSettings(
|
||||||
@@ -10,6 +12,9 @@ data class AppleSettings(
|
|||||||
val cacheDisconnectedComponentBattery: Boolean = true,
|
val cacheDisconnectedComponentBattery: Boolean = true,
|
||||||
|
|
||||||
val headGesturesEnabled: Boolean = true, // head_gestures_enabled
|
val headGesturesEnabled: Boolean = true, // head_gestures_enabled
|
||||||
|
val headGesturesVerticalOffset: Int = 30,
|
||||||
|
val headGesturesHorizontalOffset: Int = 28,
|
||||||
|
val headTrackingInterval: Duration = 40.milliseconds,
|
||||||
|
|
||||||
val leftLongPressAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES, // left_long_press_action
|
val leftLongPressAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES, // left_long_press_action
|
||||||
val rightLongPressAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES, // right_long_press_action
|
val rightLongPressAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES, // right_long_press_action
|
||||||
@@ -17,8 +22,6 @@ data class AppleSettings(
|
|||||||
val showIslandPopup: Boolean = true, // show_island_popup
|
val showIslandPopup: Boolean = true, // show_island_popup
|
||||||
val showBottomSheetPopup: Boolean = true, // show_bottom_sheet_popup
|
val showBottomSheetPopup: Boolean = true, // show_bottom_sheet_popup
|
||||||
|
|
||||||
val alternateHeadTrackingPackets: Boolean = true, // use_alternate_head_tracking_packets
|
|
||||||
|
|
||||||
val takeoverWhenDisconnected: Boolean = true, // takeover_when_disconnected
|
val takeoverWhenDisconnected: Boolean = true, // takeover_when_disconnected
|
||||||
val takeoverWhenIdle: Boolean = true, // takeover_when_idle
|
val takeoverWhenIdle: Boolean = true, // takeover_when_idle
|
||||||
val takeoverWhenMusic: Boolean = true, // takeover_when_music
|
val takeoverWhenMusic: Boolean = true, // takeover_when_music
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import me.kavishdevar.librepods.bluetooth.aacp.types.CustomEq
|
|||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType
|
import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType
|
||||||
import me.kavishdevar.librepods.data.audio.MicrophoneFrame
|
import me.kavishdevar.librepods.data.audio.MicrophoneFrame
|
||||||
import me.kavishdevar.librepods.data.audio.MicrophoneState
|
import me.kavishdevar.librepods.data.audio.MicrophoneState
|
||||||
|
import me.kavishdevar.librepods.data.heartrate.HeartRateSample
|
||||||
import me.kavishdevar.librepods.data.recording.RecordingState
|
import me.kavishdevar.librepods.data.recording.RecordingState
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
data class AppleState(
|
data class AppleState(
|
||||||
val isLocallyConnected: Boolean = false,
|
val isLocallyConnected: Boolean = false,
|
||||||
@@ -51,5 +54,9 @@ data class AppleState(
|
|||||||
val headphoneAccomodationEnabledForMedia: Boolean = false,
|
val headphoneAccomodationEnabledForMedia: Boolean = false,
|
||||||
val headphoneAccomodationEnabledForPhone: Boolean = false,
|
val headphoneAccomodationEnabledForPhone: Boolean = false,
|
||||||
|
|
||||||
|
val currentHeartRate: HeartRateSample? = null,
|
||||||
|
val hrmActive: Boolean = false,
|
||||||
|
val heartRateInterval: Duration = 1.seconds,
|
||||||
|
|
||||||
val aacpPackets: List<AACPPacket> = emptyList(),
|
val aacpPackets: List<AACPPacket> = emptyList(),
|
||||||
): DeviceState
|
): DeviceState
|
||||||
|
|||||||
+20
-2
@@ -28,9 +28,11 @@ import android.content.Context
|
|||||||
import android.content.Context.MODE_PRIVATE
|
import android.content.Context.MODE_PRIVATE
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.ServiceConnection
|
import android.content.ServiceConnection
|
||||||
|
import android.hardware.display.DisplayManager
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.view.Display
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
@@ -58,8 +60,8 @@ import me.kavishdevar.librepods.services.LibrePodsService
|
|||||||
import me.kavishdevar.librepods.utils.XposedState
|
import me.kavishdevar.librepods.utils.XposedState
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
lateinit var serviceConnection: ServiceConnection
|
private lateinit var serviceConnection: ServiceConnection
|
||||||
lateinit var connectionStatusReceiver: BroadcastReceiver
|
private lateinit var connectionStatusReceiver: BroadcastReceiver
|
||||||
//lateinit var testReviewReceiver: BroadcastReceiver
|
//lateinit var testReviewReceiver: BroadcastReceiver
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -95,6 +97,22 @@ class MainActivity : ComponentActivity() {
|
|||||||
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(settings.swipeAnywhereForBack) {
|
||||||
|
if (settings.useHighestRefreshRate) {
|
||||||
|
val display = getSystemService(DisplayManager::class.java)
|
||||||
|
.getDisplay(Display.DEFAULT_DISPLAY)
|
||||||
|
|
||||||
|
val highest = display.supportedModes
|
||||||
|
.maxByOrNull { it.refreshRate }
|
||||||
|
|
||||||
|
highest?.let {
|
||||||
|
window.attributes = window.attributes.apply {
|
||||||
|
preferredRefreshRate = it.refreshRate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LibrePodsTheme(
|
LibrePodsTheme(
|
||||||
designSystem = settings.designSystem,
|
designSystem = settings.designSystem,
|
||||||
darkTheme = darkTheme
|
darkTheme = darkTheme
|
||||||
|
|||||||
+7
-3
@@ -7,7 +7,6 @@ import android.content.Intent
|
|||||||
import android.content.ServiceConnection
|
import android.content.ServiceConnection
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.util.Log
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -15,6 +14,7 @@ import androidx.compose.foundation.isSystemInDarkTheme
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@@ -34,9 +34,9 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import me.kavishdevar.librepods.LibrePodsApplication
|
import me.kavishdevar.librepods.LibrePodsApplication
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.bluetooth.MacAddress
|
||||||
import me.kavishdevar.librepods.database.widget.WidgetConfigEntity
|
import me.kavishdevar.librepods.database.widget.WidgetConfigEntity
|
||||||
import me.kavishdevar.librepods.devices.Device
|
import me.kavishdevar.librepods.devices.Device
|
||||||
import me.kavishdevar.librepods.bluetooth.MacAddress
|
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledList
|
import me.kavishdevar.librepods.presentation.components.StyledList
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||||
@@ -164,13 +164,15 @@ private fun WidgetDevicePickerContent(
|
|||||||
) {
|
) {
|
||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = stringResource(R.string.configure_widget),
|
title = stringResource(R.string.configure_widget),
|
||||||
) {
|
navigateBack = null
|
||||||
|
) { topPadding, bottomPadding ->
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
) {
|
) {
|
||||||
|
Spacer(modifier = Modifier.size(topPadding))
|
||||||
if (devices.isEmpty()) {
|
if (devices.isEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = "No devices found. Please ensure a compatible device is paired with your phone and try again.",
|
text = "No devices found. Please ensure a compatible device is paired with your phone and try again.",
|
||||||
@@ -231,6 +233,8 @@ private fun WidgetDevicePickerContent(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.size(bottomPadding))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
package me.kavishdevar.librepods.presentation.activities
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalView
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import me.kavishdevar.librepods.LibrePodsApplication
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.onboarding.PrivacyPolicyPage
|
||||||
|
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||||
|
import me.kavishdevar.librepods.presentation.theme.NightTheme
|
||||||
|
|
||||||
|
class PrivacyPolicyActivity : ComponentActivity() {
|
||||||
|
val appDataRepository by lazy { (application as LibrePodsApplication).appDataRepository }
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
val settings by appDataRepository.settings.collectAsState()
|
||||||
|
|
||||||
|
val systemDarkTheme = isSystemInDarkTheme()
|
||||||
|
|
||||||
|
val darkTheme = when (settings.nightMode) {
|
||||||
|
NightTheme.Dark -> true
|
||||||
|
NightTheme.Light -> false
|
||||||
|
NightTheme.System -> systemDarkTheme
|
||||||
|
}
|
||||||
|
|
||||||
|
val view = LocalView.current
|
||||||
|
val window = (view.context as Activity).window
|
||||||
|
|
||||||
|
LaunchedEffect(darkTheme) {
|
||||||
|
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
|
||||||
|
}
|
||||||
|
|
||||||
|
LibrePodsTheme(
|
||||||
|
designSystem = settings.designSystem,
|
||||||
|
darkTheme = darkTheme
|
||||||
|
) {
|
||||||
|
// For demo screenshots
|
||||||
|
// val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
|
||||||
|
// windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
|
// windowInsetsController.hide(WindowInsetsCompat.Type.statusBars())
|
||||||
|
|
||||||
|
StyledScaffold(
|
||||||
|
title = stringResource(R.string.privacy_policy),
|
||||||
|
navigateBack = null
|
||||||
|
) { topPadding, bottomPadding ->
|
||||||
|
Column {
|
||||||
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(52.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.clip(RoundedCornerShape(52.dp))
|
||||||
|
.padding(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
) {
|
||||||
|
PrivacyPolicyPage { finish() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-3
@@ -122,12 +122,13 @@ fun StyledListDemo() {
|
|||||||
darkTheme = false
|
darkTheme = false
|
||||||
) {
|
) {
|
||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = "StyledListTest"
|
title = "StyledListTest",
|
||||||
) {
|
navigateBack = null
|
||||||
|
) { topPadding, bottomPadding ->
|
||||||
Column (
|
Column (
|
||||||
modifier = Modifier.padding(horizontal = 12.dp)
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(56.dp))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
StyledList(
|
StyledList(
|
||||||
title = "hello"
|
title = "hello"
|
||||||
) {
|
) {
|
||||||
@@ -145,6 +146,7 @@ fun StyledListDemo() {
|
|||||||
onCheckedChange = { checked.value = it },
|
onCheckedChange = { checked.value = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-18
@@ -668,7 +668,7 @@ fun StyledListScope.StyledListItem(
|
|||||||
Text(
|
Text(
|
||||||
text = supportingText,
|
text = supportingText,
|
||||||
style = if (LocalDesignSystem.current == DesignSystem.Apple && orientation == StyledListItemOrientation.Horizontal) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.bodySmall,
|
style = if (LocalDesignSystem.current == DesignSystem.Apple && orientation == StyledListItemOrientation.Horizontal) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.bodySmall,
|
||||||
color = if (selected == true) MaterialTheme.colorScheme.onPrimaryContainer.copy(0.7f) else MaterialTheme.colorScheme.onSurface.copy(0.7f), // TODO: move to color scheme
|
color = if (selected == true && LocalDesignSystem.current == DesignSystem.Material) MaterialTheme.colorScheme.onPrimaryContainer.copy(0.7f) else MaterialTheme.colorScheme.onSurface.copy(0.7f), // TODO: move to color scheme
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else null,
|
} else null,
|
||||||
@@ -805,25 +805,27 @@ private fun StyledListItemContent(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
.pointerInput(Unit) {
|
.pointerInput(Unit) {
|
||||||
detectTapGestures(
|
if (onClick != null) {
|
||||||
onPress = {
|
detectTapGestures(
|
||||||
if (enabled) {
|
onPress = {
|
||||||
backgroundColor = pressedColor
|
if (enabled) {
|
||||||
tryAwaitRelease()
|
backgroundColor = pressedColor
|
||||||
backgroundColor = surfaceColor
|
tryAwaitRelease()
|
||||||
}
|
backgroundColor = surfaceColor
|
||||||
},
|
}
|
||||||
onTap = {
|
},
|
||||||
if (enabled) {
|
onTap = {
|
||||||
scope.launch {
|
if (enabled) {
|
||||||
haptics.performHapticFeedback(
|
scope.launch {
|
||||||
HapticFeedbackType.ContextClick
|
haptics.performHapticFeedback(
|
||||||
)
|
HapticFeedbackType.ContextClick
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick.invoke()
|
||||||
}
|
}
|
||||||
onClick?.invoke()
|
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
.heightIn(min = height)
|
.heightIn(min = height)
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
|
|||||||
+124
-38
@@ -18,6 +18,8 @@
|
|||||||
|
|
||||||
package me.kavishdevar.librepods.presentation.components
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
|
import android.graphics.RenderEffect
|
||||||
|
import android.graphics.Shader
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.Crossfade
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
@@ -27,6 +29,7 @@ import androidx.compose.animation.scaleIn
|
|||||||
import androidx.compose.animation.scaleOut
|
import androidx.compose.animation.scaleOut
|
||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.slideOutVertically
|
import androidx.compose.animation.slideOutVertically
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -61,10 +64,17 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.asComposeRenderEffect
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.graphics.luminance
|
import androidx.compose.ui.graphics.luminance
|
||||||
|
import androidx.compose.ui.input.pointer.consumePositionChange
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
import androidx.compose.ui.platform.LocalWindowInfo
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import com.kyant.backdrop.backdrops.LayerBackdrop
|
import com.kyant.backdrop.backdrops.LayerBackdrop
|
||||||
@@ -75,6 +85,9 @@ import dev.chrisbanes.haze.hazeEffect
|
|||||||
import dev.chrisbanes.haze.hazeSource
|
import dev.chrisbanes.haze.hazeSource
|
||||||
import dev.chrisbanes.haze.rememberHazeState
|
import dev.chrisbanes.haze.rememberHazeState
|
||||||
import me.kavishdevar.librepods.presentation.icons.LocalIcons
|
import me.kavishdevar.librepods.presentation.icons.LocalIcons
|
||||||
|
import me.kavishdevar.librepods.presentation.navigation.LocalIsCurrentEntry
|
||||||
|
import me.kavishdevar.librepods.presentation.navigation.LocalSharedTransitionScope
|
||||||
|
import me.kavishdevar.librepods.presentation.navigation.LocalTransitionProgress
|
||||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||||
|
|
||||||
@@ -84,11 +97,10 @@ fun StyledScaffold(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
visible: Boolean = true,
|
visible: Boolean = true,
|
||||||
title: String,
|
title: String,
|
||||||
showBackButton: Boolean = false,
|
navigateBack: (() -> Unit)?,
|
||||||
onNavigateBack: () -> Unit = {},
|
|
||||||
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
|
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
|
||||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||||
content: @Composable () -> Unit
|
content: @Composable (topPadding: Dp, bottomPadding: Dp) -> Unit
|
||||||
) {
|
) {
|
||||||
val hazeState = rememberHazeState(blurEnabled = true)
|
val hazeState = rememberHazeState(blurEnabled = true)
|
||||||
|
|
||||||
@@ -105,14 +117,18 @@ fun StyledScaffold(
|
|||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
if (showBackButton) {
|
if (navigateBack != null) {
|
||||||
Row {
|
Row {
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
FilledTonalIconButton(
|
FilledTonalIconButton(
|
||||||
onClick = onNavigateBack,
|
onClick = navigateBack,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.minimumInteractiveComponentSize()
|
.minimumInteractiveComponentSize()
|
||||||
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Narrow)),
|
.size(
|
||||||
|
IconButtonDefaults.mediumContainerSize(
|
||||||
|
IconButtonDefaults.IconButtonWidthOption.Narrow
|
||||||
|
)
|
||||||
|
),
|
||||||
shape = IconButtonDefaults.mediumRoundShape
|
shape = IconButtonDefaults.mediumRoundShape
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -130,7 +146,7 @@ fun StyledScaffold(
|
|||||||
text = it,
|
text = it,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.padding(start = if (showBackButton) 8.dp else 12.dp, end = 12.dp),
|
modifier = Modifier.padding(start = if (navigateBack != null) 8.dp else 12.dp, end = 12.dp),
|
||||||
style = MaterialTheme.typography.titleSmall
|
style = MaterialTheme.typography.titleSmall
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -146,15 +162,20 @@ fun StyledScaffold(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
Column(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.then(if (visible) Modifier.padding(start = paddingValues.calculateStartPadding(LocalLayoutDirection.current), end = paddingValues.calculateEndPadding(LocalLayoutDirection.current)) else Modifier)
|
.then(
|
||||||
|
if (visible) Modifier.padding(
|
||||||
|
start = paddingValues.calculateStartPadding(
|
||||||
|
LocalLayoutDirection.current
|
||||||
|
),
|
||||||
|
end = paddingValues.calculateEndPadding(LocalLayoutDirection.current)
|
||||||
|
) else Modifier
|
||||||
|
)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.hazeSource(hazeState)
|
.hazeSource(hazeState)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(paddingValues.calculateTopPadding()))
|
content(paddingValues.calculateTopPadding(), paddingValues.calculateBottomPadding())
|
||||||
content()
|
|
||||||
Spacer(modifier = Modifier.height(paddingValues.calculateBottomPadding()))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,6 +194,7 @@ fun StyledScaffold(
|
|||||||
)
|
)
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
val topPadding = paddingValues.calculateTopPadding()
|
val topPadding = paddingValues.calculateTopPadding()
|
||||||
|
val bottomPadding = paddingValues.calculateBottomPadding()
|
||||||
val startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current)
|
val startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current)
|
||||||
val endPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current)
|
val endPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current)
|
||||||
|
|
||||||
@@ -184,31 +206,80 @@ fun StyledScaffold(
|
|||||||
) {
|
) {
|
||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
val bgColor = MaterialTheme.colorScheme.surfaceContainer
|
val bgColor = MaterialTheme.colorScheme.surfaceContainer
|
||||||
AnimatedVisibility(
|
|
||||||
visible = showBackButton,
|
val density = LocalDensity.current
|
||||||
enter = fadeIn() + scaleIn(
|
val screenWidthPx = with(density) {
|
||||||
initialScale = 0f,
|
LocalWindowInfo.current.containerDpSize.width.toPx()
|
||||||
animationSpec = tween()
|
}
|
||||||
),
|
val isCurrentEntry = LocalIsCurrentEntry.current
|
||||||
exit = fadeOut() + scaleOut(
|
val transitionProgress = LocalTransitionProgress.current
|
||||||
targetScale = 0.5f,
|
val sharedTransitionScope = LocalSharedTransitionScope.current
|
||||||
animationSpec = tween(100)
|
|
||||||
),
|
val showBackButton = if (transitionProgress == 0f) navigateBack != null else !isCurrentEntry
|
||||||
modifier = Modifier
|
|
||||||
.zIndex(3f)
|
if (showBackButton) {
|
||||||
.padding(top = topPadding, start = 8.dp)
|
with(sharedTransitionScope) {
|
||||||
.align(Alignment.TopStart)
|
Box(
|
||||||
) {
|
modifier = Modifier
|
||||||
StyledIconButton(
|
.zIndex(3f)
|
||||||
onClick = onNavigateBack,
|
.padding(top = topPadding, start = 8.dp)
|
||||||
backdrop = backdrop
|
.align(Alignment.TopStart)
|
||||||
) {
|
.pointerInput(Unit) {
|
||||||
Icon(
|
awaitPointerEventScope {
|
||||||
imageVector = LocalIcons.current.ArrowBack,
|
while (true) {
|
||||||
contentDescription = "Back",
|
awaitFirstDown(requireUnconsumed = false)
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onBackground
|
do {
|
||||||
)
|
val event = awaitPointerEvent()
|
||||||
|
|
||||||
|
event.changes.forEach { change ->
|
||||||
|
change.consumePositionChange()
|
||||||
|
}
|
||||||
|
} while (event.changes.any { it.pressed })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.renderInSharedTransitionScopeOverlay(
|
||||||
|
zIndexInOverlay = 3f,
|
||||||
|
renderInOverlay = {
|
||||||
|
!isCurrentEntry && transitionProgress != 0f
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.graphicsLayer { // AI generated
|
||||||
|
if (!isCurrentEntry && navigateBack == null && transitionProgress < 0f) {
|
||||||
|
val progress = (-transitionProgress).coerceIn(0f, 1f)
|
||||||
|
|
||||||
|
val eased = progress * progress * (3f - 2f * progress)
|
||||||
|
|
||||||
|
val scale = 1f - 0.18f * eased
|
||||||
|
|
||||||
|
scaleX = scale
|
||||||
|
scaleY = scale
|
||||||
|
|
||||||
|
alpha = 1f - 0.28f * eased
|
||||||
|
|
||||||
|
val blur = 8f * progress
|
||||||
|
|
||||||
|
renderEffect = RenderEffect.createBlurEffect(
|
||||||
|
blur,
|
||||||
|
blur,
|
||||||
|
Shader.TileMode.DECAL
|
||||||
|
).asComposeRenderEffect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
StyledIconButton(
|
||||||
|
onClick = { navigateBack?.invoke() },
|
||||||
|
backdrop = backdrop // i know, this doesn't capture what's actually beneath it. but it's going to matter just in the transition.
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = LocalIcons.current.ArrowBack,
|
||||||
|
contentDescription = "Back",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onBackground
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,6 +341,21 @@ fun StyledScaffold(
|
|||||||
.zIndex(3f)
|
.zIndex(3f)
|
||||||
.padding(top = topPadding, end = 8.dp)
|
.padding(top = topPadding, end = 8.dp)
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
awaitPointerEventScope {
|
||||||
|
while (true) {
|
||||||
|
awaitFirstDown(requireUnconsumed = false)
|
||||||
|
|
||||||
|
do {
|
||||||
|
val event = awaitPointerEvent()
|
||||||
|
|
||||||
|
event.changes.forEach { change ->
|
||||||
|
change.consumePositionChange()
|
||||||
|
}
|
||||||
|
} while (event.changes.any { it.pressed })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
Row{
|
Row{
|
||||||
actionButtons.forEach { actionButton ->
|
actionButtons.forEach { actionButton ->
|
||||||
@@ -283,7 +369,7 @@ fun StyledScaffold(
|
|||||||
.hazeSource(hazeState)
|
.hazeSource(hazeState)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
content()
|
content(topPadding + 64.dp, bottomPadding)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -759,12 +759,13 @@ fun StyledSliderPreview() {
|
|||||||
) {
|
) {
|
||||||
StyledScaffold(
|
StyledScaffold(
|
||||||
title = "test",
|
title = "test",
|
||||||
) {
|
navigateBack = null
|
||||||
|
) { topPadding, bottomPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(72.dp))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
StyledSlider(
|
StyledSlider(
|
||||||
value = a.floatValue,
|
value = a.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
@@ -803,6 +804,7 @@ fun StyledSliderPreview() {
|
|||||||
independent = true,
|
independent = true,
|
||||||
description = stringResource(R.string.adaptive_audio_description),
|
description = stringResource(R.string.adaptive_audio_description),
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1248,4 +1248,54 @@ object AppleIcons: IconSet {
|
|||||||
|
|
||||||
override val CircleDotted: ImageVector
|
override val CircleDotted: ImageVector
|
||||||
get() = CommonIcons.CircleDotted
|
get() = CommonIcons.CircleDotted
|
||||||
|
|
||||||
|
override val VitalSigns: ImageVector
|
||||||
|
get() = WaveFormPathEcg
|
||||||
|
val WaveFormPathEcg: ImageVector
|
||||||
|
get() {
|
||||||
|
val current = _waveformPathEcg
|
||||||
|
if (current != null) return current
|
||||||
|
|
||||||
|
return ImageVector.Builder(
|
||||||
|
name = "WaveformPathEcg",
|
||||||
|
defaultWidth = 62.78099822998047.dp,
|
||||||
|
defaultHeight = 67.31999969482422.dp,
|
||||||
|
viewportWidth = 62.781f,
|
||||||
|
viewportHeight = 67.32f,
|
||||||
|
).apply {
|
||||||
|
path(
|
||||||
|
fill = SolidColor(Color(0xFFFFFFFF)),
|
||||||
|
fillAlpha = 0.85f,
|
||||||
|
) {
|
||||||
|
moveTo(x = 2.25f, y = 39.7f)
|
||||||
|
horizontalLineToRelative(dx = 13.84f)
|
||||||
|
quadToRelative(dx1 = 2.1f, dy1 = 0.0f, dx2 = 2.57f, dy2 = -1.87f)
|
||||||
|
lineToRelative(dx = 4.84f, dy = -22.25f)
|
||||||
|
horizontalLineToRelative(dx = -0.28f)
|
||||||
|
lineToRelative(dx = 8.34f, dy = 50.06f)
|
||||||
|
curveToRelative(dx1 = 0.38f, dy1 = 2.25f, dx2 = 3.85f, dy2 = 2.22f, dx3 = 4.28f, dy3 = 0.0f)
|
||||||
|
lineToRelative(dx = 8.07f, dy = -37.75f)
|
||||||
|
lineToRelative(dx = -0.25f, dy = -0.03f)
|
||||||
|
lineToRelative(dx = 2.0f, dy = 9.72f)
|
||||||
|
curveToRelative(dx1 = 0.28f, dy1 = 1.44f, dx2 = 1.09f, dy2 = 2.13f, dx3 = 2.56f, dy3 = 2.13f)
|
||||||
|
horizontalLineToRelative(dx = 12.12f)
|
||||||
|
arcToRelative(a = 2.2f, b = 2.2f, theta = 0.0f, isMoreThanHalf = true, isPositiveArc = false, dx1 = 0.0f, dy1 = -4.4f)
|
||||||
|
horizontalLineTo(x = 48.66f)
|
||||||
|
lineToRelative(dx = 0.75f, dy = 0.46f)
|
||||||
|
lineToRelative(dx = -3.72f, dy = -16.38f)
|
||||||
|
curveToRelative(dx1 = -0.47f, dy1 = -2.18f, dx2 = -3.69f, dy2 = -2.15f, dx3 = -4.22f, dy3 = 0.1f)
|
||||||
|
lineTo(x = 33.4f, y = 55.43f)
|
||||||
|
horizontalLineToRelative(dx = 0.37f)
|
||||||
|
lineToRelative(dx = -8.37f, dy = -50.7f)
|
||||||
|
curveToRelative(dx1 = -0.35f, dy1 = -2.18f, dx2 = -3.53f, dy2 = -2.24f, dx3 = -4.03f, dy3 = 0.0f)
|
||||||
|
lineToRelative(dx = -6.72f, dy = 31.04f)
|
||||||
|
lineToRelative(dx = 0.4f, dy = -0.47f)
|
||||||
|
horizontalLineTo(x = 2.25f)
|
||||||
|
curveToRelative(dx1 = -1.28f, dy1 = 0.0f, dx2 = -2.25f, dy2 = 1.0f, dx3 = -2.25f, dy3 = 2.22f)
|
||||||
|
arcToRelative(a = 2.2f, b = 2.2f, theta = 0.0f, isMoreThanHalf = false, isPositiveArc = false, dx1 = 2.25f, dy1 = 2.19f)
|
||||||
|
}
|
||||||
|
}.build().also { _waveformPathEcg = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var _waveformPathEcg: ImageVector? = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ interface IconSet {
|
|||||||
|
|
||||||
val CircleDotted: ImageVector
|
val CircleDotted: ImageVector
|
||||||
|
|
||||||
|
val VitalSigns: ImageVector
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* AirPods Icons
|
* AirPods Icons
|
||||||
*/
|
*/
|
||||||
@@ -155,6 +157,7 @@ interface IconSet {
|
|||||||
"BoltCircle" to BoltCircle,
|
"BoltCircle" to BoltCircle,
|
||||||
"Circle" to Circle,
|
"Circle" to Circle,
|
||||||
"CircleDotted" to CircleDotted,
|
"CircleDotted" to CircleDotted,
|
||||||
|
"VitalSign" to VitalSigns,
|
||||||
|
|
||||||
"AirPods1" to AirPods1,
|
"AirPods1" to AirPods1,
|
||||||
"AirPods1Case" to AirPods1Case,
|
"AirPods1Case" to AirPods1Case,
|
||||||
|
|||||||
+65
@@ -1658,4 +1658,69 @@ object MaterialIcons: IconSet {
|
|||||||
|
|
||||||
override val CircleDotted: ImageVector
|
override val CircleDotted: ImageVector
|
||||||
get() = CommonIcons.CircleDotted
|
get() = CommonIcons.CircleDotted
|
||||||
|
|
||||||
|
override val VitalSigns: ImageVector
|
||||||
|
get() {
|
||||||
|
if (_vital_signs != null) {
|
||||||
|
return _vital_signs!!
|
||||||
|
}
|
||||||
|
_vital_signs =
|
||||||
|
ImageVector.Builder(
|
||||||
|
name = "vital_signs",
|
||||||
|
defaultWidth = 24.dp,
|
||||||
|
defaultHeight = 24.dp,
|
||||||
|
viewportWidth = 24f,
|
||||||
|
viewportHeight = 24f,
|
||||||
|
)
|
||||||
|
.apply {
|
||||||
|
path(
|
||||||
|
fill = SolidColor(Color.Black),
|
||||||
|
fillAlpha = 1f,
|
||||||
|
stroke = null,
|
||||||
|
strokeAlpha = 1f,
|
||||||
|
strokeLineWidth = 1f,
|
||||||
|
strokeLineCap = StrokeCap.Butt,
|
||||||
|
strokeLineJoin = StrokeJoin.Bevel,
|
||||||
|
strokeLineMiter = 1f,
|
||||||
|
pathFillType = PathFillType.Companion.NonZero,
|
||||||
|
) {
|
||||||
|
moveTo(8.15f, 19.73f)
|
||||||
|
quadTo(7.78f, 19.45f, 7.6f, 19.02f)
|
||||||
|
lineTo(5.3f, 13f)
|
||||||
|
horizontalLineTo(2f)
|
||||||
|
quadTo(1.58f, 13f, 1.29f, 12.71f)
|
||||||
|
quadTo(1f, 12.43f, 1f, 12f)
|
||||||
|
reflectiveQuadTo(1.29f, 11.29f)
|
||||||
|
reflectiveQuadTo(2f, 11f)
|
||||||
|
horizontalLineTo(6f)
|
||||||
|
quadToRelative(0.33f, 0f, 0.56f, 0.17f)
|
||||||
|
reflectiveQuadToRelative(0.36f, 0.47f)
|
||||||
|
lineTo(9f, 17.1f)
|
||||||
|
lineTo(13.6f, 4.97f)
|
||||||
|
quadToRelative(0.17f, -0.43f, 0.55f, -0.7f)
|
||||||
|
reflectiveQuadTo(15f, 4f)
|
||||||
|
reflectiveQuadToRelative(0.85f, 0.27f)
|
||||||
|
reflectiveQuadToRelative(0.55f, 0.7f)
|
||||||
|
lineTo(18.7f, 11f)
|
||||||
|
horizontalLineTo(22f)
|
||||||
|
quadToRelative(0.43f, 0f, 0.71f, 0.29f)
|
||||||
|
reflectiveQuadTo(23f, 12f)
|
||||||
|
reflectiveQuadToRelative(-0.29f, 0.71f)
|
||||||
|
reflectiveQuadTo(22f, 13f)
|
||||||
|
horizontalLineTo(18f)
|
||||||
|
quadToRelative(-0.32f, 0f, -0.56f, -0.18f)
|
||||||
|
reflectiveQuadTo(17.08f, 12.35f)
|
||||||
|
lineTo(15f, 6.9f)
|
||||||
|
lineTo(10.4f, 19.02f)
|
||||||
|
quadToRelative(-0.17f, 0.43f, -0.55f, 0.7f)
|
||||||
|
reflectiveQuadTo(9f, 20f)
|
||||||
|
reflectiveQuadTo(8.15f, 19.73f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
return _vital_signs!!
|
||||||
|
}
|
||||||
|
|
||||||
|
private var _vital_signs: ImageVector? = null
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-613
@@ -1,656 +1,112 @@
|
|||||||
package me.kavishdevar.librepods.presentation.navigation
|
package me.kavishdevar.librepods.presentation.navigation
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import androidx.activity.BackEventCompat.Companion.EDGE_LEFT
|
import androidx.compose.animation.EnterTransition
|
||||||
|
import androidx.compose.animation.ExitTransition
|
||||||
import androidx.compose.animation.SharedTransitionLayout
|
import androidx.compose.animation.SharedTransitionLayout
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.scaleOut
|
|
||||||
import androidx.compose.animation.slideInHorizontally
|
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.State
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
import androidx.lifecycle.viewmodel.initializer
|
|
||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
|
||||||
import androidx.navigation3.runtime.NavEntry
|
import androidx.navigation3.runtime.NavEntry
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
import me.kavishdevar.librepods.LibrePodsApplication
|
import me.kavishdevar.librepods.LibrePodsApplication
|
||||||
import me.kavishdevar.librepods.bluetooth.MacAddress
|
import me.kavishdevar.librepods.bluetooth.MacAddress
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
|
|
||||||
import me.kavishdevar.librepods.data.updates.updates
|
|
||||||
import me.kavishdevar.librepods.devices.AppleDevice
|
import me.kavishdevar.librepods.devices.AppleDevice
|
||||||
import me.kavishdevar.librepods.devices.ConnectionState
|
import me.kavishdevar.librepods.devices.ConnectionState
|
||||||
import me.kavishdevar.librepods.devices.Device
|
import me.kavishdevar.librepods.devices.Device
|
||||||
import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen
|
|
||||||
import me.kavishdevar.librepods.presentation.screens.BLESettingsScreenRoute
|
|
||||||
import me.kavishdevar.librepods.presentation.screens.DeviceListRoute
|
|
||||||
import me.kavishdevar.librepods.presentation.screens.OpenSourceLicensesScreen
|
|
||||||
import me.kavishdevar.librepods.presentation.screens.PurchaseScreen
|
|
||||||
import me.kavishdevar.librepods.presentation.screens.ReleaseNotesScreen
|
|
||||||
import me.kavishdevar.librepods.presentation.screens.TroubleshootingScreen
|
|
||||||
import me.kavishdevar.librepods.presentation.screens.apple.AccessibilitySettingsScreen
|
|
||||||
import me.kavishdevar.librepods.presentation.screens.apple.AdaptiveStrengthScreen
|
|
||||||
import me.kavishdevar.librepods.presentation.screens.apple.AirPodsSettingsRoute
|
|
||||||
import me.kavishdevar.librepods.presentation.screens.apple.CallControlScreen
|
|
||||||
import me.kavishdevar.librepods.presentation.screens.apple.DebugRoute
|
|
||||||
import me.kavishdevar.librepods.presentation.screens.apple.EqualizerRoute
|
|
||||||
import me.kavishdevar.librepods.presentation.screens.apple.HeadTrackingScreen
|
|
||||||
import me.kavishdevar.librepods.presentation.screens.apple.HearingAidAdjustmentsScreen
|
|
||||||
import me.kavishdevar.librepods.presentation.screens.apple.HearingAidScreen
|
|
||||||
import me.kavishdevar.librepods.presentation.screens.apple.HearingProtectionScreen
|
|
||||||
import me.kavishdevar.librepods.presentation.screens.apple.LongPress
|
|
||||||
import me.kavishdevar.librepods.presentation.screens.apple.MicrophoneSettingsRoute
|
|
||||||
import me.kavishdevar.librepods.presentation.screens.apple.RecordingScreenRoute
|
|
||||||
import me.kavishdevar.librepods.presentation.screens.apple.RenameScreen
|
|
||||||
import me.kavishdevar.librepods.presentation.screens.apple.TransparencySettingsScreen
|
|
||||||
import me.kavishdevar.librepods.presentation.screens.apple.UpdateHearingTestRoute
|
|
||||||
import me.kavishdevar.librepods.presentation.screens.apple.VersionScreen
|
|
||||||
import me.kavishdevar.librepods.presentation.screens.onboarding.OnboardingScreen
|
|
||||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
|
||||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
|
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
|
|
||||||
import me.kavishdevar.librepods.repository.RecordingRepository
|
|
||||||
|
|
||||||
@OptIn(ExperimentalHazeMaterialsApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AppNavGraph(
|
fun AppNavGraph(
|
||||||
|
backStack: NavBackStack<Screen>,
|
||||||
|
devicesState: State<Map<MacAddress, Device<*, *, *>>>,
|
||||||
showReleaseNotes: Boolean = false,
|
showReleaseNotes: Boolean = false,
|
||||||
updatesShown: () -> Unit = {},
|
updatesShown: () -> Unit = {},
|
||||||
onboardingComplete: () -> Unit = {},
|
onboardingComplete: () -> Unit = {},
|
||||||
backStack: SnapshotStateList<Screen>,
|
|
||||||
devicesState: State<Map<MacAddress, Device<*, *, *>>>
|
|
||||||
) {
|
) {
|
||||||
|
val backRequests = remember { Channel<CompletableDeferred<Unit>>(Channel.BUFFERED) }
|
||||||
|
|
||||||
val devices by devicesState
|
val devices by devicesState
|
||||||
|
|
||||||
val navigate: (Screen) -> Unit = { screen ->
|
|
||||||
backStack.add(screen)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun navigateToPurchase() {
|
|
||||||
navigate(Screen.Purchase)
|
|
||||||
}
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
val appDataRepository by lazy { (context.applicationContext as LibrePodsApplication).appDataRepository }
|
val appDataRepository by lazy { (context.applicationContext as LibrePodsApplication).appDataRepository }
|
||||||
val recordingRepository = RecordingRepository(context)
|
val recordingRepository by lazy { (context.applicationContext as LibrePodsApplication).recordingRepository }
|
||||||
|
val heartRateRepository by lazy { (context.applicationContext as LibrePodsApplication).heartRateRepository }
|
||||||
|
|
||||||
val currentDevice: Device<*, *, *>? =
|
val currentDevice: Device<*, *, *>? = devices[backStack.lastOrNull()?.let { (it as? DeviceScreen)?.macAddress }]
|
||||||
devices[backStack.lastOrNull()?.let { (it as? DeviceScreen)?.macAddress }]
|
|
||||||
|
|
||||||
@SuppressLint("UnrememberedMutableState")
|
@SuppressLint("UnrememberedMutableState")
|
||||||
val currentConnectionState by (currentDevice as? AppleDevice)?.connectionState?.collectAsState()
|
val currentConnectionState by (currentDevice as? AppleDevice)?.connectionState?.collectAsState()?: mutableStateOf(ConnectionState.DISCONNECTED)
|
||||||
?: mutableStateOf(ConnectionState.DISCONNECTED)
|
|
||||||
|
|
||||||
if (currentConnectionState == ConnectionState.DISCONNECTED) {
|
LaunchedEffect(currentConnectionState) {
|
||||||
// not sure how we will be able to navigate to another device from one device, but just in case we had two different devices in the backstack, we will remove DeviceScreens only of the disconnected device from the backstack
|
if (currentConnectionState == ConnectionState.DISCONNECTED) {
|
||||||
while (backStack.isNotEmpty() && backStack.last() is DeviceScreen && (backStack.last() as DeviceScreen).macAddress == currentDevice?.macAddress) {
|
while (
|
||||||
backStack.removeAt(backStack.lastIndex)
|
backStack.size > 1 &&
|
||||||
|
backStack.lastOrNull() is DeviceScreen
|
||||||
|
) {
|
||||||
|
val completed = CompletableDeferred<Unit>()
|
||||||
|
backRequests.send(completed)
|
||||||
|
completed.await()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
val appSettings by appDataRepository.settings.collectAsState()
|
||||||
|
|
||||||
SharedTransitionLayout {
|
val sceneStrategy = remember(appSettings.swipeAnywhereForBack) {
|
||||||
NavDisplay(
|
SwipeBackSceneStrategy<Screen>(
|
||||||
sharedTransitionScope = this,
|
enabled = appSettings.swipeAnywhereForBack,
|
||||||
backStack = backStack,
|
backRequests = backRequests,
|
||||||
onBack = {
|
onDismiss = {
|
||||||
if (backStack.size > 1) {
|
if (backStack.size > 1) {
|
||||||
backStack.removeAt(backStack.lastIndex)
|
backStack.removeAt(backStack.lastIndex)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
entryProvider = { screen ->
|
|
||||||
when (screen) {
|
|
||||||
Screen.Onboarding ->
|
|
||||||
NavEntry(screen) {
|
|
||||||
OnboardingScreen {
|
|
||||||
onboardingComplete()
|
|
||||||
if (showReleaseNotes) navigate(Screen.ReleaseNotes) else navigate(
|
|
||||||
Screen.DeviceList
|
|
||||||
)
|
|
||||||
backStack.remove(screen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Screen.DeviceList ->
|
|
||||||
NavEntry(screen) {
|
|
||||||
DeviceListRoute(
|
|
||||||
devices = devices,
|
|
||||||
navigateToDevice = { macAddress ->
|
|
||||||
when (devices[macAddress]) {
|
|
||||||
is AppleDevice -> navigate(Screen.AppleScreen(macAddress))
|
|
||||||
else -> {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is Screen.AppleScreen ->
|
|
||||||
NavEntry(screen) {
|
|
||||||
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
|
|
||||||
|
|
||||||
val factory = viewModelFactory {
|
|
||||||
initializer {
|
|
||||||
AppleViewModel(
|
|
||||||
device = device,
|
|
||||||
recordingRepository = recordingRepository,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
|
|
||||||
|
|
||||||
AirPodsSettingsRoute(
|
|
||||||
viewModel = appleViewModel,
|
|
||||||
navigateToRename = { navigate(Screen.Rename(screen.macAddress)) },
|
|
||||||
navigateToHearingProtection = {
|
|
||||||
navigate(
|
|
||||||
Screen.HearingProtection(
|
|
||||||
screen.macAddress
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigateToHearingAid = { navigate(Screen.HearingAid(screen.macAddress)) },
|
|
||||||
navigateToLeftLongPress = {
|
|
||||||
navigate(
|
|
||||||
Screen.LongPress(screen.macAddress, "Left")
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigateToRightLongPress = {
|
|
||||||
navigate(
|
|
||||||
Screen.LongPress(screen.macAddress, "Right")
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigateToPurchase = { navigate(Screen.Purchase) },
|
|
||||||
navigateToAdaptiveStrength = {
|
|
||||||
navigate(
|
|
||||||
Screen.AdaptiveStrength(
|
|
||||||
screen.macAddress
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigateToEqualizer = { navigate(Screen.Equalizer(screen.macAddress)) },
|
|
||||||
navigateToHeadTracking = { navigate(Screen.HeadTracking(screen.macAddress)) },
|
|
||||||
navigateToAccessibility = { navigate(Screen.Accessibility(screen.macAddress)) },
|
|
||||||
navigateToVersion = { navigate(Screen.VersionInfo(screen.macAddress)) },
|
|
||||||
navigateToCallControlScreen = {
|
|
||||||
navigate(
|
|
||||||
Screen.CallControl(
|
|
||||||
screen.macAddress,
|
|
||||||
it
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigateToMicrophoneSettings = {
|
|
||||||
navigate(
|
|
||||||
Screen.MicrophoneSettings(
|
|
||||||
screen.macAddress
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigateToRecordingScreen = { navigate(Screen.Recording(screen.macAddress)) },
|
|
||||||
navigateToDebugScreen = { navigate(Screen.Debug(screen.macAddress)) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is Screen.Rename ->
|
|
||||||
NavEntry(screen) {
|
|
||||||
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
|
|
||||||
|
|
||||||
val factory = viewModelFactory {
|
|
||||||
initializer {
|
|
||||||
AppleViewModel(
|
|
||||||
device = device,
|
|
||||||
recordingRepository = recordingRepository,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
|
|
||||||
|
|
||||||
RenameScreen(appleViewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
Screen.AppSettings ->
|
|
||||||
NavEntry(screen) {
|
|
||||||
val factory = viewModelFactory {
|
|
||||||
initializer {
|
|
||||||
AppSettingsViewModel(
|
|
||||||
appDataRepository = appDataRepository,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val appSettingsViewModel: AppSettingsViewModel = viewModel(factory = factory)
|
|
||||||
|
|
||||||
AppSettingsScreen(
|
|
||||||
viewModel = appSettingsViewModel,
|
|
||||||
navigateToPurchase = ::navigateToPurchase,
|
|
||||||
navigateToTroubleshooting = { navigate(Screen.Troubleshooting) },
|
|
||||||
navigateToOpenSourceLicenses = { navigate(Screen.OpenSourceLicenses) },
|
|
||||||
navigateToReleaseNotesScreen = { navigate(Screen.ReleaseNotes) },
|
|
||||||
navigateToBleSettingsScreen = { navigate(Screen.BLESettings) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Screen.Troubleshooting ->
|
|
||||||
NavEntry(screen) {
|
|
||||||
TroubleshootingScreen()
|
|
||||||
}
|
|
||||||
|
|
||||||
is Screen.HeadTracking ->
|
|
||||||
NavEntry(screen) {
|
|
||||||
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
|
|
||||||
|
|
||||||
val factory = viewModelFactory {
|
|
||||||
initializer {
|
|
||||||
AppleViewModel(
|
|
||||||
device = device,
|
|
||||||
recordingRepository = recordingRepository,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
|
|
||||||
|
|
||||||
HeadTrackingScreen(appleViewModel, ::navigateToPurchase)
|
|
||||||
}
|
|
||||||
|
|
||||||
is Screen.Accessibility ->
|
|
||||||
NavEntry(screen) {
|
|
||||||
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
|
|
||||||
val factory = viewModelFactory {
|
|
||||||
initializer {
|
|
||||||
AppleViewModel(
|
|
||||||
device = device,
|
|
||||||
recordingRepository = recordingRepository,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
|
|
||||||
|
|
||||||
AccessibilitySettingsScreen(
|
|
||||||
viewModel = appleViewModel,
|
|
||||||
navigateToPurchase = ::navigateToPurchase,
|
|
||||||
navigateToTransparencyCustomization = {
|
|
||||||
navigate(
|
|
||||||
Screen.TransparencyCustomization(
|
|
||||||
screen.macAddress
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is Screen.TransparencyCustomization ->
|
|
||||||
NavEntry(screen) {
|
|
||||||
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
|
|
||||||
|
|
||||||
val factory = viewModelFactory {
|
|
||||||
initializer {
|
|
||||||
AppleViewModel(
|
|
||||||
device = device,
|
|
||||||
recordingRepository = recordingRepository,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
|
|
||||||
|
|
||||||
TransparencySettingsScreen(appleViewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
is Screen.HearingAid ->
|
|
||||||
NavEntry(screen) {
|
|
||||||
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
|
|
||||||
|
|
||||||
val factory = viewModelFactory {
|
|
||||||
initializer {
|
|
||||||
AppleViewModel(
|
|
||||||
device = device,
|
|
||||||
recordingRepository = recordingRepository,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
|
|
||||||
|
|
||||||
HearingAidScreen(
|
|
||||||
viewModel = appleViewModel,
|
|
||||||
onNavigateHearingAidAdjustments = {
|
|
||||||
navigate(
|
|
||||||
Screen.HearingAidAdjustments(
|
|
||||||
screen.macAddress
|
|
||||||
)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onNavigateHearingTest = { navigate(Screen.UpdateHearingTest(screen.macAddress)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is Screen.HearingAidAdjustments ->
|
|
||||||
NavEntry(screen) {
|
|
||||||
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
|
|
||||||
|
|
||||||
val factory = viewModelFactory {
|
|
||||||
initializer {
|
|
||||||
AppleViewModel(
|
|
||||||
device = device,
|
|
||||||
recordingRepository = recordingRepository,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
|
|
||||||
|
|
||||||
HearingAidAdjustmentsScreen(appleViewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
is Screen.AdaptiveStrength ->
|
|
||||||
NavEntry(screen) {
|
|
||||||
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
|
|
||||||
|
|
||||||
val factory = viewModelFactory {
|
|
||||||
initializer {
|
|
||||||
AppleViewModel(
|
|
||||||
device = device,
|
|
||||||
recordingRepository = recordingRepository,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
|
|
||||||
|
|
||||||
AdaptiveStrengthScreen(appleViewModel, ::navigateToPurchase)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Screen.CameraControl ->
|
|
||||||
// NavEntry(screen) {
|
|
||||||
// CameraControlScreen(AppleViewModel)
|
|
||||||
// }
|
|
||||||
|
|
||||||
Screen.OpenSourceLicenses ->
|
|
||||||
NavEntry(screen) {
|
|
||||||
OpenSourceLicensesScreen()
|
|
||||||
}
|
|
||||||
|
|
||||||
is Screen.UpdateHearingTest ->
|
|
||||||
NavEntry(screen) {
|
|
||||||
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
|
|
||||||
|
|
||||||
val factory = viewModelFactory {
|
|
||||||
initializer {
|
|
||||||
AppleViewModel(
|
|
||||||
device = device,
|
|
||||||
recordingRepository = recordingRepository,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
|
|
||||||
|
|
||||||
UpdateHearingTestRoute(appleViewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
is Screen.VersionInfo ->
|
|
||||||
NavEntry(screen) {
|
|
||||||
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
|
|
||||||
|
|
||||||
val factory = viewModelFactory {
|
|
||||||
initializer {
|
|
||||||
AppleViewModel(
|
|
||||||
device = device,
|
|
||||||
recordingRepository = recordingRepository,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
|
|
||||||
|
|
||||||
VersionScreen(appleViewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
is Screen.HearingProtection ->
|
|
||||||
NavEntry(screen) {
|
|
||||||
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
|
|
||||||
|
|
||||||
val factory = viewModelFactory {
|
|
||||||
initializer {
|
|
||||||
AppleViewModel(
|
|
||||||
device = device,
|
|
||||||
recordingRepository = recordingRepository,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
|
|
||||||
|
|
||||||
HearingProtectionScreen(
|
|
||||||
viewModel = appleViewModel,
|
|
||||||
navigateToPurchase = ::navigateToPurchase
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is Screen.Purchase ->
|
|
||||||
NavEntry(screen) {
|
|
||||||
val vm: PurchaseViewModel = viewModel()
|
|
||||||
PurchaseScreen(vm, backStack)
|
|
||||||
}
|
|
||||||
|
|
||||||
is Screen.Equalizer ->
|
|
||||||
NavEntry(screen) {
|
|
||||||
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
|
|
||||||
|
|
||||||
val factory = viewModelFactory {
|
|
||||||
initializer {
|
|
||||||
AppleViewModel(
|
|
||||||
device = device,
|
|
||||||
recordingRepository = recordingRepository,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
|
|
||||||
|
|
||||||
EqualizerRoute(appleViewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
is Screen.LongPress ->
|
|
||||||
NavEntry(screen) {
|
|
||||||
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
|
|
||||||
|
|
||||||
val factory = viewModelFactory {
|
|
||||||
initializer {
|
|
||||||
AppleViewModel(
|
|
||||||
device = device,
|
|
||||||
recordingRepository = recordingRepository,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
|
|
||||||
|
|
||||||
LongPress(
|
|
||||||
viewModel = appleViewModel,
|
|
||||||
name = screen.bud,
|
|
||||||
navigateToPurchase = ::navigateToPurchase
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is Screen.CallControl ->
|
|
||||||
NavEntry(screen) {
|
|
||||||
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
|
|
||||||
|
|
||||||
val factory = viewModelFactory {
|
|
||||||
initializer {
|
|
||||||
AppleViewModel(
|
|
||||||
device = device,
|
|
||||||
recordingRepository = recordingRepository,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
|
|
||||||
|
|
||||||
CallControlScreen(
|
|
||||||
viewModel = appleViewModel,
|
|
||||||
action = screen.action,
|
|
||||||
onCallControlValueChanged = { flipped ->
|
|
||||||
device.setControlCommand(
|
|
||||||
ControlCommandIdentifier.CALL_MANAGEMENT_CONFIG,
|
|
||||||
if (flipped) byteArrayOf(0x00, 0x02) else byteArrayOf(
|
|
||||||
0x00,
|
|
||||||
0x03
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is Screen.MicrophoneSettings ->
|
|
||||||
NavEntry(screen) {
|
|
||||||
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
|
|
||||||
|
|
||||||
val factory = viewModelFactory {
|
|
||||||
initializer {
|
|
||||||
AppleViewModel(
|
|
||||||
device = device,
|
|
||||||
recordingRepository = recordingRepository,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
|
|
||||||
|
|
||||||
MicrophoneSettingsRoute(viewModel = appleViewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
is Screen.ReleaseNotes ->
|
|
||||||
NavEntry(screen) {
|
|
||||||
ReleaseNotesScreen(
|
|
||||||
updates = updates,
|
|
||||||
releaseNotesShown = {
|
|
||||||
if (showReleaseNotes) {
|
|
||||||
navigate(Screen.DeviceList)
|
|
||||||
backStack.remove(screen)
|
|
||||||
updatesShown()
|
|
||||||
} else {
|
|
||||||
backStack.removeAt(backStack.lastIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
is Screen.Recording ->
|
|
||||||
NavEntry(screen) {
|
|
||||||
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
|
|
||||||
|
|
||||||
val factory = viewModelFactory {
|
|
||||||
initializer {
|
|
||||||
AppleViewModel(
|
|
||||||
device = device,
|
|
||||||
recordingRepository = recordingRepository,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
|
|
||||||
|
|
||||||
RecordingScreenRoute(appleViewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
is Screen.Debug ->
|
|
||||||
NavEntry(screen) {
|
|
||||||
val device = devices[screen.macAddress] as? AppleDevice ?: return@NavEntry
|
|
||||||
|
|
||||||
val factory = viewModelFactory {
|
|
||||||
initializer {
|
|
||||||
AppleViewModel(
|
|
||||||
device = device,
|
|
||||||
recordingRepository = recordingRepository,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val appleViewModel: AppleViewModel = viewModel(key = "${screen.macAddress.value}:${device.connectionNumber}", factory = factory)
|
|
||||||
|
|
||||||
DebugRoute(appleViewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
is Screen.BLESettings ->
|
|
||||||
NavEntry(screen) {
|
|
||||||
val factory = viewModelFactory {
|
|
||||||
initializer {
|
|
||||||
AppSettingsViewModel(
|
|
||||||
appDataRepository = appDataRepository,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val appSettingsViewModel: AppSettingsViewModel = viewModel(factory = factory)
|
|
||||||
|
|
||||||
BLESettingsScreenRoute(
|
|
||||||
viewModel = appSettingsViewModel
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
transitionSpec = {
|
|
||||||
slideInHorizontally { it } togetherWith slideOutHorizontally { -it / 4 }
|
|
||||||
},
|
|
||||||
popTransitionSpec = {
|
|
||||||
slideInHorizontally { -it / 4 } togetherWith slideOutHorizontally { it }
|
|
||||||
},
|
|
||||||
predictivePopTransitionSpec = { swipeEdge ->
|
|
||||||
if (m3eEnabled) {
|
|
||||||
val enterOffset: (Int) -> Int =
|
|
||||||
if (swipeEdge == EDGE_LEFT) {
|
|
||||||
{ -it / 6 }
|
|
||||||
} else {
|
|
||||||
{ it / 6 }
|
|
||||||
}
|
|
||||||
|
|
||||||
val exitOffset: (Int) -> Int =
|
|
||||||
if (swipeEdge == EDGE_LEFT) {
|
|
||||||
{ it / 8 }
|
|
||||||
} else {
|
|
||||||
{ -it / 8 }
|
|
||||||
}
|
|
||||||
|
|
||||||
fadeIn(
|
|
||||||
animationSpec = tween(250)
|
|
||||||
) +
|
|
||||||
slideInHorizontally(
|
|
||||||
initialOffsetX = enterOffset,
|
|
||||||
animationSpec = tween(250)
|
|
||||||
) togetherWith
|
|
||||||
fadeOut(
|
|
||||||
targetAlpha = 0.75f,
|
|
||||||
animationSpec = tween(250)
|
|
||||||
) +
|
|
||||||
scaleOut(
|
|
||||||
targetScale = 0.85f,
|
|
||||||
animationSpec = tween(250)
|
|
||||||
) +
|
|
||||||
slideOutHorizontally(
|
|
||||||
targetOffsetX = exitOffset,
|
|
||||||
animationSpec = tween(250)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
slideInHorizontally { -it / 4 } togetherWith slideOutHorizontally { it }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SharedTransitionLayout {
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalSharedTransitionScope provides this,
|
||||||
|
) {
|
||||||
|
NavDisplay(
|
||||||
|
sharedTransitionScope = this,
|
||||||
|
backStack = backStack,
|
||||||
|
sceneStrategies = listOf(sceneStrategy),
|
||||||
|
onBack = {
|
||||||
|
if (backStack.size > 1) {
|
||||||
|
backStack.removeAt(backStack.lastIndex)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
entryProvider = { screen ->
|
||||||
|
NavEntry(screen) {
|
||||||
|
RenderScreenContent(
|
||||||
|
screen = screen,
|
||||||
|
backStack = backStack,
|
||||||
|
backRequests = backRequests,
|
||||||
|
devices = devices,
|
||||||
|
appDataRepository = appDataRepository,
|
||||||
|
recordingRepository = recordingRepository,
|
||||||
|
heartRateRepository = heartRateRepository,
|
||||||
|
showReleaseNotes = showReleaseNotes,
|
||||||
|
updatesShown = updatesShown,
|
||||||
|
onboardingComplete = onboardingComplete
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
transitionSpec = { EnterTransition.None togetherWith ExitTransition.None },
|
||||||
|
popTransitionSpec = { EnterTransition.None togetherWith ExitTransition.None },
|
||||||
|
predictivePopTransitionSpec = { EnterTransition.None togetherWith ExitTransition.None }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-146
@@ -1,35 +1,26 @@
|
|||||||
package me.kavishdevar.librepods.presentation.navigation
|
package me.kavishdevar.librepods.presentation.navigation
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
|
||||||
import androidx.compose.material.icons.outlined.Settings
|
|
||||||
import androidx.compose.material3.FilledTonalIconButton
|
|
||||||
import androidx.compose.material3.FilledTonalIconToggleButton
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButtonDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.minimumInteractiveComponentSize
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.State
|
import androidx.compose.runtime.State
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.runtime.saveable.rememberSerializable
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
import androidx.compose.ui.unit.dp
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import com.kyant.backdrop.backdrops.LayerBackdrop
|
import kotlinx.serialization.serializer
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.bluetooth.MacAddress
|
import me.kavishdevar.librepods.bluetooth.MacAddress
|
||||||
import me.kavishdevar.librepods.devices.AppleDevice
|
import me.kavishdevar.librepods.devices.AppleDevice
|
||||||
|
import me.kavishdevar.librepods.devices.ConnectionState
|
||||||
import me.kavishdevar.librepods.devices.Device
|
import me.kavishdevar.librepods.devices.Device
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledIconButton
|
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
@Composable
|
||||||
import me.kavishdevar.librepods.presentation.icons.LocalIcons
|
fun rememberScreenNavBackStack(vararg elements: Screen): NavBackStack<Screen> {
|
||||||
import me.kavishdevar.librepods.presentation.icons.MaterialIcons
|
return rememberSerializable(serializer = serializer()) {
|
||||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
NavBackStack(*elements)
|
||||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NavigationRoot(
|
fun NavigationRoot(
|
||||||
@@ -41,133 +32,33 @@ fun NavigationRoot(
|
|||||||
) {
|
) {
|
||||||
val devices by devicesState
|
val devices by devicesState
|
||||||
|
|
||||||
val backStack = remember {
|
val backStack = rememberScreenNavBackStack(
|
||||||
mutableStateListOf(
|
when {
|
||||||
when {
|
showOnboarding -> Screen.Onboarding
|
||||||
showOnboarding -> Screen.Onboarding
|
showReleaseNotes -> Screen.ReleaseNotes
|
||||||
showReleaseNotes -> Screen.ReleaseNotes
|
else -> Screen.DeviceList
|
||||||
else -> Screen.DeviceList
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
val connectedDevice = devices.values.firstOrNull { it.connectionState.collectAsState().value == ConnectionState.CONNECTED }
|
||||||
|
|
||||||
|
LaunchedEffect(connectedDevice) {
|
||||||
|
if (connectedDevice != null) {
|
||||||
|
val targetScreen = when (connectedDevice) {
|
||||||
|
is AppleDevice -> Screen.AppleScreen(connectedDevice.macAddress)
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val currentScreen = backStack.last()
|
if (targetScreen !in backStack) {
|
||||||
|
backStack.add(targetScreen)
|
||||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
|
||||||
|
|
||||||
val title = when (currentScreen) {
|
|
||||||
Screen.Onboarding -> ""
|
|
||||||
Screen.DeviceList -> stringResource(R.string.app_name)
|
|
||||||
is Screen.AppleScreen -> devices[currentScreen.macAddress]?.metadata?.collectAsState()?.value?.name ?: currentScreen.macAddress.value
|
|
||||||
is Screen.Accessibility -> stringResource(R.string.accessibility)
|
|
||||||
is Screen.AdaptiveStrength -> stringResource(R.string.customize_adaptive_audio)
|
|
||||||
Screen.AppSettings -> stringResource(R.string.settings)
|
|
||||||
// Screen.CameraControl -> stringResource(R.string.camera_control)
|
|
||||||
is Screen.Equalizer -> stringResource(R.string.equalizer)
|
|
||||||
is Screen.HeadTracking -> stringResource(R.string.head_tracking)
|
|
||||||
is Screen.HearingAid -> stringResource(R.string.hearing_aid)
|
|
||||||
is Screen.HearingAidAdjustments -> stringResource(R.string.adjustments)
|
|
||||||
is Screen.HearingProtection -> stringResource(R.string.hearing_protection)
|
|
||||||
is Screen.LongPress -> currentScreen.bud
|
|
||||||
Screen.OpenSourceLicenses -> stringResource(R.string.open_source_licenses)
|
|
||||||
Screen.Purchase -> stringResource(R.string.unlock_advanced_features)
|
|
||||||
is Screen.Rename -> stringResource(R.string.name)
|
|
||||||
is Screen.TransparencyCustomization -> stringResource(R.string.customize_transparency_mode)
|
|
||||||
Screen.Troubleshooting -> stringResource(R.string.troubleshooting)
|
|
||||||
is Screen.UpdateHearingTest -> stringResource(R.string.update_hearing_test)
|
|
||||||
is Screen.VersionInfo -> stringResource(R.string.version)
|
|
||||||
is Screen.CallControl -> currentScreen.action
|
|
||||||
is Screen.MicrophoneSettings -> stringResource(R.string.microphone_mode)
|
|
||||||
Screen.ReleaseNotes -> ""
|
|
||||||
is Screen.Recording -> stringResource(R.string.recorder)
|
|
||||||
is Screen.Debug -> "debug"
|
|
||||||
is Screen.BLESettings -> stringResource(R.string.ble_settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
// is this a bad idea? probably. I can't think of a better way without having to pass around a shouldShowBackButton to each screen to pass to each scaffold
|
|
||||||
val actionButtons = when (currentScreen) {
|
|
||||||
is Screen.AppleScreen, is Screen.DeviceList -> listOf<@Composable (backdrop: LayerBackdrop) -> Unit>(
|
|
||||||
{ scaffoldBackdrop ->
|
|
||||||
if (m3eEnabled) {
|
|
||||||
FilledTonalIconButton(
|
|
||||||
onClick = { backStack.add(Screen.AppSettings) },
|
|
||||||
modifier = Modifier
|
|
||||||
.minimumInteractiveComponentSize()
|
|
||||||
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
|
|
||||||
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.Settings,
|
|
||||||
contentDescription = "settings",
|
|
||||||
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
StyledIconButton(
|
|
||||||
onClick = { backStack.add(Screen.AppSettings) },
|
|
||||||
backdrop = scaffoldBackdrop
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = LocalIcons.current.Settings,
|
|
||||||
contentDescription = "Settings",
|
|
||||||
tint = MaterialTheme.colorScheme.onBackground
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
is Screen.HeadTracking -> listOf<@Composable (backdrop: LayerBackdrop) -> Unit>(
|
|
||||||
{ scaffoldBackdrop ->
|
|
||||||
val device = devices[currentScreen.macAddress] as? AppleDevice? ?: return@listOf
|
|
||||||
|
|
||||||
val state by device.state.collectAsState()
|
|
||||||
|
|
||||||
if (m3eEnabled) {
|
|
||||||
FilledTonalIconToggleButton(
|
|
||||||
checked = state.headTrackingActive,
|
|
||||||
onCheckedChange = { if (it) device.startHeadTracking() else device.stopHeadTracking() },
|
|
||||||
modifier = Modifier
|
|
||||||
.minimumInteractiveComponentSize()
|
|
||||||
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
|
|
||||||
shape = IconButtonDefaults.mediumRoundShape
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (state.headTrackingActive) MaterialIcons.Pause else Icons.Default.PlayArrow,
|
|
||||||
contentDescription = "Play/Pause",
|
|
||||||
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
StyledIconButton(
|
|
||||||
onClick = if (!state.headTrackingActive) device::startHeadTracking else device::stopHeadTracking,
|
|
||||||
backdrop = scaffoldBackdrop
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (state.headTrackingActive) LocalIcons.current.Pause else LocalIcons.current.Play,
|
|
||||||
contentDescription = "Play/Pause",
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onBackground
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
else -> listOf()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
StyledScaffold(
|
AppNavGraph(
|
||||||
visible = currentScreen.showTopBar,
|
backStack = backStack,
|
||||||
title = title,
|
devicesState = devicesState,
|
||||||
showBackButton = backStack.size > 1,
|
showReleaseNotes = showReleaseNotes,
|
||||||
onNavigateBack = { backStack.removeAt(backStack.lastIndex) },
|
updatesShown = updatesShown,
|
||||||
actionButtons = actionButtons
|
onboardingComplete = onboardingComplete,
|
||||||
) {
|
)
|
||||||
AppNavGraph(
|
|
||||||
showReleaseNotes = showReleaseNotes,
|
|
||||||
updatesShown = updatesShown,
|
|
||||||
onboardingComplete = onboardingComplete,
|
|
||||||
backStack = backStack,
|
|
||||||
devicesState = devicesState,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+438
@@ -0,0 +1,438 @@
|
|||||||
|
package me.kavishdevar.librepods.presentation.navigation
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.bluetooth.MacAddress
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
|
||||||
|
import me.kavishdevar.librepods.data.updates.updates
|
||||||
|
import me.kavishdevar.librepods.devices.AppleDevice
|
||||||
|
import me.kavishdevar.librepods.devices.Device
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.BLESettingsScreenRoute
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.DeviceListScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.OpenSourceLicensesScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.PurchaseScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.ReleaseNotesScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.apple.AccessibilitySettingsScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.apple.AdaptiveStrengthScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.apple.AppleSettingsRoute
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.apple.CallControlScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.apple.DebugRoute
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.apple.EqualizerRoute
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.apple.HeadTrackingScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.apple.HearingAidAdjustmentsScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.apple.HearingAidScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.apple.HearingProtectionScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.apple.HeartRateRoute
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.apple.LongPress
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.apple.MicrophoneSettingsRoute
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.apple.RecordingScreenRoute
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.apple.RenameScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.apple.TransparencySettingsScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.apple.UpdateHearingTestRoute
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.apple.VersionScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.screens.onboarding.OnboardingScreen
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
|
||||||
|
import me.kavishdevar.librepods.repository.AppDataRepository
|
||||||
|
import me.kavishdevar.librepods.repository.HeartRateRepository
|
||||||
|
import me.kavishdevar.librepods.repository.RecordingRepository
|
||||||
|
|
||||||
|
|
||||||
|
private fun createAppleViewModelFactory(
|
||||||
|
device: AppleDevice,
|
||||||
|
appDataRepository: AppDataRepository,
|
||||||
|
recordingRepository: RecordingRepository,
|
||||||
|
heartRateRepository: HeartRateRepository
|
||||||
|
): ViewModelProvider.Factory = viewModelFactory {
|
||||||
|
initializer {
|
||||||
|
AppleViewModel(
|
||||||
|
device = device,
|
||||||
|
appDataRepository = appDataRepository,
|
||||||
|
recordingRepository = recordingRepository,
|
||||||
|
heartRateRepository = heartRateRepository
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RenderScreenContent(
|
||||||
|
screen: Screen,
|
||||||
|
backStack: NavBackStack<Screen>,
|
||||||
|
backRequests: Channel<CompletableDeferred<Unit>>,
|
||||||
|
devices: Map<MacAddress, Device<*, *, *>>,
|
||||||
|
appDataRepository: AppDataRepository,
|
||||||
|
recordingRepository: RecordingRepository,
|
||||||
|
heartRateRepository: HeartRateRepository,
|
||||||
|
showReleaseNotes: Boolean,
|
||||||
|
updatesShown: () -> Unit,
|
||||||
|
onboardingComplete: () -> Unit
|
||||||
|
) {
|
||||||
|
val navigate: (Screen) -> Unit = { target -> backStack.add(target) }
|
||||||
|
fun navigateToPurchase() = navigate(Screen.Purchase)
|
||||||
|
|
||||||
|
val navigateBack: (() -> Unit)? = if (backStack.size > 1) {
|
||||||
|
{
|
||||||
|
val completed = CompletableDeferred<Unit>()
|
||||||
|
backRequests.trySend(completed)
|
||||||
|
}
|
||||||
|
} else null
|
||||||
|
|
||||||
|
when (screen) {
|
||||||
|
Screen.Onboarding -> {
|
||||||
|
OnboardingScreen {
|
||||||
|
onboardingComplete()
|
||||||
|
if (showReleaseNotes) navigate(Screen.ReleaseNotes) else navigate(Screen.DeviceList)
|
||||||
|
backStack.remove(screen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Screen.DeviceList -> {
|
||||||
|
DeviceListScreen(
|
||||||
|
devices = devices,
|
||||||
|
navigateToAppSettings = { navigate(Screen.AppSettings) },
|
||||||
|
navigateToDevice = { macAddress ->
|
||||||
|
when (devices[macAddress]) {
|
||||||
|
is AppleDevice -> navigate(Screen.AppleScreen(macAddress))
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Screen.AppleScreen -> {
|
||||||
|
val device = devices[screen.macAddress] as? AppleDevice ?: return
|
||||||
|
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
|
||||||
|
val appleViewModel: AppleViewModel = viewModel(
|
||||||
|
key = "${screen.macAddress.value}:${device.connectionNumber}",
|
||||||
|
factory = factory
|
||||||
|
)
|
||||||
|
|
||||||
|
val left = stringResource(R.string.left)
|
||||||
|
val right = stringResource(R.string.right)
|
||||||
|
|
||||||
|
AppleSettingsRoute(
|
||||||
|
viewModel = appleViewModel,
|
||||||
|
navigateBack = navigateBack,
|
||||||
|
navigateToRename = { navigate(Screen.Rename(screen.macAddress)) },
|
||||||
|
navigateToHearingProtection = { navigate(Screen.HearingProtection(screen.macAddress)) },
|
||||||
|
navigateToHearingAid = { navigate(Screen.HearingAid(screen.macAddress)) },
|
||||||
|
navigateToLeftLongPress = { navigate(Screen.LongPress(screen.macAddress, left)) },
|
||||||
|
navigateToRightLongPress = { navigate(Screen.LongPress(screen.macAddress, right)) },
|
||||||
|
navigateToPurchase = ::navigateToPurchase,
|
||||||
|
navigateToAdaptiveStrength = { navigate(Screen.AdaptiveStrength(screen.macAddress)) },
|
||||||
|
navigateToEqualizer = { navigate(Screen.Equalizer(screen.macAddress)) },
|
||||||
|
navigateToHeadTracking = { navigate(Screen.HeadTracking(screen.macAddress)) },
|
||||||
|
navigateToAccessibility = { navigate(Screen.Accessibility(screen.macAddress)) },
|
||||||
|
navigateToVersion = { navigate(Screen.VersionInfo(screen.macAddress)) },
|
||||||
|
navigateToCallControlScreen = { navigate(Screen.CallControl(screen.macAddress, it)) },
|
||||||
|
navigateToMicrophoneSettings = { navigate(Screen.MicrophoneSettings(screen.macAddress)) },
|
||||||
|
navigateToRecordingScreen = { navigate(Screen.Recording(screen.macAddress)) },
|
||||||
|
navigateToHeartRateScreen = { navigate(Screen.HeartRate(screen.macAddress)) },
|
||||||
|
navigateToDebugScreen = { navigate(Screen.Debug(screen.macAddress)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Screen.Rename -> {
|
||||||
|
val device = devices[screen.macAddress] as? AppleDevice ?: return
|
||||||
|
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
|
||||||
|
val appleViewModel: AppleViewModel = viewModel(
|
||||||
|
key = "${screen.macAddress.value}:${device.connectionNumber}",
|
||||||
|
factory = factory
|
||||||
|
)
|
||||||
|
RenameScreen(
|
||||||
|
viewModel = appleViewModel,
|
||||||
|
navigateBack = navigateBack,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Screen.AppSettings -> {
|
||||||
|
val factory = viewModelFactory {
|
||||||
|
initializer { AppSettingsViewModel(appDataRepository = appDataRepository) }
|
||||||
|
}
|
||||||
|
val appSettingsViewModel: AppSettingsViewModel = viewModel(factory = factory)
|
||||||
|
|
||||||
|
AppSettingsScreen(
|
||||||
|
viewModel = appSettingsViewModel,
|
||||||
|
navigateBack = navigateBack,
|
||||||
|
navigateToPurchase = ::navigateToPurchase,
|
||||||
|
navigateToOpenSourceLicenses = { navigate(Screen.OpenSourceLicenses) },
|
||||||
|
navigateToReleaseNotesScreen = { navigate(Screen.ReleaseNotes) },
|
||||||
|
navigateToBleSettingsScreen = { navigate(Screen.BLESettings) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Screen.HeadTracking -> {
|
||||||
|
val device = devices[screen.macAddress] as? AppleDevice ?: return
|
||||||
|
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
|
||||||
|
val appleViewModel: AppleViewModel = viewModel(
|
||||||
|
key = "${screen.macAddress.value}:${device.connectionNumber}",
|
||||||
|
factory = factory
|
||||||
|
)
|
||||||
|
HeadTrackingScreen(
|
||||||
|
viewModel = appleViewModel,
|
||||||
|
navigateBack = navigateBack,
|
||||||
|
navigateToPurchase = ::navigateToPurchase
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Screen.Accessibility -> {
|
||||||
|
val device = devices[screen.macAddress] as? AppleDevice ?: return
|
||||||
|
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
|
||||||
|
val appleViewModel: AppleViewModel = viewModel(
|
||||||
|
key = "${screen.macAddress.value}:${device.connectionNumber}",
|
||||||
|
factory = factory
|
||||||
|
)
|
||||||
|
|
||||||
|
AccessibilitySettingsScreen(
|
||||||
|
viewModel = appleViewModel,
|
||||||
|
navigateBack = navigateBack,
|
||||||
|
navigateToPurchase = ::navigateToPurchase,
|
||||||
|
navigateToTransparencyCustomization = {
|
||||||
|
navigate(Screen.TransparencyCustomization(screen.macAddress))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Screen.TransparencyCustomization -> {
|
||||||
|
val device = devices[screen.macAddress] as? AppleDevice ?: return
|
||||||
|
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
|
||||||
|
val appleViewModel: AppleViewModel = viewModel(
|
||||||
|
key = "${screen.macAddress.value}:${device.connectionNumber}",
|
||||||
|
factory = factory
|
||||||
|
)
|
||||||
|
TransparencySettingsScreen(
|
||||||
|
viewModel = appleViewModel,
|
||||||
|
navigateBack = navigateBack,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Screen.HearingAid -> {
|
||||||
|
val device = devices[screen.macAddress] as? AppleDevice ?: return
|
||||||
|
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
|
||||||
|
val appleViewModel: AppleViewModel = viewModel(
|
||||||
|
key = "${screen.macAddress.value}:${device.connectionNumber}",
|
||||||
|
factory = factory
|
||||||
|
)
|
||||||
|
|
||||||
|
HearingAidScreen(
|
||||||
|
viewModel = appleViewModel,
|
||||||
|
navigateBack = navigateBack,
|
||||||
|
navigateToHearingAidAdjustments = { navigate(Screen.HearingAidAdjustments(screen.macAddress)) },
|
||||||
|
navigateToHearingTest = { navigate(Screen.UpdateHearingTest(screen.macAddress)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Screen.HearingAidAdjustments -> {
|
||||||
|
val device = devices[screen.macAddress] as? AppleDevice ?: return
|
||||||
|
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
|
||||||
|
val appleViewModel: AppleViewModel = viewModel(
|
||||||
|
key = "${screen.macAddress.value}:${device.connectionNumber}",
|
||||||
|
factory = factory
|
||||||
|
)
|
||||||
|
HearingAidAdjustmentsScreen(
|
||||||
|
viewModel = appleViewModel,
|
||||||
|
navigateBack = navigateBack,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Screen.AdaptiveStrength -> {
|
||||||
|
val device = devices[screen.macAddress] as? AppleDevice ?: return
|
||||||
|
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
|
||||||
|
val appleViewModel: AppleViewModel = viewModel(
|
||||||
|
key = "${screen.macAddress.value}:${device.connectionNumber}",
|
||||||
|
factory = factory
|
||||||
|
)
|
||||||
|
AdaptiveStrengthScreen(
|
||||||
|
viewModel = appleViewModel,
|
||||||
|
navigateBack = navigateBack,
|
||||||
|
navigateToPurchase = ::navigateToPurchase
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Screen.OpenSourceLicenses -> OpenSourceLicensesScreen(navigateBack)
|
||||||
|
|
||||||
|
is Screen.UpdateHearingTest -> {
|
||||||
|
val device = devices[screen.macAddress] as? AppleDevice ?: return
|
||||||
|
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
|
||||||
|
val appleViewModel: AppleViewModel = viewModel(
|
||||||
|
key = "${screen.macAddress.value}:${device.connectionNumber}",
|
||||||
|
factory = factory
|
||||||
|
)
|
||||||
|
UpdateHearingTestRoute(
|
||||||
|
viewModel = appleViewModel,
|
||||||
|
navigateBack = navigateBack,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Screen.VersionInfo -> {
|
||||||
|
val device = devices[screen.macAddress] as? AppleDevice ?: return
|
||||||
|
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
|
||||||
|
val appleViewModel: AppleViewModel = viewModel(
|
||||||
|
key = "${screen.macAddress.value}:${device.connectionNumber}",
|
||||||
|
factory = factory
|
||||||
|
)
|
||||||
|
VersionScreen(
|
||||||
|
viewModel = appleViewModel,
|
||||||
|
navigateBack = navigateBack
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Screen.HearingProtection -> {
|
||||||
|
val device = devices[screen.macAddress] as? AppleDevice ?: return
|
||||||
|
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
|
||||||
|
val appleViewModel: AppleViewModel = viewModel(
|
||||||
|
key = "${screen.macAddress.value}:${device.connectionNumber}",
|
||||||
|
factory = factory
|
||||||
|
)
|
||||||
|
HearingProtectionScreen(
|
||||||
|
viewModel = appleViewModel,
|
||||||
|
navigateBack = navigateBack,
|
||||||
|
navigateToPurchase = ::navigateToPurchase
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Screen.Purchase -> {
|
||||||
|
val viewModel: PurchaseViewModel = viewModel()
|
||||||
|
PurchaseScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
navigateBack = navigateBack
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Screen.Equalizer -> {
|
||||||
|
val device = devices[screen.macAddress] as? AppleDevice ?: return
|
||||||
|
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
|
||||||
|
val appleViewModel: AppleViewModel = viewModel(
|
||||||
|
key = "${screen.macAddress.value}:${device.connectionNumber}",
|
||||||
|
factory = factory
|
||||||
|
)
|
||||||
|
EqualizerRoute(
|
||||||
|
viewModel = appleViewModel,
|
||||||
|
navigateBack = navigateBack
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Screen.LongPress -> {
|
||||||
|
val device = devices[screen.macAddress] as? AppleDevice ?: return
|
||||||
|
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
|
||||||
|
val appleViewModel: AppleViewModel = viewModel(
|
||||||
|
key = "${screen.macAddress.value}:${device.connectionNumber}",
|
||||||
|
factory = factory
|
||||||
|
)
|
||||||
|
LongPress(
|
||||||
|
viewModel = appleViewModel,
|
||||||
|
name = screen.bud,
|
||||||
|
navigateBack = navigateBack,
|
||||||
|
navigateToPurchase = ::navigateToPurchase
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Screen.CallControl -> {
|
||||||
|
val device = devices[screen.macAddress] as? AppleDevice ?: return
|
||||||
|
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
|
||||||
|
val appleViewModel: AppleViewModel = viewModel(
|
||||||
|
key = "${screen.macAddress.value}:${device.connectionNumber}",
|
||||||
|
factory = factory
|
||||||
|
)
|
||||||
|
CallControlScreen(
|
||||||
|
viewModel = appleViewModel,
|
||||||
|
action = screen.action,
|
||||||
|
navigateBack = navigateBack,
|
||||||
|
onCallControlValueChanged = { flipped ->
|
||||||
|
device.setControlCommand(
|
||||||
|
ControlCommandIdentifier.CALL_MANAGEMENT_CONFIG,
|
||||||
|
if (flipped) byteArrayOf(0x00, 0x02) else byteArrayOf(0x00, 0x03)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Screen.MicrophoneSettings -> {
|
||||||
|
val device = devices[screen.macAddress] as? AppleDevice ?: return
|
||||||
|
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
|
||||||
|
val appleViewModel: AppleViewModel = viewModel(
|
||||||
|
key = "${screen.macAddress.value}:${device.connectionNumber}",
|
||||||
|
factory = factory
|
||||||
|
)
|
||||||
|
MicrophoneSettingsRoute(
|
||||||
|
viewModel = appleViewModel,
|
||||||
|
navigateBack = navigateBack
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Screen.ReleaseNotes -> {
|
||||||
|
ReleaseNotesScreen(
|
||||||
|
updates = updates,
|
||||||
|
releaseNotesShown = {
|
||||||
|
if (showReleaseNotes) {
|
||||||
|
navigate(Screen.DeviceList)
|
||||||
|
backStack.remove(screen)
|
||||||
|
updatesShown()
|
||||||
|
} else {
|
||||||
|
backStack.removeAt(backStack.lastIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Screen.Recording -> {
|
||||||
|
val device = devices[screen.macAddress] as? AppleDevice ?: return
|
||||||
|
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
|
||||||
|
val appleViewModel: AppleViewModel = viewModel(
|
||||||
|
key = "${screen.macAddress.value}:${device.connectionNumber}",
|
||||||
|
factory = factory
|
||||||
|
)
|
||||||
|
RecordingScreenRoute(
|
||||||
|
viewModel = appleViewModel,
|
||||||
|
navigateBack = navigateBack
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Screen.Debug -> {
|
||||||
|
val device = devices[screen.macAddress] as? AppleDevice ?: return
|
||||||
|
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
|
||||||
|
val appleViewModel: AppleViewModel = viewModel(
|
||||||
|
key = "${screen.macAddress.value}:${device.connectionNumber}",
|
||||||
|
factory = factory
|
||||||
|
)
|
||||||
|
DebugRoute(
|
||||||
|
viewModel = appleViewModel,
|
||||||
|
navigateBack = navigateBack
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Screen.BLESettings -> {
|
||||||
|
val factory = viewModelFactory {
|
||||||
|
initializer { AppSettingsViewModel(appDataRepository = appDataRepository) }
|
||||||
|
}
|
||||||
|
val appSettingsViewModel: AppSettingsViewModel = viewModel(factory = factory)
|
||||||
|
BLESettingsScreenRoute(
|
||||||
|
viewModel = appSettingsViewModel,
|
||||||
|
navigateBack = navigateBack
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is Screen.HeartRate -> {
|
||||||
|
val device = devices[screen.macAddress] as? AppleDevice ?: return
|
||||||
|
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
|
||||||
|
val appleViewModel: AppleViewModel = viewModel(
|
||||||
|
key = "${screen.macAddress.value}:${device.connectionNumber}",
|
||||||
|
factory = factory
|
||||||
|
)
|
||||||
|
HeartRateRoute(
|
||||||
|
viewModel = appleViewModel,
|
||||||
|
navigateBack = navigateBack
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-3
@@ -30,9 +30,6 @@ sealed interface Screen: NavKey {
|
|||||||
@Serializable
|
@Serializable
|
||||||
data object AppSettings: Screen
|
data object AppSettings: Screen
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data object Troubleshooting: Screen
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class HeadTracking(
|
data class HeadTracking(
|
||||||
override val macAddress: MacAddress
|
override val macAddress: MacAddress
|
||||||
@@ -126,6 +123,11 @@ sealed interface Screen: NavKey {
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object BLESettings: Screen
|
data object BLESettings: Screen
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class HeartRate(
|
||||||
|
override val macAddress: MacAddress
|
||||||
|
): DeviceScreen
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
+297
@@ -0,0 +1,297 @@
|
|||||||
|
package me.kavishdevar.librepods.presentation.navigation
|
||||||
|
|
||||||
|
import androidx.activity.compose.PredictiveBackHandler
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.animate
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.gestures.Orientation
|
||||||
|
import androidx.compose.foundation.gestures.draggable
|
||||||
|
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalWindowInfo
|
||||||
|
import androidx.navigation3.runtime.NavEntry
|
||||||
|
import androidx.navigation3.scene.Scene
|
||||||
|
import androidx.navigation3.scene.SceneStrategy
|
||||||
|
import androidx.navigation3.scene.SceneStrategyScope
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
|
// mostly AI generated
|
||||||
|
|
||||||
|
private enum class Direction {
|
||||||
|
Forward,
|
||||||
|
Back,
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
class SwipeBackSceneStrategy<T : Any>(
|
||||||
|
private val enabled: Boolean,
|
||||||
|
private val backRequests: Channel<CompletableDeferred<Unit>>,
|
||||||
|
private val onDismiss: () -> Unit
|
||||||
|
) : SceneStrategy<T> {
|
||||||
|
|
||||||
|
private var previousEntries: List<NavEntry<T>> = emptyList()
|
||||||
|
|
||||||
|
override fun SceneStrategyScope<T>.calculateScene(
|
||||||
|
entries: List<NavEntry<T>>
|
||||||
|
): Scene<T>? {
|
||||||
|
if (entries.isEmpty()) return null
|
||||||
|
|
||||||
|
val currentEntry = entries.last()
|
||||||
|
val previousEntry = entries.getOrNull(entries.lastIndex - 1)
|
||||||
|
|
||||||
|
val direction = when {
|
||||||
|
previousEntries.isEmpty() -> Direction.None
|
||||||
|
currentEntry in previousEntries -> Direction.Back
|
||||||
|
else -> Direction.Forward
|
||||||
|
}
|
||||||
|
|
||||||
|
previousEntries = entries
|
||||||
|
//
|
||||||
|
// if (previousEntry == null) {
|
||||||
|
// return object : Scene<T> {
|
||||||
|
// override val key: Any
|
||||||
|
// get() = "${currentEntry.contentKey}_${currentEntry.hashCode()}"
|
||||||
|
//
|
||||||
|
// override val entries: List<NavEntry<T>>
|
||||||
|
// get() = listOf(currentEntry)
|
||||||
|
//
|
||||||
|
// override val previousEntries: List<NavEntry<T>>
|
||||||
|
// get() = emptyList()
|
||||||
|
//
|
||||||
|
// override val content: @Composable () -> Unit
|
||||||
|
// get() = { currentEntry.Content() }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
return object : Scene<T> {
|
||||||
|
override val key: Any
|
||||||
|
get() = "${currentEntry.contentKey}_${currentEntry.hashCode()}"
|
||||||
|
|
||||||
|
override val entries: List<NavEntry<T>>
|
||||||
|
get() = listOfNotNull(previousEntry, currentEntry)
|
||||||
|
|
||||||
|
override val previousEntries: List<NavEntry<T>>
|
||||||
|
get() = listOfNotNull(previousEntry)
|
||||||
|
|
||||||
|
override val content: @Composable () -> Unit
|
||||||
|
get() = {
|
||||||
|
SwipeBackSceneContent(
|
||||||
|
previousEntry = previousEntry,
|
||||||
|
currentEntry = currentEntry,
|
||||||
|
direction = direction,
|
||||||
|
swipeEnabled = enabled,
|
||||||
|
backRequests = backRequests,
|
||||||
|
onDismiss = onDismiss
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun <T : Any> SwipeBackSceneContent(
|
||||||
|
previousEntry: NavEntry<T>?,
|
||||||
|
currentEntry: NavEntry<T>,
|
||||||
|
direction: Direction,
|
||||||
|
swipeEnabled: Boolean,
|
||||||
|
backRequests: Channel<CompletableDeferred<Unit>>,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val screenWidthPx = with(density) {
|
||||||
|
LocalWindowInfo.current.containerDpSize.width.toPx()
|
||||||
|
}
|
||||||
|
|
||||||
|
val animatedOffset = remember(
|
||||||
|
"${currentEntry.contentKey}_${currentEntry.hashCode()}"
|
||||||
|
) {
|
||||||
|
Animatable(
|
||||||
|
if (direction == Direction.Forward) {
|
||||||
|
screenWidthPx
|
||||||
|
} else {
|
||||||
|
0f
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var transitionProgress by remember {
|
||||||
|
mutableFloatStateOf(
|
||||||
|
if (direction == Direction.Forward) 1f else 0f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
LaunchedEffect(
|
||||||
|
"${currentEntry.contentKey}_${currentEntry.hashCode()}",
|
||||||
|
direction
|
||||||
|
) {
|
||||||
|
if (direction == Direction.Forward) {
|
||||||
|
transitionProgress = 1f
|
||||||
|
|
||||||
|
animatedOffset.animateTo(
|
||||||
|
0f,
|
||||||
|
tween(220)
|
||||||
|
)
|
||||||
|
|
||||||
|
transitionProgress = 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
for (completed in backRequests) {
|
||||||
|
animatedOffset.animateTo(
|
||||||
|
screenWidthPx,
|
||||||
|
tween(150)
|
||||||
|
)
|
||||||
|
|
||||||
|
transitionProgress = -1f
|
||||||
|
onDismiss()
|
||||||
|
completed.complete(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PredictiveBackHandler { progressFlow ->
|
||||||
|
try {
|
||||||
|
progressFlow.collect { backEvent ->
|
||||||
|
val progress = backEvent.progress
|
||||||
|
|
||||||
|
transitionProgress = -progress
|
||||||
|
|
||||||
|
animatedOffset.snapTo(
|
||||||
|
progress * screenWidthPx
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
animatedOffset.animateTo(
|
||||||
|
screenWidthPx,
|
||||||
|
tween(150)
|
||||||
|
)
|
||||||
|
|
||||||
|
transitionProgress = -1f
|
||||||
|
onDismiss()
|
||||||
|
} catch (_: CancellationException) {
|
||||||
|
animatedOffset.animateTo(
|
||||||
|
0f,
|
||||||
|
tween(150)
|
||||||
|
)
|
||||||
|
|
||||||
|
transitionProgress = 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val draggableState = rememberDraggableState { delta ->
|
||||||
|
scope.launch {
|
||||||
|
val offset =
|
||||||
|
(animatedOffset.value + delta)
|
||||||
|
.coerceAtLeast(0f)
|
||||||
|
|
||||||
|
animatedOffset.snapTo(offset)
|
||||||
|
|
||||||
|
transitionProgress =
|
||||||
|
-(offset / screenWidthPx)
|
||||||
|
.coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalTransitionProgress provides transitionProgress
|
||||||
|
) {
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalIsCurrentEntry provides false
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.graphicsLayer {
|
||||||
|
translationX =
|
||||||
|
if (direction == Direction.Forward) {
|
||||||
|
0f
|
||||||
|
} else {
|
||||||
|
(-screenWidthPx / 3f) +
|
||||||
|
(animatedOffset.value / 3f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
previousEntry?.Content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalIsCurrentEntry provides true
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.graphicsLayer {
|
||||||
|
translationX = animatedOffset.value
|
||||||
|
}
|
||||||
|
.draggable(
|
||||||
|
enabled = swipeEnabled && previousEntry != null,
|
||||||
|
state = draggableState,
|
||||||
|
orientation = Orientation.Horizontal,
|
||||||
|
onDragStopped = { velocity ->
|
||||||
|
val currentOffset = animatedOffset.value
|
||||||
|
val currentProgress =
|
||||||
|
(currentOffset / screenWidthPx).coerceIn(0f, 1f)
|
||||||
|
|
||||||
|
val shouldDismiss =
|
||||||
|
currentOffset > screenWidthPx * 0.35f ||
|
||||||
|
velocity > 1000f
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
if (shouldDismiss) {
|
||||||
|
animate(
|
||||||
|
initialValue = currentProgress,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = tween(150)
|
||||||
|
) { value, _ ->
|
||||||
|
transitionProgress = -value
|
||||||
|
scope.launch {
|
||||||
|
animatedOffset.snapTo(value * screenWidthPx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transitionProgress = -1f
|
||||||
|
onDismiss()
|
||||||
|
} else {
|
||||||
|
animate(
|
||||||
|
initialValue = currentProgress,
|
||||||
|
targetValue = 0f,
|
||||||
|
animationSpec = tween(150)
|
||||||
|
) { value, _ ->
|
||||||
|
transitionProgress = -value
|
||||||
|
scope.launch {
|
||||||
|
animatedOffset.snapTo(value * screenWidthPx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transitionProgress = 0f
|
||||||
|
animatedOffset.snapTo(0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
currentEntry.Content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
package me.kavishdevar.librepods.presentation.navigation
|
||||||
|
|
||||||
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
|
||||||
|
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope> {
|
||||||
|
error("LocalSharedTransitionScope not provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
val LocalTransitionProgress = compositionLocalOf { 0f }
|
||||||
|
val LocalIsCurrentEntry = compositionLocalOf { false }
|
||||||
+242
-219
@@ -29,14 +29,10 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBars
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.input.TextFieldState
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
@@ -76,10 +72,10 @@ import me.kavishdevar.librepods.presentation.components.StyledInputField
|
|||||||
import me.kavishdevar.librepods.presentation.components.StyledList
|
import me.kavishdevar.librepods.presentation.components.StyledList
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
|
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
import me.kavishdevar.librepods.presentation.icons.LocalIcons
|
import me.kavishdevar.librepods.presentation.icons.LocalIcons
|
||||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
|
||||||
import me.kavishdevar.librepods.presentation.theme.NightTheme
|
import me.kavishdevar.librepods.presentation.theme.NightTheme
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
|
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
|
||||||
import me.kavishdevar.librepods.utils.XposedState
|
import me.kavishdevar.librepods.utils.XposedState
|
||||||
@@ -89,8 +85,8 @@ import java.util.concurrent.TimeUnit
|
|||||||
@Composable
|
@Composable
|
||||||
fun AppSettingsScreen(
|
fun AppSettingsScreen(
|
||||||
viewModel: AppSettingsViewModel = viewModel(),
|
viewModel: AppSettingsViewModel = viewModel(),
|
||||||
|
navigateBack: (() -> Unit)?,
|
||||||
navigateToPurchase: () -> Unit,
|
navigateToPurchase: () -> Unit,
|
||||||
navigateToTroubleshooting: () -> Unit,
|
|
||||||
navigateToOpenSourceLicenses: () -> Unit,
|
navigateToOpenSourceLicenses: () -> Unit,
|
||||||
navigateToReleaseNotesScreen: () -> Unit,
|
navigateToReleaseNotesScreen: () -> Unit,
|
||||||
navigateToBleSettingsScreen: () -> Unit
|
navigateToBleSettingsScreen: () -> Unit
|
||||||
@@ -107,237 +103,263 @@ fun AppSettingsScreen(
|
|||||||
val subjectFocusRequester = remember { FocusRequester() }
|
val subjectFocusRequester = remember { FocusRequester() }
|
||||||
val descriptionFocusRequester = remember { FocusRequester() }
|
val descriptionFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
StyledScaffold(
|
||||||
val topPadding = if (m3eEnabled) 16.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
title = stringResource(R.string.settings),
|
||||||
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
navigateBack = navigateBack
|
||||||
|
) { topPadding, bottomPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
.layerBackdrop(backdrop)
|
||||||
|
.verticalScroll(scrollState)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
Column(
|
if (!state.isPremium && state.state.hasConnectedToAACP) {
|
||||||
modifier = Modifier
|
StyledButton(
|
||||||
.fillMaxSize()
|
onClick = navigateToPurchase,
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
backdrop = rememberLayerBackdrop(),
|
||||||
.layerBackdrop(backdrop)
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.verticalScroll(scrollState)
|
maxScale = 0.05f,
|
||||||
.padding(horizontal = 16.dp)
|
surfaceColor = MaterialTheme.colorScheme.primary
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(topPadding))
|
Text(
|
||||||
|
stringResource(R.string.unlock_advanced_features),
|
||||||
if (!state.isPremium && state.state.hasConnectedToAACP) {
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
StyledButton(
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
onClick = navigateToPurchase,
|
|
||||||
backdrop = rememberLayerBackdrop(),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
maxScale = 0.05f,
|
|
||||||
surfaceColor = MaterialTheme.colorScheme.primary
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.unlock_advanced_features),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
}
|
|
||||||
if (state.state.timeUntilFOSSPremiumExpiry > 0L) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.background(Color(0xFF32829B), RoundedCornerShape(28.dp))
|
|
||||||
.clip(RoundedCornerShape(28.dp))
|
|
||||||
.clickable {
|
|
||||||
val emailIntent = Intent(Intent.ACTION_SENDTO).apply {
|
|
||||||
data = "mailto:".toUri()
|
|
||||||
putExtra(Intent.EXTRA_EMAIL, arrayOf("billing@kavish.xyz"))
|
|
||||||
putExtra(Intent.EXTRA_SUBJECT, "LibrePods Play billing error")
|
|
||||||
putExtra(
|
|
||||||
Intent.EXTRA_TEXT,
|
|
||||||
"Please enter your GitHub username to restore your premium access:\n\nGitHub username: "
|
|
||||||
)
|
|
||||||
}
|
|
||||||
context.startActivity(emailIntent)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(
|
|
||||||
R.string.play_foss_premium_banner, maxOf(1, TimeUnit.MILLISECONDS.toDays(state.state.timeUntilFOSSPremiumExpiry).toInt())
|
|
||||||
),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(16.dp),
|
|
||||||
style = MaterialTheme.typography.bodyMediumEmphasized,
|
|
||||||
color = Color.White
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.state.hasConnectedToAACP) {
|
|
||||||
StyledList(title = stringResource(R.string.appearance)) {
|
|
||||||
StyledListItem(
|
|
||||||
contentText = stringResource(R.string.light),
|
|
||||||
selected = state.settings.nightMode == NightTheme.Light,
|
|
||||||
onClick = { viewModel.updateSettings { it.copy(nightMode = NightTheme.Light) } },
|
|
||||||
enabled = state.isPremium
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledListItem(
|
|
||||||
contentText = stringResource(R.string.system),
|
|
||||||
selected = state.settings.nightMode == NightTheme.System,
|
|
||||||
onClick = { viewModel.updateSettings { it.copy(nightMode = NightTheme.System) } },
|
|
||||||
enabled = state.isPremium
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledListItem(
|
|
||||||
contentText = stringResource(R.string.dark),
|
|
||||||
selected = state.settings.nightMode == NightTheme.Dark,
|
|
||||||
onClick = { viewModel.updateSettings { it.copy(nightMode = NightTheme.Dark) } },
|
|
||||||
enabled = state.isPremium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
StyledList(title = stringResource(R.string.design_system)) {
|
|
||||||
StyledListItem(
|
|
||||||
contentText = stringResource(R.string.apple),
|
|
||||||
selected = state.settings.designSystem == DesignSystem.Apple,
|
|
||||||
onClick = { viewModel.updateSettings { it.copy(designSystem = DesignSystem.Apple) } },
|
|
||||||
enabled = state.isPremium
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledListItem(
|
|
||||||
contentText = stringResource(R.string.material3e),
|
|
||||||
selected = state.settings.designSystem == DesignSystem.Material,
|
|
||||||
onClick = { viewModel.updateSettings { it.copy(designSystem = DesignSystem.Material) } },
|
|
||||||
enabled = state.isPremium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
|
|
||||||
val restartBluetoothText = stringResource(R.string.found_offset_restart_bluetooth)
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.act_as_an_apple_device) + " (${
|
|
||||||
stringResource(
|
|
||||||
R.string.requires_xposed
|
|
||||||
)
|
)
|
||||||
})",
|
|
||||||
description = stringResource(R.string.act_as_an_apple_device_description),
|
|
||||||
checked = state.vendorIdHook,
|
|
||||||
onCheckedChange = { checked ->
|
|
||||||
Toast.makeText(context, restartBluetoothText, Toast.LENGTH_SHORT).show()
|
|
||||||
viewModel.setVendorIdHook(checked)
|
|
||||||
}
|
}
|
||||||
)
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
StyledListItem(
|
if (state.state.timeUntilFOSSPremiumExpiry > 0L) {
|
||||||
contentText = stringResource(R.string.ble_settings),
|
Box(
|
||||||
supportingText = stringResource(R.string.do_not_change),
|
modifier = Modifier
|
||||||
orientation = StyledListItemOrientation.Vertical,
|
.background(Color(0xFF32829B), RoundedCornerShape(28.dp))
|
||||||
onClick = navigateToBleSettingsScreen
|
.clip(RoundedCornerShape(28.dp))
|
||||||
)
|
.clickable {
|
||||||
|
val emailIntent = Intent(Intent.ACTION_SENDTO).apply {
|
||||||
StyledToggle(
|
data = "mailto:".toUri()
|
||||||
label = stringResource(R.string.enable_debug_mode),
|
putExtra(Intent.EXTRA_EMAIL, arrayOf("billing@kavish.xyz"))
|
||||||
checked = state.settings.debugMode,
|
putExtra(Intent.EXTRA_SUBJECT, "LibrePods Play billing error")
|
||||||
onCheckedChange = { checked ->
|
putExtra(
|
||||||
viewModel.updateSettings {
|
Intent.EXTRA_TEXT,
|
||||||
it.copy(debugMode = checked)
|
"Please enter your GitHub username to restore your premium access:\n\nGitHub username: "
|
||||||
|
)
|
||||||
|
}
|
||||||
|
context.startActivity(emailIntent)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.play_foss_premium_banner, maxOf(1, TimeUnit.MILLISECONDS.toDays(state.state.timeUntilFOSSPremiumExpiry).toInt())
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp),
|
||||||
|
style = MaterialTheme.typography.bodyMediumEmphasized,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.state.hasConnectedToAACP) {
|
||||||
|
StyledList(title = stringResource(R.string.appearance)) {
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.light),
|
||||||
|
selected = state.settings.nightMode == NightTheme.Light,
|
||||||
|
onClick = { viewModel.updateSettings { it.copy(nightMode = NightTheme.Light) } },
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.system),
|
||||||
|
selected = state.settings.nightMode == NightTheme.System,
|
||||||
|
onClick = { viewModel.updateSettings { it.copy(nightMode = NightTheme.System) } },
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.dark),
|
||||||
|
selected = state.settings.nightMode == NightTheme.Dark,
|
||||||
|
onClick = { viewModel.updateSettings { it.copy(nightMode = NightTheme.Dark) } },
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
StyledList(title = stringResource(R.string.design_system)) {
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.apple),
|
||||||
|
selected = state.settings.designSystem == DesignSystem.Apple,
|
||||||
|
onClick = { viewModel.updateSettings { it.copy(designSystem = DesignSystem.Apple) } },
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.material3e),
|
||||||
|
selected = state.settings.designSystem == DesignSystem.Material,
|
||||||
|
onClick = { viewModel.updateSettings { it.copy(designSystem = DesignSystem.Material) } },
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
StyledList(title = stringResource(R.string.interaction)) {
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.swipe_anywhere_to_go_back),
|
||||||
|
checked = state.settings.swipeAnywhereForBack,
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
viewModel.updateSettings {
|
||||||
|
it.copy(swipeAnywhereForBack = checked)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.use_highest_refresh_rate),
|
||||||
|
checked = state.settings.useHighestRefreshRate,
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
viewModel.updateSettings {
|
||||||
|
it.copy(useHighestRefreshRate = checked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
if (!BuildConfig.PLAY_BUILD) {
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
StyledList {
|
|
||||||
|
StyledList(
|
||||||
|
title = stringResource(R.string.advanced_options),
|
||||||
|
description = stringResource(R.string.do_not_change)
|
||||||
|
) {
|
||||||
|
if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
|
||||||
|
val restartBluetoothText =
|
||||||
|
stringResource(R.string.found_offset_restart_bluetooth)
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.act_as_an_apple_device) + " (${
|
||||||
|
stringResource(
|
||||||
|
R.string.requires_xposed
|
||||||
|
)
|
||||||
|
})",
|
||||||
|
description = stringResource(R.string.act_as_an_apple_device_description),
|
||||||
|
checked = state.vendorIdHook,
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
Toast.makeText(context, restartBluetoothText, Toast.LENGTH_SHORT).show()
|
||||||
|
viewModel.setVendorIdHook(checked)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.enable_debug_mode),
|
||||||
|
description = stringResource(R.string.debug_mode_description),
|
||||||
|
checked = state.settings.debugMode,
|
||||||
|
onCheckedChange = { checked ->
|
||||||
|
viewModel.updateSettings {
|
||||||
|
it.copy(debugMode = checked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
StyledListItem(
|
StyledListItem(
|
||||||
contentText = stringResource(R.string.troubleshooting),
|
contentText = stringResource(R.string.ble_settings),
|
||||||
onClick = navigateToTroubleshooting,
|
orientation = StyledListItemOrientation.Vertical,
|
||||||
|
onClick = navigateToBleSettingsScreen
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
StyledList(title = stringResource(R.string.contact)) {
|
StyledList(title = stringResource(R.string.contact)) {
|
||||||
StyledListItem(
|
StyledListItem(
|
||||||
contentText = stringResource(R.string.email),
|
contentText = stringResource(R.string.email),
|
||||||
supportingText = stringResource(R.string.contact_email_supporting_text),
|
supportingText = stringResource(R.string.contact_email_supporting_text),
|
||||||
orientation = StyledListItemOrientation.Vertical,
|
orientation = StyledListItemOrientation.Vertical,
|
||||||
onClick = { contactBottomSheet.value = true },
|
onClick = { contactBottomSheet.value = true },
|
||||||
)
|
)
|
||||||
|
|
||||||
val errorOpeningDiscordInviteText = stringResource(R.string.error_opening_discord_invite)
|
val errorOpeningDiscordInviteText = stringResource(R.string.error_opening_discord_invite)
|
||||||
|
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.discord),
|
||||||
|
supportingText = stringResource(R.string.contact_discord_supporting_text),
|
||||||
|
orientation = StyledListItemOrientation.Vertical,
|
||||||
|
onClick = {
|
||||||
|
try {
|
||||||
|
val intent =
|
||||||
|
Intent(Intent.ACTION_VIEW, "https://discord.gg/Ts4wupXcmc".toUri())
|
||||||
|
context.startActivity(intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
errorOpeningDiscordInviteText,
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
val errorOpeningGitHubLink = stringResource(R.string.error_opening_github_link)
|
||||||
|
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.github_issues),
|
||||||
|
supportingText = stringResource(R.string.contact_github_supporting_text),
|
||||||
|
orientation = StyledListItemOrientation.Vertical,
|
||||||
|
onClick = {
|
||||||
|
try {
|
||||||
|
val appVersion =
|
||||||
|
Uri.encode("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
|
||||||
|
val device = Uri.encode("${Build.MANUFACTURER} ${Build.MODEL}")
|
||||||
|
val androidVersion = Uri.encode("${Build.ID} (${Build.DISPLAY})")
|
||||||
|
val appSource = Uri.encode(
|
||||||
|
when {
|
||||||
|
BuildConfig.PLAY_BUILD -> "Play"
|
||||||
|
else -> "GitHub"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
val url = "https://github.com/kavishdevar/librepods/issues/new" +
|
||||||
|
"?template=01-bug-report-android.yml" +
|
||||||
|
"&app-source=$appSource" +
|
||||||
|
"&app-version=$appVersion" +
|
||||||
|
"&device=$device" +
|
||||||
|
"&android-version=$androidVersion"
|
||||||
|
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||||
|
context.startActivity(intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
errorOpeningGitHubLink,
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
DeviceInfoCard()
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
AppInfoCard(navigateToReleaseNotesScreen)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
StyledListItem(
|
StyledListItem(
|
||||||
contentText = stringResource(R.string.discord),
|
contentText = stringResource(R.string.open_source_licenses),
|
||||||
supportingText = stringResource(R.string.contact_discord_supporting_text),
|
onClick = navigateToOpenSourceLicenses,
|
||||||
orientation = StyledListItemOrientation.Vertical,
|
|
||||||
onClick = {
|
|
||||||
try {
|
|
||||||
val intent =
|
|
||||||
Intent(Intent.ACTION_VIEW, "https://discord.gg/Ts4wupXcmc".toUri())
|
|
||||||
context.startActivity(intent)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
errorOpeningDiscordInviteText,
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
val errorOpeningGitHubLink = stringResource(R.string.error_opening_github_link)
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
|
|
||||||
StyledListItem(
|
|
||||||
contentText = stringResource(R.string.github_issues),
|
|
||||||
supportingText = stringResource(R.string.contact_github_supporting_text),
|
|
||||||
orientation = StyledListItemOrientation.Vertical,
|
|
||||||
onClick = {
|
|
||||||
try {
|
|
||||||
val appVersion =
|
|
||||||
Uri.encode("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
|
|
||||||
val device = Uri.encode("${Build.MANUFACTURER} ${Build.MODEL}")
|
|
||||||
val androidVersion = Uri.encode("${Build.ID} (${Build.DISPLAY})")
|
|
||||||
val appSource = Uri.encode(
|
|
||||||
when {
|
|
||||||
BuildConfig.PLAY_BUILD -> "Play"
|
|
||||||
else -> "GitHub"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
val url = "https://github.com/kavishdevar/librepods/issues/new" +
|
|
||||||
"?template=01-bug-report-android.yml" +
|
|
||||||
"&app-source=$appSource" +
|
|
||||||
"&app-version=$appVersion" +
|
|
||||||
"&device=$device" +
|
|
||||||
"&android-version=$androidVersion"
|
|
||||||
|
|
||||||
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
|
||||||
context.startActivity(intent)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
errorOpeningGitHubLink,
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
|
||||||
DeviceInfoCard()
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
AppInfoCard(navigateToReleaseNotesScreen)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
StyledListItem(
|
|
||||||
contentText = stringResource(R.string.open_source_licenses),
|
|
||||||
onClick = navigateToOpenSourceLicenses,
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(bottomPadding))
|
|
||||||
|
|
||||||
// if (state.showCameraDialog) {
|
// if (state.showCameraDialog) {
|
||||||
// AlertDialog(onDismissRequest = { viewModel.setShowCameraDialog(false) }, title = {
|
// AlertDialog(onDismissRequest = { viewModel.setShowCameraDialog(false) }, title = {
|
||||||
@@ -403,6 +425,7 @@ fun AppSettingsScreen(
|
|||||||
// }
|
// }
|
||||||
// })
|
// })
|
||||||
// }
|
// }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
StyledBottomSheet(
|
StyledBottomSheet(
|
||||||
|
|||||||
+70
-84
@@ -1,16 +1,9 @@
|
|||||||
package me.kavishdevar.librepods.presentation.screens
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBars
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -24,58 +17,45 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.flow.debounce
|
import kotlinx.coroutines.flow.debounce
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledList
|
import me.kavishdevar.librepods.presentation.components.StyledList
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
|
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
|
||||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
|
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BLESettingsScreenRoute(
|
fun BLESettingsScreenRoute(
|
||||||
viewModel: AppSettingsViewModel
|
viewModel: AppSettingsViewModel,
|
||||||
|
navigateBack: (() -> Unit)?
|
||||||
) {
|
) {
|
||||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
|
||||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
|
||||||
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
|
||||||
|
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
val settings = uiState.settings
|
val settings = uiState.settings
|
||||||
|
|
||||||
Box (
|
BLESettingsScreen(
|
||||||
modifier = Modifier
|
navigateBack = navigateBack,
|
||||||
.fillMaxSize()
|
scanMode = settings.bleScanMode,
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
onScanModeChanged = { scanMode ->
|
||||||
) {
|
viewModel.updateSettings {
|
||||||
BLESettingsScreen(
|
it.copy(bleScanMode = scanMode)
|
||||||
topPadding = topPadding,
|
}
|
||||||
bottomPadding = bottomPadding,
|
},
|
||||||
scanMode = settings.bleScanMode,
|
reportDelay = settings.bleReportDelay,
|
||||||
onScanModeChanged = { scanMode ->
|
onReportDelayChanged = { reportDelay ->
|
||||||
viewModel.updateSettings {
|
viewModel.updateSettings {
|
||||||
it.copy(bleScanMode = scanMode)
|
it.copy(bleReportDelay = reportDelay)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
reportDelay = settings.bleReportDelay,
|
)
|
||||||
onReportDelayChanged = { reportDelay ->
|
|
||||||
viewModel.updateSettings {
|
|
||||||
it.copy(bleReportDelay = reportDelay)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BLESettingsScreen(
|
fun BLESettingsScreen(
|
||||||
topPadding: Dp = 16.dp,
|
navigateBack: (() -> Unit)? = null,
|
||||||
bottomPadding: Dp = 16.dp,
|
|
||||||
scanMode: Int,
|
scanMode: Int,
|
||||||
onScanModeChanged: (Int) -> Unit,
|
onScanModeChanged: (Int) -> Unit,
|
||||||
reportDelay: Long,
|
reportDelay: Long,
|
||||||
@@ -83,62 +63,68 @@ fun BLESettingsScreen(
|
|||||||
) {
|
) {
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
Column(
|
StyledScaffold(
|
||||||
modifier = Modifier
|
title = stringResource(R.string.ble_settings),
|
||||||
.padding(horizontal = 16.dp)
|
navigateBack = navigateBack
|
||||||
.verticalScroll(scrollState),
|
) { topPadding, bottomPadding ->
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
Column(
|
||||||
) {
|
modifier = Modifier
|
||||||
Spacer(modifier = Modifier.padding(top = topPadding))
|
.padding(horizontal = 16.dp)
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.padding(top = topPadding))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.do_not_change),
|
text = stringResource(R.string.do_not_change),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.error
|
color = MaterialTheme.colorScheme.error
|
||||||
)
|
|
||||||
|
|
||||||
StyledList(title = stringResource(R.string.scanMode)) {
|
|
||||||
StyledListItem(
|
|
||||||
onClick = { onScanModeChanged(0) },
|
|
||||||
contentText = stringResource(R.string.low_power),
|
|
||||||
supportingText = stringResource(R.string.ble_scan_mode_low_power_description),
|
|
||||||
orientation = StyledListItemOrientation.Vertical,
|
|
||||||
selected = scanMode == 0
|
|
||||||
)
|
)
|
||||||
StyledListItem(
|
|
||||||
onClick = { onScanModeChanged(1) },
|
|
||||||
contentText = stringResource(R.string.balanced),
|
|
||||||
supportingText = stringResource(R.string.ble_scan_mode_balanced_description),
|
|
||||||
orientation = StyledListItemOrientation.Vertical,
|
|
||||||
selected = scanMode == 1
|
|
||||||
)
|
|
||||||
StyledListItem(
|
|
||||||
onClick = { onScanModeChanged(2) },
|
|
||||||
contentText = stringResource(R.string.low_latency),
|
|
||||||
supportingText = stringResource(R.string.ble_scan_mode_low_latency_description),
|
|
||||||
orientation = StyledListItemOrientation.Vertical,
|
|
||||||
selected = scanMode == 2
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val sliderValue = remember { mutableFloatStateOf(reportDelay.toFloat()) }
|
StyledList(title = stringResource(R.string.scanMode)) {
|
||||||
|
StyledListItem(
|
||||||
|
onClick = { onScanModeChanged(0) },
|
||||||
|
contentText = stringResource(R.string.low_power),
|
||||||
|
supportingText = stringResource(R.string.ble_scan_mode_low_power_description),
|
||||||
|
orientation = StyledListItemOrientation.Vertical,
|
||||||
|
selected = scanMode == 0
|
||||||
|
)
|
||||||
|
StyledListItem(
|
||||||
|
onClick = { onScanModeChanged(1) },
|
||||||
|
contentText = stringResource(R.string.balanced),
|
||||||
|
supportingText = stringResource(R.string.ble_scan_mode_balanced_description),
|
||||||
|
orientation = StyledListItemOrientation.Vertical,
|
||||||
|
selected = scanMode == 1
|
||||||
|
)
|
||||||
|
StyledListItem(
|
||||||
|
onClick = { onScanModeChanged(2) },
|
||||||
|
contentText = stringResource(R.string.low_latency),
|
||||||
|
supportingText = stringResource(R.string.ble_scan_mode_low_latency_description),
|
||||||
|
orientation = StyledListItemOrientation.Vertical,
|
||||||
|
selected = scanMode == 2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(sliderValue) {
|
val sliderValue = remember { mutableFloatStateOf(reportDelay.toFloat()) }
|
||||||
snapshotFlow { sliderValue.floatValue }
|
|
||||||
.debounce(1.seconds)
|
LaunchedEffect(sliderValue) {
|
||||||
.collect { newValue ->
|
snapshotFlow { sliderValue.floatValue }
|
||||||
onReportDelayChanged(newValue.toLong())
|
.debounce(1.seconds)
|
||||||
}
|
.collect { newValue ->
|
||||||
}
|
onReportDelayChanged(newValue.toLong())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
StyledList(title = stringResource(R.string.ble_report_delay)) {
|
|
||||||
StyledSlider(
|
StyledSlider(
|
||||||
|
label = stringResource(R.string.ble_report_delay),
|
||||||
value = sliderValue.floatValue,
|
value = sliderValue.floatValue,
|
||||||
onValueChange = { sliderValue.floatValue = it },
|
onValueChange = { sliderValue.floatValue = it },
|
||||||
valueRange = 0f..1000f,
|
valueRange = 0f..1000f,
|
||||||
description = sliderValue.floatValue.toString() + "ms",
|
description = sliderValue.floatValue.toString() + "ms",
|
||||||
|
independent = true // i thought I got rid of all this lol
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.padding(bottom = bottomPadding))
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.padding(bottom = bottomPadding))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+351
-340
@@ -9,26 +9,25 @@ import androidx.compose.animation.core.Spring
|
|||||||
import androidx.compose.animation.core.infiniteRepeatable
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
import androidx.compose.animation.core.spring
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.statusBars
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Settings
|
||||||
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
import androidx.compose.material3.MaterialShapes
|
import androidx.compose.material3.MaterialShapes
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.contentColorFor
|
import androidx.compose.material3.contentColorFor
|
||||||
|
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||||
import androidx.compose.material3.toPath
|
import androidx.compose.material3.toPath
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -48,7 +47,6 @@ import androidx.compose.ui.graphics.Path
|
|||||||
import androidx.compose.ui.graphics.drawscope.rotate
|
import androidx.compose.ui.graphics.drawscope.rotate
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.graphics.shapes.Morph
|
import androidx.graphics.shapes.Morph
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -69,9 +67,11 @@ import me.kavishdevar.librepods.devices.BaseCapability
|
|||||||
import me.kavishdevar.librepods.devices.ConnectionState
|
import me.kavishdevar.librepods.devices.ConnectionState
|
||||||
import me.kavishdevar.librepods.devices.Device
|
import me.kavishdevar.librepods.devices.Device
|
||||||
import me.kavishdevar.librepods.presentation.components.NoiseControlSettings
|
import me.kavishdevar.librepods.presentation.components.NoiseControlSettings
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledIconButton
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledList
|
import me.kavishdevar.librepods.presentation.components.StyledList
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
|
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.presentation.icons.LocalIcons
|
import me.kavishdevar.librepods.presentation.icons.LocalIcons
|
||||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||||
@@ -79,388 +79,399 @@ import me.kavishdevar.librepods.presentation.utils.createAirPodsBatteryRichText
|
|||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DeviceListRoute(
|
|
||||||
devices: Map<MacAddress, Device<*, *, *>>,
|
|
||||||
navigateToDevice: (MacAddress) -> Unit,
|
|
||||||
) {
|
|
||||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
|
||||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
|
||||||
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
|
||||||
|
|
||||||
Box (
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
|
||||||
) {
|
|
||||||
DeviceListScreen(
|
|
||||||
devices = devices,
|
|
||||||
navigateToDevice = navigateToDevice,
|
|
||||||
topPadding = topPadding,
|
|
||||||
bottomPadding = bottomPadding
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DeviceListScreen(
|
fun DeviceListScreen(
|
||||||
devices: Map<MacAddress, Device<*, *, *>>,
|
devices: Map<MacAddress, Device<*, *, *>>,
|
||||||
|
navigateToAppSettings: () -> Unit,
|
||||||
navigateToDevice: (MacAddress) -> Unit,
|
navigateToDevice: (MacAddress) -> Unit,
|
||||||
topPadding: Dp = 16.dp,
|
|
||||||
bottomPadding: Dp = 16.dp
|
|
||||||
) {
|
) {
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
Column(
|
StyledScaffold(
|
||||||
modifier = Modifier
|
title = stringResource(R.string.app_name),
|
||||||
.padding(horizontal = 16.dp)
|
navigateBack = null,
|
||||||
.verticalScroll(scrollState),
|
actionButtons = listOf(
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
{ scaffoldBackdrop ->
|
||||||
) {
|
if (LocalDesignSystem.current == DesignSystem.Material) {
|
||||||
Spacer(modifier = Modifier.padding(top = topPadding))
|
FilledTonalIconButton(
|
||||||
|
onClick = navigateToAppSettings,
|
||||||
|
modifier = Modifier
|
||||||
|
.minimumInteractiveComponentSize()
|
||||||
|
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
|
||||||
|
|
||||||
Log.d("DeviceListScreen", "Rendering device list with ${devices.size} devices")
|
) {
|
||||||
|
Icon(
|
||||||
StyledList(title = stringResource(R.string.devices), key = devices) {
|
imageVector = Icons.Outlined.Settings,
|
||||||
devices.forEach { (macAddress, device) ->
|
contentDescription = "settings",
|
||||||
val connectionState by device.connectionState.collectAsState()
|
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
|
||||||
val deviceState by device.state.collectAsState()
|
)
|
||||||
val deviceMetadata by device.metadata.collectAsState()
|
}
|
||||||
|
} else {
|
||||||
fun ConnectionState.shape() = when (this) {
|
StyledIconButton(
|
||||||
ConnectionState.DISCONNECTED -> MaterialShapes.Circle.normalized()
|
onClick = navigateToAppSettings,
|
||||||
ConnectionState.CONNECTING -> MaterialShapes.SoftBurst.normalized()
|
backdrop = scaffoldBackdrop
|
||||||
ConnectionState.CONNECTED -> MaterialShapes.SoftBurst.normalized()
|
) {
|
||||||
ConnectionState.DISCONNECTING -> MaterialShapes.Cookie4Sided.normalized()
|
Icon(
|
||||||
ConnectionState.AVAILABLE -> MaterialShapes.Circle.normalized()
|
imageVector = LocalIcons.current.Settings,
|
||||||
}
|
contentDescription = "Settings",
|
||||||
|
tint = MaterialTheme.colorScheme.onBackground
|
||||||
val connectingShapes = remember {
|
)
|
||||||
listOf(
|
|
||||||
MaterialShapes.Cookie4Sided.normalized(),
|
|
||||||
MaterialShapes.SoftBurst.normalized(),
|
|
||||||
MaterialShapes.Cookie9Sided.normalized(),
|
|
||||||
MaterialShapes.Pentagon.normalized(),
|
|
||||||
MaterialShapes.Pill.normalized(),
|
|
||||||
MaterialShapes.Sunny.normalized(),
|
|
||||||
MaterialShapes.Cookie4Sided.normalized(),
|
|
||||||
MaterialShapes.Oval.normalized(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val connectingMorphs = remember {
|
|
||||||
buildList {
|
|
||||||
connectingShapes.zipWithNext { a, b ->
|
|
||||||
add(Morph(a, b))
|
|
||||||
}
|
|
||||||
add(Morph(connectingShapes.last(), connectingShapes.first()))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) { topPadding, bottomPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.padding(top = topPadding))
|
||||||
|
|
||||||
var previousState by remember { mutableStateOf(connectionState) }
|
Log.d("DeviceListScreen", "Rendering device list with ${devices.size} devices")
|
||||||
|
|
||||||
var pressed by remember { mutableStateOf(false) }
|
StyledList(title = stringResource(R.string.devices), key = devices) {
|
||||||
|
devices.forEach { (macAddress, device) ->
|
||||||
|
val connectionState by device.connectionState.collectAsState()
|
||||||
|
val deviceState by device.state.collectAsState()
|
||||||
|
val deviceMetadata by device.metadata.collectAsState()
|
||||||
|
|
||||||
val touchMorph = remember {
|
fun ConnectionState.shape() = when (this) {
|
||||||
Morph(
|
ConnectionState.DISCONNECTED -> MaterialShapes.Circle.normalized()
|
||||||
if (connectionState == ConnectionState.CONNECTED) MaterialShapes.SoftBurst.normalized() else MaterialShapes.Circle.normalized(),
|
ConnectionState.CONNECTING -> MaterialShapes.SoftBurst.normalized()
|
||||||
MaterialShapes.Cookie4Sided.normalized()
|
ConnectionState.CONNECTED -> MaterialShapes.SoftBurst.normalized()
|
||||||
)
|
ConnectionState.DISCONNECTING -> MaterialShapes.Cookie4Sided.normalized()
|
||||||
}
|
ConnectionState.AVAILABLE -> MaterialShapes.Circle.normalized()
|
||||||
|
}
|
||||||
|
|
||||||
val touchProgress = remember { Animatable(0f) }
|
val connectingShapes = remember {
|
||||||
|
listOf(
|
||||||
LaunchedEffect(pressed) {
|
MaterialShapes.Cookie4Sided.normalized(),
|
||||||
touchProgress.animateTo(
|
MaterialShapes.SoftBurst.normalized(),
|
||||||
targetValue = if (pressed) 1f else 0f,
|
MaterialShapes.Cookie9Sided.normalized(),
|
||||||
animationSpec = spring(
|
MaterialShapes.Pentagon.normalized(),
|
||||||
dampingRatio = 0.6f,
|
MaterialShapes.Pill.normalized(),
|
||||||
stiffness = 200f,
|
MaterialShapes.Sunny.normalized(),
|
||||||
visibilityThreshold = 0.1f
|
MaterialShapes.Cookie4Sided.normalized(),
|
||||||
|
MaterialShapes.Oval.normalized(),
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
var currentMorphIndex by remember { mutableIntStateOf(0) }
|
val connectingMorphs = remember {
|
||||||
var morphRotationTarget by remember { mutableFloatStateOf(90f) }
|
buildList {
|
||||||
|
connectingShapes.zipWithNext { a, b ->
|
||||||
val morphProgress = remember { Animatable(0f) }
|
add(Morph(a, b))
|
||||||
val globalRotation = remember { Animatable(0f) }
|
|
||||||
|
|
||||||
LaunchedEffect(connectionState) {
|
|
||||||
if (connectionState == ConnectionState.CONNECTING) {
|
|
||||||
pressed = false
|
|
||||||
currentMorphIndex = 0
|
|
||||||
morphRotationTarget = 90f
|
|
||||||
|
|
||||||
morphProgress.stop()
|
|
||||||
morphProgress.snapTo(0f)
|
|
||||||
|
|
||||||
globalRotation.stop()
|
|
||||||
globalRotation.snapTo(0f)
|
|
||||||
|
|
||||||
coroutineScope {
|
|
||||||
launch {
|
|
||||||
while (isActive) {
|
|
||||||
val deferred = async {
|
|
||||||
morphProgress.animateTo(
|
|
||||||
1f,
|
|
||||||
spring(
|
|
||||||
dampingRatio = 0.6f,
|
|
||||||
stiffness = 200f,
|
|
||||||
visibilityThreshold = 0.1f
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
currentMorphIndex =
|
|
||||||
(currentMorphIndex + 1) % connectingMorphs.size
|
|
||||||
|
|
||||||
morphProgress.snapTo(0f)
|
|
||||||
|
|
||||||
morphRotationTarget =
|
|
||||||
(morphRotationTarget + 90f) % 360f
|
|
||||||
}
|
|
||||||
|
|
||||||
delay(650.milliseconds)
|
|
||||||
deferred.await()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
launch {
|
|
||||||
globalRotation.animateTo(
|
|
||||||
targetValue = 360f,
|
|
||||||
animationSpec = infiniteRepeatable(
|
|
||||||
tween(4666, easing = LinearEasing),
|
|
||||||
repeatMode = RepeatMode.Restart
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
add(Morph(connectingShapes.last(), connectingShapes.first()))
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
globalRotation.stop()
|
|
||||||
morphProgress.stop()
|
|
||||||
|
|
||||||
morphProgress.snapTo(0f)
|
var previousState by remember { mutableStateOf(connectionState) }
|
||||||
|
|
||||||
morphProgress.animateTo(
|
var pressed by remember { mutableStateOf(false) }
|
||||||
1f,
|
|
||||||
spring(
|
val touchMorph = remember {
|
||||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
Morph(
|
||||||
stiffness = Spring.StiffnessMedium
|
if (connectionState == ConnectionState.CONNECTED) MaterialShapes.SoftBurst.normalized() else MaterialShapes.Circle.normalized(),
|
||||||
|
MaterialShapes.Cookie4Sided.normalized()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val touchProgress = remember { Animatable(0f) }
|
||||||
|
|
||||||
|
LaunchedEffect(pressed) {
|
||||||
|
touchProgress.animateTo(
|
||||||
|
targetValue = if (pressed) 1f else 0f,
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = 0.6f,
|
||||||
|
stiffness = 200f,
|
||||||
|
visibilityThreshold = 0.1f
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
previousState = connectionState
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
val morph = remember(
|
var currentMorphIndex by remember { mutableIntStateOf(0) }
|
||||||
connectionState,
|
var morphRotationTarget by remember { mutableFloatStateOf(90f) }
|
||||||
previousState,
|
|
||||||
currentMorphIndex
|
val morphProgress = remember { Animatable(0f) }
|
||||||
) {
|
val globalRotation = remember { Animatable(0f) }
|
||||||
if (connectionState == ConnectionState.CONNECTING) {
|
|
||||||
connectingMorphs[currentMorphIndex]
|
LaunchedEffect(connectionState) {
|
||||||
} else {
|
if (connectionState == ConnectionState.CONNECTING) {
|
||||||
Morph(previousState.shape(), connectionState.shape())
|
pressed = false
|
||||||
|
currentMorphIndex = 0
|
||||||
|
morphRotationTarget = 90f
|
||||||
|
|
||||||
|
morphProgress.stop()
|
||||||
|
morphProgress.snapTo(0f)
|
||||||
|
|
||||||
|
globalRotation.stop()
|
||||||
|
globalRotation.snapTo(0f)
|
||||||
|
|
||||||
|
coroutineScope {
|
||||||
|
launch {
|
||||||
|
while (isActive) {
|
||||||
|
val deferred = async {
|
||||||
|
morphProgress.animateTo(
|
||||||
|
1f,
|
||||||
|
spring(
|
||||||
|
dampingRatio = 0.6f,
|
||||||
|
stiffness = 200f,
|
||||||
|
visibilityThreshold = 0.1f
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
currentMorphIndex =
|
||||||
|
(currentMorphIndex + 1) % connectingMorphs.size
|
||||||
|
|
||||||
|
morphProgress.snapTo(0f)
|
||||||
|
|
||||||
|
morphRotationTarget =
|
||||||
|
(morphRotationTarget + 90f) % 360f
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(650.milliseconds)
|
||||||
|
deferred.await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
launch {
|
||||||
|
globalRotation.animateTo(
|
||||||
|
targetValue = 360f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
tween(4666, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Restart
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
globalRotation.stop()
|
||||||
|
morphProgress.stop()
|
||||||
|
|
||||||
|
morphProgress.snapTo(0f)
|
||||||
|
|
||||||
|
morphProgress.animateTo(
|
||||||
|
1f,
|
||||||
|
spring(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
|
stiffness = Spring.StiffnessMedium
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
previousState = connectionState
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
val iconBackgroundColor by animateColorAsState(
|
val morph = remember(
|
||||||
targetValue = when (connectionState) {
|
connectionState,
|
||||||
ConnectionState.CONNECTING -> MaterialTheme.colorScheme.secondaryContainer
|
previousState,
|
||||||
ConnectionState.CONNECTED -> MaterialTheme.colorScheme.primaryContainer
|
currentMorphIndex
|
||||||
ConnectionState.DISCONNECTING -> MaterialTheme.colorScheme.surfaceContainer
|
) {
|
||||||
ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.surfaceDim
|
if (connectionState == ConnectionState.CONNECTING) {
|
||||||
ConnectionState.AVAILABLE -> MaterialTheme.colorScheme.surfaceBright
|
connectingMorphs[currentMorphIndex]
|
||||||
},
|
} else {
|
||||||
animationSpec = spring(
|
Morph(previousState.shape(), connectionState.shape())
|
||||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
}
|
||||||
stiffness = Spring.StiffnessMedium
|
}
|
||||||
),
|
|
||||||
label = "iconBackgroundColor"
|
|
||||||
)
|
|
||||||
|
|
||||||
val iconColor by animateColorAsState(
|
val iconBackgroundColor by animateColorAsState(
|
||||||
targetValue = when (connectionState) {
|
targetValue = when (connectionState) {
|
||||||
ConnectionState.CONNECTING -> MaterialTheme.colorScheme.onSecondaryContainer
|
ConnectionState.CONNECTING -> MaterialTheme.colorScheme.secondaryContainer
|
||||||
ConnectionState.CONNECTED -> MaterialTheme.colorScheme.onPrimaryContainer
|
ConnectionState.CONNECTED -> MaterialTheme.colorScheme.primaryContainer
|
||||||
ConnectionState.DISCONNECTING -> MaterialTheme.colorScheme.onSurface
|
ConnectionState.DISCONNECTING -> MaterialTheme.colorScheme.surfaceContainer
|
||||||
ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.contentColorFor(MaterialTheme.colorScheme.surfaceDim)
|
ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.surfaceDim
|
||||||
ConnectionState.AVAILABLE -> MaterialTheme.colorScheme.contentColorFor(MaterialTheme.colorScheme.surfaceBright)
|
ConnectionState.AVAILABLE -> MaterialTheme.colorScheme.surfaceBright
|
||||||
},
|
},
|
||||||
animationSpec = spring(
|
animationSpec = spring(
|
||||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
stiffness = Spring.StiffnessMedium
|
stiffness = Spring.StiffnessMedium
|
||||||
),
|
),
|
||||||
label = "iconColor"
|
label = "iconBackgroundColor"
|
||||||
)
|
)
|
||||||
|
|
||||||
val path = remember { Path() }
|
val iconColor by animateColorAsState(
|
||||||
val matrix = remember { Matrix() }
|
targetValue = when (connectionState) {
|
||||||
|
ConnectionState.CONNECTING -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
ConnectionState.CONNECTED -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
ConnectionState.DISCONNECTING -> MaterialTheme.colorScheme.onSurface
|
||||||
|
ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.contentColorFor(MaterialTheme.colorScheme.surfaceDim)
|
||||||
|
ConnectionState.AVAILABLE -> MaterialTheme.colorScheme.contentColorFor(MaterialTheme.colorScheme.surfaceBright)
|
||||||
|
},
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||||
|
stiffness = Spring.StiffnessMedium
|
||||||
|
),
|
||||||
|
label = "iconColor"
|
||||||
|
)
|
||||||
|
|
||||||
StyledListItem(
|
val path = remember { Path() }
|
||||||
onClick = if (device.connectionState.collectAsState().value == ConnectionState.CONNECTED) { { navigateToDevice(macAddress) } } else null,
|
val matrix = remember { Matrix() }
|
||||||
contentText = deviceMetadata.name,
|
|
||||||
leadingContent = {
|
StyledListItem(
|
||||||
Box(
|
onClick = if (device.connectionState.collectAsState().value == ConnectionState.CONNECTED) { { navigateToDevice(macAddress) } } else null,
|
||||||
modifier = Modifier
|
contentText = deviceMetadata.name,
|
||||||
.size(56.dp)
|
leadingContent = {
|
||||||
.pointerInput(Unit) {
|
Box(
|
||||||
detectTapGestures(
|
modifier = Modifier
|
||||||
onPress = {
|
.size(56.dp)
|
||||||
pressed = true
|
.pointerInput(Unit) {
|
||||||
tryAwaitRelease()
|
detectTapGestures(
|
||||||
pressed = false
|
onPress = {
|
||||||
},
|
pressed = true
|
||||||
onTap = {
|
tryAwaitRelease()
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
pressed = false
|
||||||
when (connectionState) {
|
},
|
||||||
ConnectionState.CONNECTED -> device.disconnect()
|
onTap = {
|
||||||
ConnectionState.DISCONNECTED -> device.connect()
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
else -> {}
|
when (connectionState) {
|
||||||
|
ConnectionState.CONNECTED -> device.disconnect()
|
||||||
|
ConnectionState.DISCONNECTED -> device.connect()
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.drawBehind {
|
|
||||||
val activeMorph: Morph
|
|
||||||
val activeProgress: Float
|
|
||||||
|
|
||||||
if (connectionState != ConnectionState.CONNECTING && touchProgress.value > 0f) {
|
|
||||||
activeMorph = touchMorph
|
|
||||||
activeProgress = touchProgress.value
|
|
||||||
} else {
|
|
||||||
activeMorph = morph
|
|
||||||
activeProgress = morphProgress.value
|
|
||||||
}
|
|
||||||
|
|
||||||
val shapePath = activeMorph.toPath(
|
|
||||||
progress = activeProgress,
|
|
||||||
path = path
|
|
||||||
)
|
|
||||||
|
|
||||||
val bounds = shapePath.getBounds()
|
|
||||||
|
|
||||||
val scale = min(
|
|
||||||
size.width / bounds.width,
|
|
||||||
size.height / bounds.height
|
|
||||||
) * 0.9f
|
|
||||||
|
|
||||||
matrix.reset()
|
|
||||||
matrix.scale(scale, scale)
|
|
||||||
|
|
||||||
shapePath.transform(matrix)
|
|
||||||
|
|
||||||
shapePath.translate(
|
|
||||||
size.center - shapePath.getBounds().center
|
|
||||||
)
|
|
||||||
|
|
||||||
val rotation =
|
|
||||||
if (connectionState == ConnectionState.CONNECTING) {
|
|
||||||
morphProgress.value * 90f +
|
|
||||||
morphRotationTarget +
|
|
||||||
globalRotation.value
|
|
||||||
} else {
|
|
||||||
0f
|
|
||||||
}
|
|
||||||
|
|
||||||
rotate(rotation) {
|
|
||||||
drawPath(
|
|
||||||
path = shapePath,
|
|
||||||
color = iconBackgroundColor
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
.drawBehind {
|
||||||
contentAlignment = Alignment.Center
|
val activeMorph: Morph
|
||||||
) {
|
val activeProgress: Float
|
||||||
Icon(
|
|
||||||
imageVector = LocalIcons.current.fromName(deviceMetadata.iconName)?: LocalIcons.current.Headphones,
|
if (connectionState != ConnectionState.CONNECTING && touchProgress.value > 0f) {
|
||||||
contentDescription = null,
|
activeMorph = touchMorph
|
||||||
modifier = Modifier.size(32.dp),
|
activeProgress = touchProgress.value
|
||||||
tint = iconColor
|
} else {
|
||||||
)
|
activeMorph = morph
|
||||||
}
|
activeProgress = morphProgress.value
|
||||||
},
|
}
|
||||||
supportingContent = {
|
|
||||||
when (connectionState) {
|
val shapePath = activeMorph.toPath(
|
||||||
ConnectionState.AVAILABLE -> {
|
progress = activeProgress,
|
||||||
when (deviceState) {
|
path = path
|
||||||
is AppleState -> {
|
)
|
||||||
|
|
||||||
|
val bounds = shapePath.getBounds()
|
||||||
|
|
||||||
|
val scale = min(
|
||||||
|
size.width / bounds.width,
|
||||||
|
size.height / bounds.height
|
||||||
|
) * 0.9f
|
||||||
|
|
||||||
|
matrix.reset()
|
||||||
|
matrix.scale(scale, scale)
|
||||||
|
|
||||||
|
shapePath.transform(matrix)
|
||||||
|
|
||||||
|
shapePath.translate(
|
||||||
|
size.center - shapePath.getBounds().center
|
||||||
|
)
|
||||||
|
|
||||||
|
val rotation =
|
||||||
|
if (connectionState == ConnectionState.CONNECTING) {
|
||||||
|
morphProgress.value * 90f +
|
||||||
|
morphRotationTarget +
|
||||||
|
globalRotation.value
|
||||||
|
} else {
|
||||||
|
0f
|
||||||
|
}
|
||||||
|
|
||||||
|
rotate(rotation) {
|
||||||
|
drawPath(
|
||||||
|
path = shapePath,
|
||||||
|
color = iconBackgroundColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = LocalIcons.current.fromName(deviceMetadata.iconName)?: LocalIcons.current.Headphones,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
tint = iconColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
when (connectionState) {
|
||||||
|
ConnectionState.AVAILABLE -> {
|
||||||
|
when (deviceState) {
|
||||||
|
is AppleState -> {
|
||||||
// battery from BLE
|
// battery from BLE
|
||||||
|
}
|
||||||
|
else -> Text(
|
||||||
|
text = "????",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
}
|
}
|
||||||
else -> Text(
|
}
|
||||||
text = "????",
|
|
||||||
|
ConnectionState.DISCONNECTING, ConnectionState.DISCONNECTED -> {
|
||||||
|
Text(
|
||||||
|
text = macAddress.value,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
ConnectionState.DISCONNECTING, ConnectionState.DISCONNECTED -> {
|
ConnectionState.CONNECTING -> {
|
||||||
Text(
|
Text(
|
||||||
text = macAddress.value,
|
text = stringResource(R.string.connecting),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
ConnectionState.CONNECTING -> {
|
ConnectionState.CONNECTED -> {
|
||||||
Text(
|
when (deviceState) {
|
||||||
text = stringResource(R.string.connecting),
|
is AppleState -> {
|
||||||
style = MaterialTheme.typography.bodySmall,
|
Column (
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
)
|
) {
|
||||||
}
|
val deviceState = deviceState as AppleState
|
||||||
|
val deviceMetadata = deviceMetadata as AppleMetadata
|
||||||
|
|
||||||
ConnectionState.CONNECTED -> {
|
val batteryRichText = createAirPodsBatteryRichText(
|
||||||
when (deviceState) {
|
battery = deviceState.battery,
|
||||||
is AppleState -> {
|
airPodsSpec = AirPodsSpecs.getSpec(deviceMetadata.model)
|
||||||
Column (
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
|
||||||
) {
|
|
||||||
val deviceState = deviceState as AppleState
|
|
||||||
val deviceMetadata = deviceMetadata as AppleMetadata
|
|
||||||
|
|
||||||
val batteryRichText = createAirPodsBatteryRichText(
|
|
||||||
battery = deviceState.battery,
|
|
||||||
airPodsSpec = AirPodsSpecs.getSpec(deviceMetadata.model)
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = batteryRichText.text,
|
|
||||||
inlineContent = batteryRichText.inlineContent,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
|
|
||||||
if (AirPodsSpecs.getSpec(deviceMetadata.model).baseCapabilities.contains(BaseCapability.LISTENING_MODE)) {
|
|
||||||
NoiseControlSettings(
|
|
||||||
showOffListeningMode = deviceState.controlStates[ControlCommandIdentifier.LISTENING_MODE]?.get(0) == 1.toByte(),
|
|
||||||
noiseControlModeValue = deviceState.controlStates[ControlCommandIdentifier.LISTENING_MODE]?.get(0)?.toInt() ?: 2,
|
|
||||||
onNoiseControlModeChanged = { newMode ->
|
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
|
||||||
(device as AppleDevice).setControlCommand(
|
|
||||||
ControlCommandIdentifier.LISTENING_MODE,
|
|
||||||
newMode.toByte()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
showLabels = false
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = batteryRichText.text,
|
||||||
|
inlineContent = batteryRichText.inlineContent,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
|
||||||
|
if (AirPodsSpecs.getSpec(deviceMetadata.model).baseCapabilities.contains(BaseCapability.LISTENING_MODE)) {
|
||||||
|
NoiseControlSettings(
|
||||||
|
showOffListeningMode = deviceState.controlStates[ControlCommandIdentifier.LISTENING_MODE]?.get(0) == 1.toByte(),
|
||||||
|
noiseControlModeValue = deviceState.controlStates[ControlCommandIdentifier.LISTENING_MODE]?.get(0)?.toInt() ?: 2,
|
||||||
|
onNoiseControlModeChanged = { newMode ->
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
(device as AppleDevice).setControlCommand(
|
||||||
|
ControlCommandIdentifier.LISTENING_MODE,
|
||||||
|
newMode.toByte()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showLabels = false
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
orientation = StyledListItemOrientation.Vertical
|
||||||
orientation = StyledListItemOrientation.Vertical
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.padding(top = bottomPadding))
|
Spacer(modifier = Modifier.padding(top = bottomPadding))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-35
@@ -1,35 +0,0 @@
|
|||||||
package me.kavishdevar.librepods.presentation.screens
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.material3.CircularWavyProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun LoadingScreen() {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
CircularWavyProgressIndicator(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(120.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun LoadingScreenPreview() {
|
|
||||||
LibrePodsTheme {
|
|
||||||
LoadingScreen()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+115
-110
@@ -43,6 +43,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.mikepenz.aboutlibraries.ui.compose.LibrariesContainer
|
import com.mikepenz.aboutlibraries.ui.compose.LibrariesContainer
|
||||||
import com.mikepenz.aboutlibraries.ui.compose.LibraryDefaults
|
import com.mikepenz.aboutlibraries.ui.compose.LibraryDefaults
|
||||||
@@ -57,131 +58,135 @@ import com.mikepenz.aboutlibraries.ui.compose.variant.LibraryDetailMode
|
|||||||
import com.mikepenz.aboutlibraries.ui.compose.variant.LibraryInlineDetail
|
import com.mikepenz.aboutlibraries.ui.compose.variant.LibraryInlineDetail
|
||||||
import com.mikepenz.aboutlibraries.ui.compose.variant.LibraryRow
|
import com.mikepenz.aboutlibraries.ui.compose.variant.LibraryRow
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun OpenSourceLicensesScreen() {
|
fun OpenSourceLicensesScreen(
|
||||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
navigateBack: (() -> Unit)?
|
||||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
) {
|
||||||
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
StyledScaffold(
|
||||||
|
title = stringResource(R.string.open_source_licenses),
|
||||||
|
navigateBack = navigateBack
|
||||||
|
) { topPadding, bottomPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(Modifier.height(topPadding))
|
||||||
|
|
||||||
Column(
|
val libraries by produceLibraries(R.raw.aboutlibraries)
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
Spacer(Modifier.height(topPadding))
|
|
||||||
|
|
||||||
val libraries by produceLibraries(R.raw.aboutlibraries)
|
val count = libraries?.libraries?.size ?: 0
|
||||||
|
|
||||||
val count = libraries?.libraries?.size ?: 0
|
LibrariesContainer(
|
||||||
|
libraries = libraries,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
|
||||||
LibrariesContainer(
|
contentPadding = PaddingValues(top = 16.dp, bottom = bottomPadding),
|
||||||
libraries = libraries,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
|
|
||||||
contentPadding = PaddingValues(top = 16.dp, bottom = bottomPadding),
|
badges = LibraryBadges(version = true),
|
||||||
|
|
||||||
badges = LibraryBadges(version = true),
|
variant = LibrariesVariant.Refined,
|
||||||
|
detailMode = LibraryDetailMode.Inline,
|
||||||
|
|
||||||
variant = LibrariesVariant.Refined,
|
colors = LibraryDefaults.libraryColors(
|
||||||
detailMode = LibraryDetailMode.Inline,
|
libraryBackgroundColor = MaterialTheme.colorScheme.surface,
|
||||||
|
libraryContentColor = MaterialTheme.colorScheme.onBackground,
|
||||||
|
),
|
||||||
|
|
||||||
colors = LibraryDefaults.libraryColors(
|
variantColors = LibraryDefaults.m3VariantColors(
|
||||||
libraryBackgroundColor = MaterialTheme.colorScheme.surface,
|
rowBackground = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
libraryContentColor = MaterialTheme.colorScheme.onBackground,
|
rowOnBackground = MaterialTheme.colorScheme.onSurface,
|
||||||
),
|
rowExpandedBackground = MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
),
|
||||||
|
|
||||||
variantColors = LibraryDefaults.m3VariantColors(
|
divider = {
|
||||||
rowBackground = MaterialTheme.colorScheme.surfaceContainer,
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
rowOnBackground = MaterialTheme.colorScheme.onSurface,
|
},
|
||||||
rowExpandedBackground = MaterialTheme.colorScheme.surfaceContainer
|
|
||||||
),
|
|
||||||
|
|
||||||
divider = {
|
libraryRow = { index, library, expanded, toggle, style ->
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
val transition = updateTransition(
|
||||||
},
|
targetState = expanded,
|
||||||
|
label = "library"
|
||||||
libraryRow = { index, library, expanded, toggle, style ->
|
|
||||||
val transition = updateTransition(
|
|
||||||
targetState = expanded,
|
|
||||||
label = "library"
|
|
||||||
)
|
|
||||||
|
|
||||||
val bottomCorner by transition.animateDp(
|
|
||||||
transitionSpec = {
|
|
||||||
spring(
|
|
||||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
|
||||||
stiffness = Spring.StiffnessMediumLow,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
label = "bottomCorner"
|
|
||||||
) { expanded ->
|
|
||||||
if (expanded) 0.dp else if (index == count - 1) 24.dp else 8.dp
|
|
||||||
}
|
|
||||||
|
|
||||||
val topCorner = when {
|
|
||||||
count == 1 -> 24.dp
|
|
||||||
index == 0 -> 24.dp
|
|
||||||
index == count - 1 -> 8.dp
|
|
||||||
else -> 8.dp
|
|
||||||
}
|
|
||||||
|
|
||||||
val shape = RoundedCornerShape(
|
|
||||||
topStart = topCorner,
|
|
||||||
topEnd = topCorner,
|
|
||||||
bottomStart = bottomCorner,
|
|
||||||
bottomEnd = bottomCorner,
|
|
||||||
)
|
|
||||||
|
|
||||||
LibraryRow(
|
|
||||||
library = library,
|
|
||||||
expanded = expanded,
|
|
||||||
onToggle = toggle,
|
|
||||||
style = style,
|
|
||||||
variant = LibrariesVariant.Refined,
|
|
||||||
badges = LibraryBadges(version = true),
|
|
||||||
modifier = Modifier.clip(shape)
|
|
||||||
)
|
|
||||||
|
|
||||||
transition.AnimatedVisibility(
|
|
||||||
visible = { it },
|
|
||||||
enter = expandVertically(
|
|
||||||
animationSpec = spring(
|
|
||||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
|
||||||
stiffness = Spring.StiffnessMediumLow,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
exit = shrinkVertically(
|
|
||||||
animationSpec = spring(
|
|
||||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
|
||||||
stiffness = Spring.StiffnessMediumLow,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
LibraryInlineDetail(
|
|
||||||
library = library,
|
|
||||||
actionMode = LibraryActionMode.Chips,
|
|
||||||
style = style,
|
|
||||||
actionLabels = DefaultLibraryActionBadges,
|
|
||||||
onActionClick = { _, _ -> false },
|
|
||||||
onDialogRequest = { },
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(
|
|
||||||
RoundedCornerShape(
|
|
||||||
bottomStart = if (index == count - 1) 24.dp else 8.dp,
|
|
||||||
bottomEnd = if (index == count - 1) 24.dp else 8.dp
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.height(bottomPadding))
|
val bottomCorner by transition.animateDp(
|
||||||
|
transitionSpec = {
|
||||||
|
spring(
|
||||||
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||||
|
stiffness = Spring.StiffnessMediumLow,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = "bottomCorner"
|
||||||
|
) { expanded ->
|
||||||
|
if (expanded) 0.dp else if (index == count - 1) 24.dp else 8.dp
|
||||||
|
}
|
||||||
|
|
||||||
|
val topCorner = when {
|
||||||
|
count == 1 -> 24.dp
|
||||||
|
index == 0 -> 24.dp
|
||||||
|
index == count - 1 -> 8.dp
|
||||||
|
else -> 8.dp
|
||||||
|
}
|
||||||
|
|
||||||
|
val shape = RoundedCornerShape(
|
||||||
|
topStart = topCorner,
|
||||||
|
topEnd = topCorner,
|
||||||
|
bottomStart = bottomCorner,
|
||||||
|
bottomEnd = bottomCorner,
|
||||||
|
)
|
||||||
|
|
||||||
|
LibraryRow(
|
||||||
|
library = library,
|
||||||
|
expanded = expanded,
|
||||||
|
onToggle = toggle,
|
||||||
|
style = style,
|
||||||
|
variant = LibrariesVariant.Refined,
|
||||||
|
badges = LibraryBadges(version = true),
|
||||||
|
modifier = Modifier.clip(shape)
|
||||||
|
)
|
||||||
|
|
||||||
|
transition.AnimatedVisibility(
|
||||||
|
visible = { it },
|
||||||
|
enter = expandVertically(
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||||
|
stiffness = Spring.StiffnessMediumLow,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
exit = shrinkVertically(
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||||
|
stiffness = Spring.StiffnessMediumLow,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
LibraryInlineDetail(
|
||||||
|
library = library,
|
||||||
|
actionMode = LibraryActionMode.Chips,
|
||||||
|
style = style,
|
||||||
|
actionLabels = DefaultLibraryActionBadges,
|
||||||
|
onActionClick = { _, _ -> false },
|
||||||
|
onDialogRequest = { },
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(
|
||||||
|
RoundedCornerShape(
|
||||||
|
bottomStart = if (index == count - 1) 24.dp else 8.dp,
|
||||||
|
bottomEnd = if (index == count - 1) 24.dp else 8.dp
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(bottomPadding))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+137
-139
@@ -36,7 +36,6 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -45,12 +44,12 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
|||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
|
|
||||||
import me.kavishdevar.librepods.presentation.components.MaterialButtonStyle
|
import me.kavishdevar.librepods.presentation.components.MaterialButtonStyle
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledList
|
import me.kavishdevar.librepods.presentation.components.StyledList
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||||
import me.kavishdevar.librepods.presentation.navigation.Screen
|
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
|
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
|
||||||
@@ -59,7 +58,7 @@ import me.kavishdevar.librepods.utils.XposedState
|
|||||||
@Composable
|
@Composable
|
||||||
fun PurchaseScreen(
|
fun PurchaseScreen(
|
||||||
viewModel: PurchaseViewModel = viewModel(),
|
viewModel: PurchaseViewModel = viewModel(),
|
||||||
backStack: SnapshotStateList<Screen>
|
navigateBack: (() -> Unit)?,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
@@ -68,150 +67,149 @@ fun PurchaseScreen(
|
|||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
|
||||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
StyledScaffold(
|
||||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
title = stringResource(R.string.unlock_advanced_features),
|
||||||
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
navigateBack = navigateBack
|
||||||
|
) { topPadding, bottomPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.layerBackdrop(backdrop)
|
||||||
|
.verticalScroll(scrollState)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
Column(
|
LaunchedEffect(state.isPremium) {
|
||||||
modifier = Modifier
|
if (state.isPremium) {
|
||||||
.layerBackdrop(backdrop)
|
navigateBack?.invoke()
|
||||||
.verticalScroll(scrollState)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(topPadding))
|
|
||||||
|
|
||||||
LaunchedEffect(state.isPremium) {
|
|
||||||
if (state.isPremium) {
|
|
||||||
if (backStack.size > 1) {
|
|
||||||
backStack.removeAt(backStack.lastIndex)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
if (!state.isPremium) {
|
||||||
if (!state.isPremium) {
|
StyledList(title = stringResource(R.string.free_features)) {
|
||||||
StyledList(title = stringResource(R.string.free_features)) {
|
|
||||||
StyledListItem(
|
|
||||||
contentText = stringResource(R.string.ear_detection),
|
|
||||||
supportingText = stringResource(R.string.ear_detection_description),
|
|
||||||
enabled = false,
|
|
||||||
orientation = StyledListItemOrientation.Vertical
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledListItem(
|
|
||||||
contentText = stringResource(R.string.battery),
|
|
||||||
supportingText = stringResource(R.string.battery_description),
|
|
||||||
enabled = false,
|
|
||||||
orientation = StyledListItemOrientation.Vertical
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledListItem(
|
|
||||||
contentText = stringResource(R.string.noise_control),
|
|
||||||
supportingText = stringResource(R.string.noise_control_description),
|
|
||||||
enabled = false,
|
|
||||||
orientation = StyledListItemOrientation.Vertical
|
|
||||||
)
|
|
||||||
|
|
||||||
if (XposedState.isAvailable) {
|
|
||||||
StyledListItem(
|
StyledListItem(
|
||||||
contentText = "${stringResource(R.string.hearing_aid)} (${stringResource(R.string.requires_xposed)})",
|
contentText = stringResource(R.string.ear_detection),
|
||||||
supportingText = stringResource(R.string.hearing_aid_description)
|
supportingText = stringResource(R.string.ear_detection_description),
|
||||||
.substringBefore("\n\n"),
|
enabled = false,
|
||||||
|
orientation = StyledListItemOrientation.Vertical
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.battery),
|
||||||
|
supportingText = stringResource(R.string.battery_description),
|
||||||
|
enabled = false,
|
||||||
|
orientation = StyledListItemOrientation.Vertical
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.noise_control),
|
||||||
|
supportingText = stringResource(R.string.noise_control_description),
|
||||||
|
enabled = false,
|
||||||
|
orientation = StyledListItemOrientation.Vertical
|
||||||
|
)
|
||||||
|
|
||||||
|
if (XposedState.isAvailable) {
|
||||||
|
StyledListItem(
|
||||||
|
contentText = "${stringResource(R.string.hearing_aid)} (${stringResource(R.string.requires_xposed)})",
|
||||||
|
supportingText = stringResource(R.string.hearing_aid_description)
|
||||||
|
.substringBefore("\n\n"),
|
||||||
|
enabled = false,
|
||||||
|
orientation = StyledListItemOrientation.Vertical
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
StyledList(title = stringResource(R.string.advanced_features), description = stringResource(R.string.feature_availability_disclaimer)) {
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.conversational_awareness),
|
||||||
|
supportingText = stringResource(R.string.conversational_awareness_description),
|
||||||
|
enabled = false,
|
||||||
|
orientation = StyledListItemOrientation.Vertical
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.digital_assistant_on_long_press),
|
||||||
|
supportingText = stringResource(R.string.digital_assistant_on_long_press_description),
|
||||||
|
enabled = false,
|
||||||
|
orientation = StyledListItemOrientation.Vertical
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.head_gestures),
|
||||||
|
supportingText = stringResource(R.string.head_gestures_details),
|
||||||
|
enabled = false,
|
||||||
|
orientation = StyledListItemOrientation.Vertical
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.advanced_device_settings),
|
||||||
|
supportingText = stringResource(R.string.advanced_device_settings_description),
|
||||||
|
enabled = false,
|
||||||
|
orientation = StyledListItemOrientation.Vertical
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.automatic_connection),
|
||||||
|
supportingText = stringResource(R.string.automatic_connection_description),
|
||||||
|
enabled = false,
|
||||||
|
orientation = StyledListItemOrientation.Vertical
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.customizations),
|
||||||
|
supportingText = stringResource(R.string.customizations_description),
|
||||||
|
enabled = false,
|
||||||
|
orientation = StyledListItemOrientation.Vertical
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.support_the_development),
|
||||||
|
supportingText = stringResource(R.string.support_development_description),
|
||||||
enabled = false,
|
enabled = false,
|
||||||
orientation = StyledListItemOrientation.Vertical
|
orientation = StyledListItemOrientation.Vertical
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
StyledButton(
|
||||||
|
onClick = {
|
||||||
|
viewModel.purchase(context)
|
||||||
|
},
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxScale = 0.05f,
|
||||||
|
surfaceColor = MaterialTheme.colorScheme.primary,
|
||||||
|
materialButtonStyle = MaterialButtonStyle.Filled
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.buy_price, state.price),
|
||||||
|
style = MaterialTheme.typography.bodyMediumEmphasized,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
StyledButton(
|
||||||
|
onClick = {
|
||||||
|
viewModel.restorePurchases()
|
||||||
|
},
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxScale = 0.05f,
|
||||||
|
isInteractive = false,
|
||||||
|
materialButtonStyle = MaterialButtonStyle.Outlined
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.restore_purchases),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
StyledList(title = stringResource(R.string.advanced_features), description = stringResource(R.string.feature_availability_disclaimer)) {
|
|
||||||
StyledListItem(
|
|
||||||
contentText = stringResource(R.string.conversational_awareness),
|
|
||||||
supportingText = stringResource(R.string.conversational_awareness_description),
|
|
||||||
enabled = false,
|
|
||||||
orientation = StyledListItemOrientation.Vertical
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledListItem(
|
|
||||||
contentText = stringResource(R.string.digital_assistant_on_long_press),
|
|
||||||
supportingText = stringResource(R.string.digital_assistant_on_long_press_description),
|
|
||||||
enabled = false,
|
|
||||||
orientation = StyledListItemOrientation.Vertical
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledListItem(
|
|
||||||
contentText = stringResource(R.string.head_gestures),
|
|
||||||
supportingText = stringResource(R.string.head_gestures_details),
|
|
||||||
enabled = false,
|
|
||||||
orientation = StyledListItemOrientation.Vertical
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledListItem(
|
|
||||||
contentText = stringResource(R.string.advanced_device_settings),
|
|
||||||
supportingText = stringResource(R.string.advanced_device_settings_description),
|
|
||||||
enabled = false,
|
|
||||||
orientation = StyledListItemOrientation.Vertical
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledListItem(
|
|
||||||
contentText = stringResource(R.string.automatic_connection),
|
|
||||||
supportingText = stringResource(R.string.automatic_connection_description),
|
|
||||||
enabled = false,
|
|
||||||
orientation = StyledListItemOrientation.Vertical
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledListItem(
|
|
||||||
contentText = stringResource(R.string.customizations),
|
|
||||||
supportingText = stringResource(R.string.customizations_description),
|
|
||||||
enabled = false,
|
|
||||||
orientation = StyledListItemOrientation.Vertical
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledListItem(
|
|
||||||
contentText = stringResource(R.string.support_the_development),
|
|
||||||
supportingText = stringResource(R.string.support_development_description),
|
|
||||||
enabled = false,
|
|
||||||
orientation = StyledListItemOrientation.Vertical
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
|
|
||||||
StyledButton(
|
|
||||||
onClick = {
|
|
||||||
viewModel.purchase(context)
|
|
||||||
},
|
|
||||||
backdrop = rememberLayerBackdrop(),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
maxScale = 0.05f,
|
|
||||||
surfaceColor = MaterialTheme.colorScheme.primary,
|
|
||||||
materialButtonStyle = MaterialButtonStyle.Filled
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.buy_price, state.price),
|
|
||||||
style = MaterialTheme.typography.bodyMediumEmphasized,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
StyledButton(
|
|
||||||
onClick = {
|
|
||||||
viewModel.restorePurchases()
|
|
||||||
},
|
|
||||||
backdrop = rememberLayerBackdrop(),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
maxScale = 0.05f,
|
|
||||||
isInteractive = false,
|
|
||||||
materialButtonStyle = MaterialButtonStyle.Outlined
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.restore_purchases),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(bottomPadding))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-922
@@ -1,922 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.presentation.screens
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.slideInVertically
|
|
||||||
import androidx.compose.animation.slideOutVertically
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.horizontalScroll
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.statusBars
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Delete
|
|
||||||
import androidx.compose.material.icons.filled.Share
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.Font
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.core.content.FileProvider
|
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import me.kavishdevar.librepods.R
|
|
||||||
import me.kavishdevar.librepods.presentation.icons.LocalIcons
|
|
||||||
import me.kavishdevar.librepods.utils.LogCollector
|
|
||||||
import java.io.File
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun CustomIconButton(
|
|
||||||
onClick: () -> Unit,
|
|
||||||
content: @Composable () -> Unit
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
.padding(8.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
|
||||||
@Composable
|
|
||||||
fun TroubleshootingScreen() {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val scrollState = rememberScrollState()
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
val logCollector = remember { LogCollector(context) }
|
|
||||||
val savedLogs = remember { mutableStateListOf<File>() }
|
|
||||||
|
|
||||||
var isCollectingLogs by remember { mutableStateOf(false) }
|
|
||||||
var showTroubleshootingSteps by remember { mutableStateOf(false) }
|
|
||||||
var currentStep by remember { mutableIntStateOf(0) }
|
|
||||||
var logContent by remember { mutableStateOf("") }
|
|
||||||
var selectedLogFile by remember { mutableStateOf<File?>(null) }
|
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
|
||||||
var showDeleteAllDialog by remember { mutableStateOf(false) }
|
|
||||||
var isLoadingLogContent by remember { mutableStateOf(false) }
|
|
||||||
var logContentLoaded by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
LaunchedEffect(isCollectingLogs) {
|
|
||||||
while (isCollectingLogs) {
|
|
||||||
delay(250)
|
|
||||||
delay(250)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
|
||||||
var showBottomSheet by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
|
|
||||||
val accentColor = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
|
||||||
val buttonBgColor = if (isSystemInDarkTheme()) Color(0xFF333333) else Color(0xFFDDDDDD)
|
|
||||||
|
|
||||||
var instructionText by remember { mutableStateOf("") }
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val logsDir = File(context.filesDir, "logs")
|
|
||||||
if (logsDir.exists()) {
|
|
||||||
savedLogs.clear()
|
|
||||||
savedLogs.addAll(logsDir.listFiles()?.filter { it.name.endsWith(".txt") }
|
|
||||||
?.sortedByDescending { it.lastModified() } ?: emptyList())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val saveLauncher = rememberLauncherForActivityResult(
|
|
||||||
contract = ActivityResultContracts.CreateDocument("text/plain")
|
|
||||||
) { uri ->
|
|
||||||
if (uri != null) {
|
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
|
||||||
outputStream.write(logContent.toByteArray())
|
|
||||||
}
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
Toast.makeText(context, "Log saved successfully", Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
"Failed to save log: ${e.localizedMessage}",
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(currentStep) {
|
|
||||||
instructionText = when (currentStep) {
|
|
||||||
0 -> "First, let's ensure Xposed module is properly configured. Tap the button below to check Xposed scope settings."
|
|
||||||
1 -> "Please put your AirPods in the case and close it, so they disconnect completely."
|
|
||||||
2 -> "Preparing to collect logs... Please wait."
|
|
||||||
3 -> "Now, open the AirPods case and connect your AirPods. Logs are being collected. Connection will be detected automatically, or you can manually stop logging when you're done."
|
|
||||||
4 -> "Log collection complete! You can now save or share the logs."
|
|
||||||
else -> ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun openLogBottomSheet(file: File) {
|
|
||||||
selectedLogFile = file
|
|
||||||
logContent = ""
|
|
||||||
isLoadingLogContent = false
|
|
||||||
logContentLoaded = false
|
|
||||||
showBottomSheet = true
|
|
||||||
}
|
|
||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
) {
|
|
||||||
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
|
||||||
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.layerBackdrop(backdrop)
|
|
||||||
.verticalScroll(scrollState)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(topPadding))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.saved_logs),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.inter))
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(16.dp, bottom = 4.dp, top = 8.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
|
|
||||||
if (savedLogs.isEmpty()) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
backgroundColor,
|
|
||||||
RoundedCornerShape(28.dp)
|
|
||||||
)
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.no_logs_found),
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
backgroundColor,
|
|
||||||
RoundedCornerShape(28.dp)
|
|
||||||
)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 8.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Total Logs: ${savedLogs.size}",
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
|
|
||||||
if (savedLogs.size > 1) {
|
|
||||||
TextButton(
|
|
||||||
onClick = { showDeleteAllDialog = true },
|
|
||||||
colors = ButtonDefaults.textButtonColors(
|
|
||||||
contentColor = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text("Delete All")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
savedLogs.forEach { logFile ->
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 8.dp)
|
|
||||||
.clickable {
|
|
||||||
openLogBottomSheet(logFile)
|
|
||||||
},
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = logFile.name,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.US)
|
|
||||||
.format(Date(logFile.lastModified())),
|
|
||||||
fontSize = 14.sp,
|
|
||||||
color = textColor.copy(alpha = 0.6f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
CustomIconButton(
|
|
||||||
onClick = {
|
|
||||||
selectedLogFile = logFile
|
|
||||||
showDeleteDialog = true
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Delete,
|
|
||||||
contentDescription = "Delete",
|
|
||||||
tint = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = !showTroubleshootingSteps,
|
|
||||||
enter = fadeIn(animationSpec = tween(300)),
|
|
||||||
exit = fadeOut(animationSpec = tween(300))
|
|
||||||
) {
|
|
||||||
Button(
|
|
||||||
onClick = { showTroubleshootingSteps = true },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(10.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = buttonBgColor,
|
|
||||||
contentColor = textColor
|
|
||||||
),
|
|
||||||
enabled = !isCollectingLogs
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.collect_logs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = showTroubleshootingSteps,
|
|
||||||
enter = fadeIn(animationSpec = tween(300)) +
|
|
||||||
slideInVertically(animationSpec = tween(300)) { it / 2 },
|
|
||||||
exit = fadeOut(animationSpec = tween(300)) +
|
|
||||||
slideOutVertically(animationSpec = tween(300)) { it / 2 }
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.troubleshooting_steps),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Light,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.inter))
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 8.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
backgroundColor,
|
|
||||||
RoundedCornerShape(28.dp)
|
|
||||||
)
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
val textAlpha = animateFloatAsState(
|
|
||||||
targetValue = 1f,
|
|
||||||
animationSpec = tween(durationMillis = 300),
|
|
||||||
label = "textAlpha"
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = instructionText,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor.copy(alpha = textAlpha.value),
|
|
||||||
lineHeight = 22.sp
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
when (currentStep) {
|
|
||||||
0 -> {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
coroutineScope.launch {
|
|
||||||
logCollector.openXposedSettings(context)
|
|
||||||
delay(2000)
|
|
||||||
currentStep = 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(10.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = buttonBgColor,
|
|
||||||
contentColor = textColor
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text("Open Xposed Settings")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
1 -> {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
currentStep = 2
|
|
||||||
isCollectingLogs = true
|
|
||||||
|
|
||||||
coroutineScope.launch {
|
|
||||||
try {
|
|
||||||
logCollector.clearLogs()
|
|
||||||
|
|
||||||
logCollector.addLogMarker(LogCollector.LogMarkerType.START)
|
|
||||||
|
|
||||||
logCollector.killBluetoothService()
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
delay(500)
|
|
||||||
currentStep = 3
|
|
||||||
}
|
|
||||||
|
|
||||||
val timestamp = SimpleDateFormat(
|
|
||||||
"yyyyMMdd_HHmmss",
|
|
||||||
Locale.US
|
|
||||||
).format(Date())
|
|
||||||
|
|
||||||
logContent =
|
|
||||||
logCollector.startLogCollection(
|
|
||||||
listener = { /* Removed live log display */ },
|
|
||||||
connectionDetectedCallback = {
|
|
||||||
launch {
|
|
||||||
delay(5000)
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
if (isCollectingLogs) {
|
|
||||||
logCollector.stopLogCollection()
|
|
||||||
currentStep = 4
|
|
||||||
isCollectingLogs =
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val logFile =
|
|
||||||
logCollector.saveLogToInternalStorage(
|
|
||||||
"airpods_log_$timestamp.txt",
|
|
||||||
logContent
|
|
||||||
)
|
|
||||||
logFile?.let {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
savedLogs.add(0, it)
|
|
||||||
selectedLogFile = it
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
"Log saved: ${it.name}",
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
"Error collecting logs: ${e.message}",
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
isCollectingLogs = false
|
|
||||||
currentStep = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(10.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = buttonBgColor,
|
|
||||||
contentColor = textColor
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text("Continue")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
2, 3 -> {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
color = accentColor
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = if (currentStep == 2) "Preparing..." else "Collecting logs...",
|
|
||||||
fontSize = 14.sp,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
|
|
||||||
if (currentStep == 3) {
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
coroutineScope.launch {
|
|
||||||
logCollector.addLogMarker(
|
|
||||||
LogCollector.LogMarkerType.CUSTOM,
|
|
||||||
"Manual stop requested by user"
|
|
||||||
)
|
|
||||||
delay(1000)
|
|
||||||
logCollector.stopLogCollection()
|
|
||||||
delay(500)
|
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
currentStep = 4
|
|
||||||
isCollectingLogs = false
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
"Log collection stopped",
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
shape = RoundedCornerShape(10.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = buttonBgColor,
|
|
||||||
contentColor = textColor
|
|
||||||
),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text("Stop Collection")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
4 -> {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
selectedLogFile?.let { file ->
|
|
||||||
val fileUri = FileProvider.getUriForFile(
|
|
||||||
context,
|
|
||||||
"${context.packageName}.provider",
|
|
||||||
file
|
|
||||||
)
|
|
||||||
val shareIntent =
|
|
||||||
Intent(Intent.ACTION_SEND).apply {
|
|
||||||
type = "text/plain"
|
|
||||||
putExtra(
|
|
||||||
Intent.EXTRA_STREAM,
|
|
||||||
fileUri
|
|
||||||
)
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
}
|
|
||||||
context.startActivity(
|
|
||||||
Intent.createChooser(
|
|
||||||
shareIntent,
|
|
||||||
"Share log file"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
shape = RoundedCornerShape(10.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = buttonBgColor,
|
|
||||||
contentColor = textColor
|
|
||||||
),
|
|
||||||
modifier = Modifier.width(150.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Share,
|
|
||||||
contentDescription = "Share"
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text("Share")
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
selectedLogFile?.let { file ->
|
|
||||||
saveLauncher.launch(
|
|
||||||
file.absolutePath
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
shape = RoundedCornerShape(10.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = buttonBgColor,
|
|
||||||
contentColor = textColor
|
|
||||||
),
|
|
||||||
modifier = Modifier.width(150.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = LocalIcons.current.Save,
|
|
||||||
contentDescription = "Save"
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text("Save")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
currentStep = 0
|
|
||||||
showTroubleshootingSteps = false
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
shape = RoundedCornerShape(10.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = buttonBgColor,
|
|
||||||
contentColor = textColor
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text("Done")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showDeleteDialog && selectedLogFile != null) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showDeleteDialog = false },
|
|
||||||
title = { Text("Delete Log File") },
|
|
||||||
text = {
|
|
||||||
Text("Are you sure you want to delete this log file? This action cannot be undone.")
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
selectedLogFile?.let { file ->
|
|
||||||
if (file.delete()) {
|
|
||||||
savedLogs.remove(file)
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
"Log file deleted",
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
)
|
|
||||||
.show()
|
|
||||||
} else {
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
"Failed to delete log file",
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
showDeleteDialog = false
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { showDeleteDialog = false }) {
|
|
||||||
Text("Cancel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showDeleteAllDialog) {
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showDeleteAllDialog = false },
|
|
||||||
title = { Text("Delete All Logs") },
|
|
||||||
text = {
|
|
||||||
Text("Are you sure you want to delete all log files? This action cannot be undone and will remove ${savedLogs.size} log files.")
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
|
||||||
var deletedCount = 0
|
|
||||||
savedLogs.forEach { file ->
|
|
||||||
if (file.delete()) {
|
|
||||||
deletedCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
if (deletedCount > 0) {
|
|
||||||
savedLogs.clear()
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
"Deleted $deletedCount log files",
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
} else {
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
"Failed to delete log files",
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
showDeleteAllDialog = false
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Text("Delete All", color = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { showDeleteAllDialog = false }) {
|
|
||||||
Text("Cancel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(bottomPadding))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showBottomSheet) {
|
|
||||||
ModalBottomSheet(
|
|
||||||
onDismissRequest = { showBottomSheet = false },
|
|
||||||
sheetState = sheetState,
|
|
||||||
containerColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7),
|
|
||||||
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
|
|
||||||
tonalElevation = 8.dp
|
|
||||||
) {
|
|
||||||
LaunchedEffect(selectedLogFile) {
|
|
||||||
if (!logContentLoaded) {
|
|
||||||
delay(300)
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
isLoadingLogContent = true
|
|
||||||
logContent = try {
|
|
||||||
selectedLogFile?.readText() ?: ""
|
|
||||||
} catch (e: Exception) {
|
|
||||||
"Error loading log content: ${e.message}"
|
|
||||||
}
|
|
||||||
isLoadingLogContent = false
|
|
||||||
logContentLoaded = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
|
||||||
.padding(bottom = 32.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(bottom = 12.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = selectedLogFile?.name ?: "Log Content",
|
|
||||||
style = TextStyle(
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 20.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.inter))
|
|
||||||
),
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.US)
|
|
||||||
.format(Date(selectedLogFile?.lastModified() ?: 0)),
|
|
||||||
fontSize = 14.sp,
|
|
||||||
color = textColor.copy(alpha = 0.7f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.inter))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoadingLogContent) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(300.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(color = accentColor)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(300.dp)
|
|
||||||
.background(
|
|
||||||
color = Color.Black,
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
val horizontalScrollState = rememberScrollState()
|
|
||||||
val verticalScrollState = rememberScrollState()
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(8.dp)
|
|
||||||
.horizontalScroll(horizontalScrollState)
|
|
||||||
.verticalScroll(verticalScrollState)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = logContent,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
color = Color.LightGray,
|
|
||||||
lineHeight = 20.sp,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
softWrap = false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = 16.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
selectedLogFile?.let { file ->
|
|
||||||
val fileUri = FileProvider.getUriForFile(
|
|
||||||
context,
|
|
||||||
"${context.packageName}.provider",
|
|
||||||
file
|
|
||||||
)
|
|
||||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
|
||||||
type = "text/plain"
|
|
||||||
putExtra(Intent.EXTRA_STREAM, fileUri)
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
}
|
|
||||||
context.startActivity(
|
|
||||||
Intent.createChooser(
|
|
||||||
shareIntent,
|
|
||||||
"Share log file"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
shape = RoundedCornerShape(10.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = buttonBgColor,
|
|
||||||
contentColor = textColor
|
|
||||||
),
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Share,
|
|
||||||
contentDescription = "Share"
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text("Share")
|
|
||||||
}
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
selectedLogFile?.let { file ->
|
|
||||||
saveLauncher.launch(file.absolutePath)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
shape = RoundedCornerShape(10.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = buttonBgColor,
|
|
||||||
contentColor = textColor
|
|
||||||
),
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = LocalIcons.current.Save,
|
|
||||||
contentDescription = "Save"
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text("Save")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
logCollector.stopLogCollection()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+211
-206
@@ -18,7 +18,6 @@
|
|||||||
|
|
||||||
package me.kavishdevar.librepods.presentation.screens.apple
|
package me.kavishdevar.librepods.presentation.screens.apple
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -56,6 +55,7 @@ import me.kavishdevar.librepods.devices.BaseCapability
|
|||||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledList
|
import me.kavishdevar.librepods.presentation.components.StyledList
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
import me.kavishdevar.librepods.presentation.icons.LocalIcons
|
import me.kavishdevar.librepods.presentation.icons.LocalIcons
|
||||||
@@ -67,7 +67,12 @@ import kotlin.time.Duration.Companion.milliseconds
|
|||||||
//private var phoneMediaDebounceJob: Job? = null
|
//private var phoneMediaDebounceJob: Job? = null
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AccessibilitySettingsScreen(viewModel: AppleViewModel, navigateToPurchase: () -> Unit, navigateToTransparencyCustomization: () -> Unit) {
|
fun AccessibilitySettingsScreen(
|
||||||
|
viewModel: AppleViewModel,
|
||||||
|
navigateBack: (() -> Unit)?,
|
||||||
|
navigateToPurchase: () -> Unit,
|
||||||
|
navigateToTransparencyCustomization: () -> Unit
|
||||||
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
val state = uiState.state
|
val state = uiState.state
|
||||||
@@ -82,89 +87,88 @@ fun AccessibilitySettingsScreen(viewModel: AppleViewModel, navigateToPurchase: (
|
|||||||
)?.toInt() == 1
|
)?.toInt() == 1
|
||||||
|
|
||||||
|
|
||||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
StyledScaffold(
|
||||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
title = stringResource(R.string.accessibility),
|
||||||
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
navigateBack = navigateBack
|
||||||
|
) { topPadding, bottomPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
Column(
|
if (!uiState.isPremium) {
|
||||||
modifier = Modifier
|
StyledButton(
|
||||||
.fillMaxSize()
|
onClick = navigateToPurchase,
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
backdrop = rememberLayerBackdrop(),
|
||||||
.verticalScroll(rememberScrollState())
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.padding(horizontal = 16.dp),
|
maxScale = 0.05f,
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
surfaceColor = MaterialTheme.colorScheme.primary
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(topPadding))
|
Text(
|
||||||
|
stringResource(R.string.unlock_advanced_features),
|
||||||
if (!uiState.isPremium) {
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
StyledButton(
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
onClick = navigateToPurchase,
|
)
|
||||||
backdrop = rememberLayerBackdrop(),
|
}
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
maxScale = 0.05f,
|
|
||||||
surfaceColor = MaterialTheme.colorScheme.primary
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.unlock_advanced_features),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
// val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
|
// val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
|
||||||
// val phoneEQEnabled = remember { mutableStateOf(false) }
|
// val phoneEQEnabled = remember { mutableStateOf(false) }
|
||||||
// val mediaEQEnabled = remember { mutableStateOf(false) }
|
// val mediaEQEnabled = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val pressSpeedOptions = mapOf(
|
val pressSpeedOptions = mapOf(
|
||||||
0.toByte() to stringResource(R.string.default_option),
|
0.toByte() to stringResource(R.string.default_option),
|
||||||
1.toByte() to stringResource(R.string.slower),
|
1.toByte() to stringResource(R.string.slower),
|
||||||
2.toByte() to stringResource(R.string.slowest)
|
2.toByte() to stringResource(R.string.slowest)
|
||||||
)
|
)
|
||||||
|
|
||||||
val selectedPressSpeedValue =
|
val selectedPressSpeedValue =
|
||||||
state.controlStates[ControlCommandIdentifier.DOUBLE_CLICK_INTERVAL]?.getOrNull(
|
state.controlStates[ControlCommandIdentifier.DOUBLE_CLICK_INTERVAL]?.getOrNull(
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
var selectedPressSpeed by remember {
|
var selectedPressSpeed by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]
|
pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val pressAndHoldDurationOptions = mapOf(
|
val pressAndHoldDurationOptions = mapOf(
|
||||||
0.toByte() to stringResource(R.string.default_option),
|
0.toByte() to stringResource(R.string.default_option),
|
||||||
1.toByte() to stringResource(R.string.slower),
|
1.toByte() to stringResource(R.string.slower),
|
||||||
2.toByte() to stringResource(R.string.slowest)
|
2.toByte() to stringResource(R.string.slowest)
|
||||||
)
|
)
|
||||||
|
|
||||||
val selectedPressAndHoldDurationValue =
|
val selectedPressAndHoldDurationValue =
|
||||||
state.controlStates[ControlCommandIdentifier.CLICK_HOLD_INTERVAL]?.getOrNull(
|
state.controlStates[ControlCommandIdentifier.CLICK_HOLD_INTERVAL]?.getOrNull(
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
var selectedPressAndHoldDuration by remember {
|
var selectedPressAndHoldDuration by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
pressAndHoldDurationOptions[selectedPressAndHoldDurationValue]
|
pressAndHoldDurationOptions[selectedPressAndHoldDurationValue]
|
||||||
?: pressAndHoldDurationOptions[0]
|
?: pressAndHoldDurationOptions[0]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val volumeSwipeSpeedOptions = mapOf(
|
val volumeSwipeSpeedOptions = mapOf(
|
||||||
1.toByte() to stringResource(R.string.default_option),
|
1.toByte() to stringResource(R.string.default_option),
|
||||||
2.toByte() to stringResource(R.string.longer),
|
2.toByte() to stringResource(R.string.longer),
|
||||||
3.toByte() to stringResource(R.string.longest)
|
3.toByte() to stringResource(R.string.longest)
|
||||||
)
|
|
||||||
val selectedVolumeSwipeSpeedValue =
|
|
||||||
state.controlStates[ControlCommandIdentifier.VOLUME_SWIPE_INTERVAL]?.getOrNull(
|
|
||||||
0
|
|
||||||
)
|
)
|
||||||
var selectedVolumeSwipeSpeed by remember {
|
val selectedVolumeSwipeSpeedValue =
|
||||||
mutableStateOf(
|
state.controlStates[ControlCommandIdentifier.VOLUME_SWIPE_INTERVAL]?.getOrNull(
|
||||||
volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue]
|
0
|
||||||
?: volumeSwipeSpeedOptions[1]
|
)
|
||||||
)
|
var selectedVolumeSwipeSpeed by remember {
|
||||||
}
|
mutableStateOf(
|
||||||
|
volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue]
|
||||||
|
?: volumeSwipeSpeedOptions[1]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
|
// val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
|
||||||
// val phoneEQEnabled = remember { mutableStateOf(false) }
|
// val phoneEQEnabled = remember { mutableStateOf(false) }
|
||||||
@@ -191,151 +195,151 @@ fun AccessibilitySettingsScreen(viewModel: AppleViewModel, navigateToPurchase: (
|
|||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
|
|
||||||
StyledList(
|
|
||||||
title = stringResource(R.string.press_speed),
|
|
||||||
description = stringResource(R.string.press_speed_description)
|
|
||||||
) {
|
|
||||||
pressSpeedOptions.forEach { (value, label) ->
|
|
||||||
StyledListItem(
|
|
||||||
contentText = label,
|
|
||||||
selected = selectedPressSpeed == label,
|
|
||||||
onClick = {
|
|
||||||
selectedPressSpeed = label
|
|
||||||
|
|
||||||
viewModel.setControlCommand(
|
|
||||||
identifier = ControlCommandIdentifier.DOUBLE_CLICK_INTERVAL,
|
|
||||||
value = value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledList(
|
|
||||||
title = stringResource(R.string.press_and_hold_duration),
|
|
||||||
description = stringResource(R.string.press_and_hold_duration_description)
|
|
||||||
) {
|
|
||||||
pressAndHoldDurationOptions.forEach { (value, label) ->
|
|
||||||
StyledListItem(
|
|
||||||
contentText = label,
|
|
||||||
selected = selectedPressAndHoldDuration == label,
|
|
||||||
onClick = {
|
|
||||||
selectedPressAndHoldDuration = label
|
|
||||||
|
|
||||||
viewModel.setControlCommand(
|
|
||||||
identifier = ControlCommandIdentifier.CLICK_HOLD_INTERVAL,
|
|
||||||
value = value
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledToggle(
|
|
||||||
title = stringResource(R.string.noise_control),
|
|
||||||
label = stringResource(R.string.noise_cancellation_single_airpod),
|
|
||||||
description = stringResource(R.string.noise_cancellation_single_airpod_description),
|
|
||||||
checked = state.controlStates[ControlCommandIdentifier.ONE_BUD_ANC_MODE]?.getOrNull(
|
|
||||||
0
|
|
||||||
) == 0x01.toByte(),
|
|
||||||
onCheckedChange = {
|
|
||||||
viewModel.setControlCommand(
|
|
||||||
ControlCommandIdentifier.ONE_BUD_ANC_MODE, it
|
|
||||||
)
|
|
||||||
},
|
|
||||||
enabled = uiState.isPremium
|
|
||||||
)
|
|
||||||
|
|
||||||
if (AirPodsSpecs.getSpec(metadata.model).baseCapabilities.contains(BaseCapability.LOUD_SOUND_REDUCTION) && uiState.vendorIdHook) {
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.loud_sound_reduction),
|
|
||||||
description = stringResource(R.string.loud_sound_reduction_description),
|
|
||||||
checked = state.loudSoundReductionEnabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
viewModel.writeATTCharacteristic(
|
|
||||||
ATTHandle.LOUD_SOUND_REDUCTION,
|
|
||||||
if (it) byteArrayOf(0x01) else byteArrayOf(0x00)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
enabled = uiState.isPremium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hearingAidEnabled && uiState.vendorIdHook) {
|
|
||||||
StyledListItem(
|
|
||||||
contentText = stringResource(R.string.customize_transparency_mode),
|
|
||||||
onClick = navigateToTransparencyCustomization,
|
|
||||||
enabled = uiState.isPremium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val toneVolumeValue = remember { mutableFloatStateOf(state.controlStates[ControlCommandIdentifier.CHIME_VOLUME]?.getOrNull(0)?.toFloat() ?: 75f) }
|
|
||||||
|
|
||||||
LaunchedEffect(toneVolumeValue) {
|
|
||||||
snapshotFlow {
|
|
||||||
toneVolumeValue.floatValue
|
|
||||||
}
|
|
||||||
.debounce(100.milliseconds)
|
|
||||||
.collect {
|
|
||||||
viewModel.setControlCommand(
|
|
||||||
ControlCommandIdentifier.CHIME_VOLUME,
|
|
||||||
byteArrayOf(it.toInt().toByte(), 0x50)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledSlider(
|
|
||||||
label = stringResource(R.string.tone_volume),
|
|
||||||
description = stringResource(R.string.tone_volume_description),
|
|
||||||
value = toneVolumeValue.floatValue,
|
|
||||||
onValueChange = {
|
|
||||||
toneVolumeValue.floatValue = it
|
|
||||||
},
|
|
||||||
valueRange = 0f..100f,
|
|
||||||
snapPoints = listOf(75f),
|
|
||||||
startImageVector = LocalIcons.current.SpeakerMin,
|
|
||||||
endImageVector = LocalIcons.current.SpeakerMax,
|
|
||||||
independent = true,
|
|
||||||
enabled = uiState.isPremium
|
|
||||||
)
|
|
||||||
|
|
||||||
if (AirPodsSpecs.getSpec(metadata.model).baseCapabilities.contains(BaseCapability.SWIPE_FOR_VOLUME)) {
|
|
||||||
val volumeSwipeEnabled =
|
|
||||||
state.controlStates[ControlCommandIdentifier.VOLUME_SWIPE_MODE]?.getOrNull(
|
|
||||||
0
|
|
||||||
)?.toInt() == 0x01
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.volume_control),
|
|
||||||
description = stringResource(R.string.volume_control_description),
|
|
||||||
checked = volumeSwipeEnabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
viewModel.setControlCommand(
|
|
||||||
ControlCommandIdentifier.VOLUME_SWIPE_MODE, it
|
|
||||||
)
|
|
||||||
},
|
|
||||||
enabled = uiState.isPremium
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledList(
|
StyledList(
|
||||||
title = stringResource(R.string.volume_swipe_speed),
|
title = stringResource(R.string.press_speed),
|
||||||
description = stringResource(R.string.volume_swipe_speed_description)
|
description = stringResource(R.string.press_speed_description)
|
||||||
) {
|
) {
|
||||||
volumeSwipeSpeedOptions.forEach { (value, label) ->
|
pressSpeedOptions.forEach { (value, label) ->
|
||||||
StyledListItem(
|
StyledListItem(
|
||||||
contentText = label,
|
contentText = label,
|
||||||
selected = selectedVolumeSwipeSpeed == label,
|
selected = selectedPressSpeed == label,
|
||||||
onClick = {
|
onClick = {
|
||||||
selectedVolumeSwipeSpeed = label
|
selectedPressSpeed = label
|
||||||
|
|
||||||
viewModel.setControlCommand(
|
viewModel.setControlCommand(
|
||||||
identifier = ControlCommandIdentifier.VOLUME_SWIPE_INTERVAL,
|
identifier = ControlCommandIdentifier.DOUBLE_CLICK_INTERVAL,
|
||||||
value = value
|
value = value
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
StyledList(
|
||||||
|
title = stringResource(R.string.press_and_hold_duration),
|
||||||
|
description = stringResource(R.string.press_and_hold_duration_description)
|
||||||
|
) {
|
||||||
|
pressAndHoldDurationOptions.forEach { (value, label) ->
|
||||||
|
StyledListItem(
|
||||||
|
contentText = label,
|
||||||
|
selected = selectedPressAndHoldDuration == label,
|
||||||
|
onClick = {
|
||||||
|
selectedPressAndHoldDuration = label
|
||||||
|
|
||||||
|
viewModel.setControlCommand(
|
||||||
|
identifier = ControlCommandIdentifier.CLICK_HOLD_INTERVAL,
|
||||||
|
value = value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledToggle(
|
||||||
|
title = stringResource(R.string.noise_control),
|
||||||
|
label = stringResource(R.string.noise_cancellation_single_airpod),
|
||||||
|
description = stringResource(R.string.noise_cancellation_single_airpod_description),
|
||||||
|
checked = state.controlStates[ControlCommandIdentifier.ONE_BUD_ANC_MODE]?.getOrNull(
|
||||||
|
0
|
||||||
|
) == 0x01.toByte(),
|
||||||
|
onCheckedChange = {
|
||||||
|
viewModel.setControlCommand(
|
||||||
|
ControlCommandIdentifier.ONE_BUD_ANC_MODE, it
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = uiState.isPremium
|
||||||
|
)
|
||||||
|
|
||||||
|
if (AirPodsSpecs.getSpec(metadata.model).baseCapabilities.contains(BaseCapability.LOUD_SOUND_REDUCTION) && uiState.vendorIdHook) {
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.loud_sound_reduction),
|
||||||
|
description = stringResource(R.string.loud_sound_reduction_description),
|
||||||
|
checked = state.loudSoundReductionEnabled,
|
||||||
|
onCheckedChange = {
|
||||||
|
viewModel.writeATTCharacteristic(
|
||||||
|
ATTHandle.LOUD_SOUND_REDUCTION,
|
||||||
|
if (it) byteArrayOf(0x01) else byteArrayOf(0x00)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = uiState.isPremium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hearingAidEnabled && uiState.vendorIdHook) {
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.customize_transparency_mode),
|
||||||
|
onClick = navigateToTransparencyCustomization,
|
||||||
|
enabled = uiState.isPremium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val toneVolumeValue = remember { mutableFloatStateOf(state.controlStates[ControlCommandIdentifier.CHIME_VOLUME]?.getOrNull(0)?.toFloat() ?: 75f) }
|
||||||
|
|
||||||
|
LaunchedEffect(toneVolumeValue) {
|
||||||
|
snapshotFlow {
|
||||||
|
toneVolumeValue.floatValue
|
||||||
|
}
|
||||||
|
.debounce(100.milliseconds)
|
||||||
|
.collect {
|
||||||
|
viewModel.setControlCommand(
|
||||||
|
ControlCommandIdentifier.CHIME_VOLUME,
|
||||||
|
byteArrayOf(it.toInt().toByte(), 0x50)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledSlider(
|
||||||
|
label = stringResource(R.string.tone_volume),
|
||||||
|
description = stringResource(R.string.tone_volume_description),
|
||||||
|
value = toneVolumeValue.floatValue,
|
||||||
|
onValueChange = {
|
||||||
|
toneVolumeValue.floatValue = it
|
||||||
|
},
|
||||||
|
valueRange = 0f..100f,
|
||||||
|
snapPoints = listOf(75f),
|
||||||
|
startImageVector = LocalIcons.current.SpeakerMin,
|
||||||
|
endImageVector = LocalIcons.current.SpeakerMax,
|
||||||
|
independent = true,
|
||||||
|
enabled = uiState.isPremium
|
||||||
|
)
|
||||||
|
|
||||||
|
if (AirPodsSpecs.getSpec(metadata.model).baseCapabilities.contains(BaseCapability.SWIPE_FOR_VOLUME)) {
|
||||||
|
val volumeSwipeEnabled =
|
||||||
|
state.controlStates[ControlCommandIdentifier.VOLUME_SWIPE_MODE]?.getOrNull(
|
||||||
|
0
|
||||||
|
)?.toInt() == 0x01
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.volume_control),
|
||||||
|
description = stringResource(R.string.volume_control_description),
|
||||||
|
checked = volumeSwipeEnabled,
|
||||||
|
onCheckedChange = {
|
||||||
|
viewModel.setControlCommand(
|
||||||
|
ControlCommandIdentifier.VOLUME_SWIPE_MODE, it
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = uiState.isPremium
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledList(
|
||||||
|
title = stringResource(R.string.volume_swipe_speed),
|
||||||
|
description = stringResource(R.string.volume_swipe_speed_description)
|
||||||
|
) {
|
||||||
|
volumeSwipeSpeedOptions.forEach { (value, label) ->
|
||||||
|
StyledListItem(
|
||||||
|
contentText = label,
|
||||||
|
selected = selectedVolumeSwipeSpeed == label,
|
||||||
|
onClick = {
|
||||||
|
selectedVolumeSwipeSpeed = label
|
||||||
|
|
||||||
|
viewModel.setControlCommand(
|
||||||
|
identifier = ControlCommandIdentifier.VOLUME_SWIPE_INTERVAL,
|
||||||
|
value = value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// if (!hearingAidEnabled && XposedState.isAvailable) {
|
// if (!hearingAidEnabled && XposedState.isAvailable) {
|
||||||
// Text(
|
// Text(
|
||||||
@@ -570,6 +574,7 @@ fun AccessibilitySettingsScreen(viewModel: AppleViewModel, navigateToPurchase: (
|
|||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
Spacer(modifier = Modifier.height(bottomPadding))
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-56
@@ -18,7 +18,6 @@
|
|||||||
|
|
||||||
package me.kavishdevar.librepods.presentation.screens.apple
|
package me.kavishdevar.librepods.presentation.screens.apple
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -48,6 +47,7 @@ import kotlinx.coroutines.flow.debounce
|
|||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
|
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||||
import me.kavishdevar.librepods.presentation.icons.LocalIcons
|
import me.kavishdevar.librepods.presentation.icons.LocalIcons
|
||||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||||
@@ -56,68 +56,71 @@ import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
|||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AdaptiveStrengthScreen(viewModel: AppleViewModel, navigateToPurchase: () -> Unit) {
|
fun AdaptiveStrengthScreen(
|
||||||
|
viewModel: AppleViewModel,
|
||||||
|
navigateBack: (() -> Unit)?,
|
||||||
|
navigateToPurchase: () -> Unit
|
||||||
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
val state = uiState.state
|
val state = uiState.state
|
||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
|
||||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
StyledScaffold(
|
||||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
title = stringResource(R.string.customize_adaptive_audio),
|
||||||
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
navigateBack = navigateBack
|
||||||
|
) { topPadding, bottomPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.layerBackdrop(backdrop)
|
.layerBackdrop(backdrop)
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
.padding(horizontal = 16.dp),
|
||||||
.padding(horizontal = 16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
) {
|
||||||
) {
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
Spacer(modifier = Modifier.height(topPadding))
|
if (!uiState.isPremium) {
|
||||||
if (!uiState.isPremium) {
|
StyledButton(
|
||||||
StyledButton(
|
onClick = navigateToPurchase,
|
||||||
onClick = navigateToPurchase,
|
backdrop = rememberLayerBackdrop(),
|
||||||
backdrop = rememberLayerBackdrop(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
maxScale = 0.05f,
|
||||||
maxScale = 0.05f,
|
surfaceColor = MaterialTheme.colorScheme.primary
|
||||||
surfaceColor = MaterialTheme.colorScheme.primary
|
) {
|
||||||
) {
|
Text(
|
||||||
Text(
|
stringResource(R.string.unlock_advanced_features),
|
||||||
stringResource(R.string.unlock_advanced_features),
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
color = MaterialTheme.colorScheme.onPrimary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
}
|
|
||||||
val sliderValue = remember {
|
|
||||||
mutableFloatStateOf(100f - (state.controlStates[ControlCommandIdentifier.AUTO_ANC_STRENGTH]?.getOrNull(0)?.toFloat() ?: 50f))
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(sliderValue) {
|
|
||||||
snapshotFlow { sliderValue.floatValue }
|
|
||||||
.debounce(100.milliseconds)
|
|
||||||
.collect { value ->
|
|
||||||
viewModel.setControlCommand(
|
|
||||||
ControlCommandIdentifier.AUTO_ANC_STRENGTH,
|
|
||||||
byteArrayOf((100 - value).toInt().toByte())
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
val sliderValue = remember {
|
||||||
|
mutableFloatStateOf(100f - (state.controlStates[ControlCommandIdentifier.AUTO_ANC_STRENGTH]?.getOrNull(0)?.toFloat() ?: 50f))
|
||||||
|
}
|
||||||
|
|
||||||
StyledSlider(
|
LaunchedEffect(sliderValue) {
|
||||||
label = stringResource(R.string.customize_adaptive_audio),
|
snapshotFlow { sliderValue.floatValue }
|
||||||
value = sliderValue.floatValue,
|
.debounce(100.milliseconds)
|
||||||
onValueChange = { sliderValue.floatValue = it },
|
.collect { value ->
|
||||||
valueRange = 0f..100f,
|
viewModel.setControlCommand(
|
||||||
snapPoints = listOf(0f, 50f, 100f),
|
ControlCommandIdentifier.AUTO_ANC_STRENGTH,
|
||||||
startImageVector = LocalIcons.current.SpeakerMin,
|
byteArrayOf((100 - value).toInt().toByte())
|
||||||
endImageVector = LocalIcons.current.SpeakerMax,
|
)
|
||||||
independent = true,
|
}
|
||||||
description = stringResource(R.string.adaptive_audio_description),
|
}
|
||||||
enabled = uiState.isPremium
|
|
||||||
)
|
StyledSlider(
|
||||||
Spacer(modifier = Modifier.height(bottomPadding))
|
value = sliderValue.floatValue,
|
||||||
|
onValueChange = { sliderValue.floatValue = it },
|
||||||
|
valueRange = 0f..100f,
|
||||||
|
snapPoints = listOf(0f, 50f, 100f),
|
||||||
|
startImageVector = LocalIcons.current.SpeakerMin,
|
||||||
|
endImageVector = LocalIcons.current.SpeakerMax,
|
||||||
|
independent = true,
|
||||||
|
description = stringResource(R.string.adaptive_audio_description),
|
||||||
|
enabled = uiState.isPremium
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+337
-338
@@ -16,25 +16,17 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.presentation.screens.apple
|
package me.kavishdevar.librepods.presentation.screens.apple
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBars
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -44,10 +36,8 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
|
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
|
||||||
import me.kavishdevar.librepods.bluetooth.att.ATTHandle
|
import me.kavishdevar.librepods.bluetooth.att.ATTHandle
|
||||||
@@ -59,22 +49,22 @@ import me.kavishdevar.librepods.presentation.components.BatteryView
|
|||||||
import me.kavishdevar.librepods.presentation.components.CallControlSettings
|
import me.kavishdevar.librepods.presentation.components.CallControlSettings
|
||||||
import me.kavishdevar.librepods.presentation.components.ConnectionSettings
|
import me.kavishdevar.librepods.presentation.components.ConnectionSettings
|
||||||
import me.kavishdevar.librepods.presentation.components.HearingHealthSettings
|
import me.kavishdevar.librepods.presentation.components.HearingHealthSettings
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
|
|
||||||
import me.kavishdevar.librepods.presentation.components.NoiseControlSettings
|
import me.kavishdevar.librepods.presentation.components.NoiseControlSettings
|
||||||
import me.kavishdevar.librepods.presentation.components.PressAndHoldSettings
|
import me.kavishdevar.librepods.presentation.components.PressAndHoldSettings
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||||
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState
|
import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AirPodsSettingsRoute(
|
fun AppleSettingsRoute(
|
||||||
viewModel: AppleViewModel,
|
viewModel: AppleViewModel,
|
||||||
|
navigateBack: (() -> Unit)?,
|
||||||
navigateToRename: () -> Unit,
|
navigateToRename: () -> Unit,
|
||||||
navigateToHearingProtection: () -> Unit,
|
navigateToHearingProtection: () -> Unit,
|
||||||
navigateToHearingAid: () -> Unit,
|
navigateToHearingAid: () -> Unit,
|
||||||
@@ -89,66 +79,50 @@ fun AirPodsSettingsRoute(
|
|||||||
navigateToCallControlScreen: (action: String) -> Unit,
|
navigateToCallControlScreen: (action: String) -> Unit,
|
||||||
navigateToMicrophoneSettings: () -> Unit,
|
navigateToMicrophoneSettings: () -> Unit,
|
||||||
navigateToRecordingScreen: () -> Unit,
|
navigateToRecordingScreen: () -> Unit,
|
||||||
navigateToDebugScreen: () -> Unit
|
navigateToDebugScreen: () -> Unit,
|
||||||
|
navigateToHeartRateScreen: () -> Unit
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
AppleSettingsScreen(
|
||||||
val topPadding = WindowInsets.statusBars.asPaddingValues()
|
uiState = uiState,
|
||||||
.calculateTopPadding() + if (m3eEnabled) 0.dp else 84.dp
|
|
||||||
val bottomPadding =
|
|
||||||
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
|
||||||
|
|
||||||
Box(
|
setControlCommandInt = { id, value -> viewModel.setControlCommand(id, value) },
|
||||||
modifier = Modifier
|
setControlCommandBoolean = { id, value -> viewModel.setControlCommand(id, value) },
|
||||||
.fillMaxSize()
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
|
||||||
) {
|
|
||||||
AirPodsSettingsScreen(
|
|
||||||
uiState = uiState,
|
|
||||||
|
|
||||||
topPadding = topPadding,
|
|
||||||
bottomPadding = bottomPadding,
|
|
||||||
|
|
||||||
setControlCommandInt = { id, value -> viewModel.setControlCommand(id, value) },
|
|
||||||
setControlCommandBoolean = { id, value -> viewModel.setControlCommand(id, value) },
|
|
||||||
// setControlCommandByte = { id, value -> viewModel.setControlCommand(id, value) },
|
// setControlCommandByte = { id, value -> viewModel.setControlCommand(id, value) },
|
||||||
// setControlCommandValue = { id, value -> viewModel.setControlCommand(id, value) },
|
// setControlCommandValue = { id, value -> viewModel.setControlCommand(id, value) },
|
||||||
|
|
||||||
writeATTCharacteristic = viewModel::writeATTCharacteristic,
|
writeATTCharacteristic = viewModel::writeATTCharacteristic,
|
||||||
|
|
||||||
// onAutomaticEarDetectionChanged = viewModel::setAutomaticEarDetectionEnabled,
|
// onAutomaticEarDetectionChanged = viewModel::setAutomaticEarDetectionEnabled,
|
||||||
// onAutomaticConnectionChanged = viewModel::setAutomaticConnectionEnabled,
|
// onAutomaticConnectionChanged = viewModel::setAutomaticConnectionEnabled,
|
||||||
disconnect = viewModel::disconnect,
|
disconnect = viewModel::disconnect,
|
||||||
|
|
||||||
navigateToRename = navigateToRename,
|
navigateBack = navigateBack,
|
||||||
navigateToHearingProtection = navigateToHearingProtection,
|
navigateToRename = navigateToRename,
|
||||||
navigateToHearingAid = navigateToHearingAid,
|
navigateToHearingProtection = navigateToHearingProtection,
|
||||||
navigateToLeftLongPress = navigateToLeftLongPress,
|
navigateToHearingAid = navigateToHearingAid,
|
||||||
navigateToRightLongPress = navigateToRightLongPress,
|
navigateToLeftLongPress = navigateToLeftLongPress,
|
||||||
navigateToPurchase = navigateToPurchase,
|
navigateToRightLongPress = navigateToRightLongPress,
|
||||||
navigateToAdaptiveStrength = navigateToAdaptiveStrength,
|
navigateToPurchase = navigateToPurchase,
|
||||||
navigateToEqualizer = navigateToEqualizer,
|
navigateToAdaptiveStrength = navigateToAdaptiveStrength,
|
||||||
navigateToHeadTracking = navigateToHeadTracking,
|
navigateToEqualizer = navigateToEqualizer,
|
||||||
navigateToAccessibility = navigateToAccessibility,
|
navigateToHeadTracking = navigateToHeadTracking,
|
||||||
navigateToVersion = navigateToVersion,
|
navigateToAccessibility = navigateToAccessibility,
|
||||||
navigateToCallControlScreen = navigateToCallControlScreen,
|
navigateToVersion = navigateToVersion,
|
||||||
navigateToMicrophoneSettings = navigateToMicrophoneSettings,
|
navigateToCallControlScreen = navigateToCallControlScreen,
|
||||||
navigateToRecordingScreen = navigateToRecordingScreen,
|
navigateToMicrophoneSettings = navigateToMicrophoneSettings,
|
||||||
navigateToDebugScreen = navigateToDebugScreen
|
navigateToRecordingScreen = navigateToRecordingScreen,
|
||||||
)
|
navigateToHeartRateScreen = navigateToHeartRateScreen,
|
||||||
}
|
navigateToDebugScreen = navigateToDebugScreen
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
|
||||||
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
|
@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
|
||||||
@Composable
|
@Composable
|
||||||
fun AirPodsSettingsScreen(
|
fun AppleSettingsScreen(
|
||||||
uiState: AppleUiState,
|
uiState: AppleUiState,
|
||||||
|
|
||||||
topPadding: Dp = 16.dp,
|
|
||||||
bottomPadding: Dp = 16.dp,
|
|
||||||
|
|
||||||
setControlCommandInt: (ControlCommandIdentifier, Int) -> Unit,
|
setControlCommandInt: (ControlCommandIdentifier, Int) -> Unit,
|
||||||
setControlCommandBoolean: (ControlCommandIdentifier, Boolean) -> Unit,
|
setControlCommandBoolean: (ControlCommandIdentifier, Boolean) -> Unit,
|
||||||
// setControlCommandByte: (ControlCommandIdentifier, Byte) -> Unit,
|
// setControlCommandByte: (ControlCommandIdentifier, Byte) -> Unit,
|
||||||
@@ -161,6 +135,7 @@ fun AirPodsSettingsScreen(
|
|||||||
|
|
||||||
disconnect: () -> Unit,
|
disconnect: () -> Unit,
|
||||||
|
|
||||||
|
navigateBack: (() -> Unit)?,
|
||||||
navigateToRename: () -> Unit,
|
navigateToRename: () -> Unit,
|
||||||
navigateToHearingProtection: () -> Unit,
|
navigateToHearingProtection: () -> Unit,
|
||||||
navigateToHearingAid: () -> Unit,
|
navigateToHearingAid: () -> Unit,
|
||||||
@@ -175,6 +150,7 @@ fun AirPodsSettingsScreen(
|
|||||||
navigateToCallControlScreen: (action: String) -> Unit,
|
navigateToCallControlScreen: (action: String) -> Unit,
|
||||||
navigateToMicrophoneSettings: () -> Unit,
|
navigateToMicrophoneSettings: () -> Unit,
|
||||||
navigateToRecordingScreen: () -> Unit,
|
navigateToRecordingScreen: () -> Unit,
|
||||||
|
navigateToHeartRateScreen: () -> Unit,
|
||||||
navigateToDebugScreen: () -> Unit
|
navigateToDebugScreen: () -> Unit
|
||||||
) {
|
) {
|
||||||
val state = uiState.state
|
val state = uiState.state
|
||||||
@@ -185,113 +161,128 @@ fun AirPodsSettingsScreen(
|
|||||||
|
|
||||||
val baseCapabilities = spec.baseCapabilities
|
val baseCapabilities = spec.baseCapabilities
|
||||||
|
|
||||||
LazyColumn(
|
StyledScaffold(
|
||||||
modifier = Modifier
|
title = uiState.metadata.name,
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
navigateBack = navigateBack
|
||||||
.padding(horizontal = 16.dp),
|
) { topPadding, bottomPadding ->
|
||||||
) {
|
LazyColumn(
|
||||||
item(key = "top_padding") { Spacer(modifier = Modifier.height(topPadding)) }
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
item(key = "top_padding") { Spacer(modifier = Modifier.height(topPadding)) }
|
||||||
|
|
||||||
item(key = "battery") {
|
item(key = "battery") {
|
||||||
BatteryView(
|
BatteryView(
|
||||||
batteryList = state.battery,
|
batteryList = state.battery,
|
||||||
primaryImageRes = spec.primaryImageRes,
|
primaryImageRes = spec.primaryImageRes,
|
||||||
caseImageRes = spec.caseImageRes ?: R.drawable.img_airpods_pro_2_case // TODO
|
caseImageRes = spec.caseImageRes ?: R.drawable.img_airpods_pro_2_case // TODO
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item(key = "spacer_battery") {
|
item(key = "spacer_battery") {
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
item(key = "name") {
|
item(key = "name") {
|
||||||
StyledListItem(
|
StyledListItem(
|
||||||
contentText = stringResource(R.string.name),
|
contentText = stringResource(R.string.name),
|
||||||
supportingText = metadata.name,
|
supportingText = metadata.name,
|
||||||
onClick = navigateToRename,
|
onClick = navigateToRename,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val hasHearingAidCapability = baseCapabilities.contains(BaseCapability.HEARING_AID)
|
val hasHearingAidCapability = baseCapabilities.contains(BaseCapability.HEARING_AID)
|
||||||
val hasPPECapability = baseCapabilities.contains(BaseCapability.PPE)
|
val hasPPECapability = baseCapabilities.contains(BaseCapability.PPE)
|
||||||
|
|
||||||
if (hasHearingAidCapability || hasPPECapability) {
|
if (hasHearingAidCapability || hasPPECapability) {
|
||||||
if (hasPPECapability || uiState.vendorIdHook) {
|
if (hasPPECapability || uiState.vendorIdHook) {
|
||||||
item(key = "spacer_hearing_health") {
|
item(key = "spacer_hearing_health") {
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item(key = "hearing_health") {
|
||||||
|
HearingHealthSettings(
|
||||||
|
hasPPECapability = hasPPECapability,
|
||||||
|
hasHearingAidCapability = hasHearingAidCapability,
|
||||||
|
vendorIdHook = uiState.vendorIdHook,
|
||||||
|
navigateToHearingProtection = navigateToHearingProtection,
|
||||||
|
navigateToHearingAid = navigateToHearingAid
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item(key = "hearing_health") {
|
|
||||||
HearingHealthSettings(
|
|
||||||
hasPPECapability = hasPPECapability,
|
|
||||||
hasHearingAidCapability = hasHearingAidCapability,
|
|
||||||
vendorIdHook = uiState.vendorIdHook,
|
|
||||||
navigateToHearingProtection = navigateToHearingProtection,
|
|
||||||
navigateToHearingAid = navigateToHearingAid
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metadata.version3.startsWith("8") || metadata.version3.startsWith("9")) {
|
if (metadata.version3.startsWith("8") || metadata.version3.startsWith("9")) {
|
||||||
item(key = "spacer_recording") {
|
item(key = "spacer_recording") {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
item(key = "recording") {
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.recorder),
|
||||||
|
supportingText = stringResource(R.string.recorder_description),
|
||||||
|
onClick = navigateToRecordingScreen,
|
||||||
|
orientation = StyledListItemOrientation.Vertical
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseCapabilities.contains(BaseCapability.LISTENING_MODE)) {
|
||||||
|
item(key = "spacer_noise") {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
item(key = "noise_control") {
|
||||||
|
NoiseControlSettings(
|
||||||
|
showOffListeningMode = state.controlStates[ControlCommandIdentifier.ALLOW_OFF_OPTION]?.getOrNull(0)?.toInt() == 1,
|
||||||
|
noiseControlModeValue = state.controlStates[ControlCommandIdentifier.LISTENING_MODE]?.getOrNull(0)?.toInt() ?: 3,
|
||||||
|
onNoiseControlModeChanged = {
|
||||||
|
setControlCommandInt(
|
||||||
|
ControlCommandIdentifier.LISTENING_MODE, it
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseCapabilities.contains(BaseCapability.HRM)) {
|
||||||
|
item(key = "spacer_heart_rate") {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
item(key = "heart_rate") {
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.heart_rate),
|
||||||
|
onClick = navigateToHeartRateScreen,
|
||||||
|
supportingText = state.currentHeartRate?.let { "${it.bpm} bpm" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseCapabilities.contains(BaseCapability.STEM_CONFIG)) {
|
||||||
|
item(key = "spacer_press_hold") {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
item(key = "press_hold") {
|
||||||
|
PressAndHoldSettings(
|
||||||
|
leftAction = settings.leftLongPressAction,
|
||||||
|
rightAction = settings.rightLongPressAction,
|
||||||
|
navigateToLeftLongPress = navigateToLeftLongPress,
|
||||||
|
navigateToRightLongPress = navigateToRightLongPress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "spacer_call") {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
item(key = "recording") {
|
item(key = "call_control") {
|
||||||
StyledListItem(
|
val bytes =
|
||||||
contentText = stringResource(R.string.recorder),
|
state.controlStates[ControlCommandIdentifier.CALL_MANAGEMENT_CONFIG]?.take(2)?.toByteArray() ?: byteArrayOf(0x00, 0x00)
|
||||||
supportingText = stringResource(R.string.recorder_description),
|
val flipped = try {
|
||||||
onClick = navigateToRecordingScreen,
|
bytes[1] == 0x02.toByte()
|
||||||
orientation = StyledListItemOrientation.Vertical
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
CallControlSettings(
|
||||||
|
flipped = flipped,
|
||||||
|
navigateToCallControlScreen = navigateToCallControlScreen
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (baseCapabilities.contains(BaseCapability.LISTENING_MODE)) {
|
|
||||||
item(key = "spacer_noise") {
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
}
|
|
||||||
item(key = "noise_control") {
|
|
||||||
NoiseControlSettings(
|
|
||||||
showOffListeningMode = state.controlStates[ControlCommandIdentifier.ALLOW_OFF_OPTION]?.getOrNull(0)?.toInt() == 1,
|
|
||||||
noiseControlModeValue = state.controlStates[ControlCommandIdentifier.LISTENING_MODE]?.getOrNull(0)?.toInt() ?: 3,
|
|
||||||
onNoiseControlModeChanged = {
|
|
||||||
setControlCommandInt(
|
|
||||||
ControlCommandIdentifier.LISTENING_MODE, it
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (baseCapabilities.contains(BaseCapability.STEM_CONFIG)) {
|
|
||||||
item(key = "spacer_press_hold") {
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
}
|
|
||||||
item(key = "press_hold") {
|
|
||||||
PressAndHoldSettings(
|
|
||||||
leftAction = settings.leftLongPressAction,
|
|
||||||
rightAction = settings.rightLongPressAction,
|
|
||||||
navigateToLeftLongPress = navigateToLeftLongPress,
|
|
||||||
navigateToRightLongPress = navigateToRightLongPress
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
item(key = "spacer_call") {
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
}
|
|
||||||
item(key = "call_control") {
|
|
||||||
val bytes =
|
|
||||||
state.controlStates[ControlCommandIdentifier.CALL_MANAGEMENT_CONFIG]?.take(2)?.toByteArray() ?: byteArrayOf(0x00, 0x00)
|
|
||||||
val flipped = try {
|
|
||||||
bytes[1] == 0x02.toByte()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
CallControlSettings(
|
|
||||||
flipped = flipped,
|
|
||||||
navigateToCallControlScreen = navigateToCallControlScreen
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// if (baseCapabilities.contains(BaseCapability.RAW_GESTURES_CONFIG) && !BuildConfig.PLAY_BUILD) {
|
// if (baseCapabilities.contains(BaseCapability.RAW_GESTURES_CONFIG) && !BuildConfig.PLAY_BUILD) {
|
||||||
// item(key = "spacer_camera") { Spacer(modifier = Modifier.height(16.dp)) }
|
// item(key = "spacer_camera") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
@@ -306,208 +297,212 @@ fun AirPodsSettingsScreen(
|
|||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
|
|
||||||
item(key = "upgrade_button") {
|
item(key = "upgrade_button") {
|
||||||
if (!uiState.isPremium) {
|
if (!uiState.isPremium) {
|
||||||
Spacer(modifier = Modifier.height(28.dp))
|
Spacer(modifier = Modifier.height(28.dp))
|
||||||
|
StyledButton(
|
||||||
|
onClick = navigateToPurchase,
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxScale = 0.05f,
|
||||||
|
surfaceColor = MaterialTheme.colorScheme.primary
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.unlock_advanced_features),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "spacer_audio") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "audio") {
|
||||||
|
val adaptiveVolumeCapability =
|
||||||
|
baseCapabilities.contains(BaseCapability.ADAPTIVE_VOLUME)
|
||||||
|
val conversationalAwarenessCapability =
|
||||||
|
baseCapabilities.contains(BaseCapability.CONVERSATION_AWARENESS)
|
||||||
|
val loudSoundReductionCapability =
|
||||||
|
baseCapabilities.contains(BaseCapability.LOUD_SOUND_REDUCTION)
|
||||||
|
val adaptiveAudioCapability =
|
||||||
|
baseCapabilities.contains(BaseCapability.ADAPTIVE_VOLUME)
|
||||||
|
|
||||||
|
val adaptiveVolumeChecked =
|
||||||
|
state.controlStates[ControlCommandIdentifier.ADAPTIVE_VOLUME_CONFIG]?.getOrNull(
|
||||||
|
0
|
||||||
|
) == 0x01.toByte()
|
||||||
|
val conversationalAwarenessChecked =
|
||||||
|
state.controlStates[ControlCommandIdentifier.CONVERSATION_DETECT_CONFIG]?.getOrNull(
|
||||||
|
0
|
||||||
|
) == 0x01.toByte()
|
||||||
|
|
||||||
|
AudioSettings(
|
||||||
|
adaptiveVolumeCapability = adaptiveVolumeCapability,
|
||||||
|
conversationalAwarenessCapability = conversationalAwarenessCapability,
|
||||||
|
loudSoundReductionCapability = loudSoundReductionCapability,
|
||||||
|
adaptiveAudioCapability = adaptiveAudioCapability,
|
||||||
|
customEqCapability = metadata.version3.startsWith("9"),
|
||||||
|
adaptiveVolumeChecked = adaptiveVolumeChecked,
|
||||||
|
onAdaptiveVolumeCheckedChange = { checked ->
|
||||||
|
setControlCommandBoolean(
|
||||||
|
ControlCommandIdentifier.ADAPTIVE_VOLUME_CONFIG,
|
||||||
|
checked
|
||||||
|
)
|
||||||
|
},
|
||||||
|
conversationalAwarenessChecked = conversationalAwarenessChecked && uiState.isPremium,
|
||||||
|
onConversationalAwarenessCheckedChange = { checked ->
|
||||||
|
setControlCommandBoolean(
|
||||||
|
ControlCommandIdentifier.CONVERSATION_DETECT_CONFIG,
|
||||||
|
checked
|
||||||
|
)
|
||||||
|
},
|
||||||
|
loudSoundReductionChecked = state.loudSoundReductionEnabled,
|
||||||
|
onLoudSoundReductionCheckedChange = { checked ->
|
||||||
|
writeATTCharacteristic(
|
||||||
|
ATTHandle.LOUD_SOUND_REDUCTION,
|
||||||
|
byteArrayOf(if (checked) 0x01.toByte() else 0x00.toByte())
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigateToAdaptiveStrength = navigateToAdaptiveStrength,
|
||||||
|
navigateToEqualizer = navigateToEqualizer,
|
||||||
|
vendorIdHook = uiState.vendorIdHook,
|
||||||
|
isPremium = uiState.isPremium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "spacer_connection") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "connection") {
|
||||||
|
ConnectionSettings(
|
||||||
|
automaticEarDetectionEnabled = state.controlStates[ControlCommandIdentifier.EAR_DETECTION_CONFIG]?.getOrNull(0) == 0x01.toByte(),
|
||||||
|
onAutomaticEarDetectionChanged = { setControlCommandBoolean(ControlCommandIdentifier.EAR_DETECTION_CONFIG, it) },
|
||||||
|
automaticConnectionEnabled = state.controlStates[ControlCommandIdentifier.SMART_ROUTING_MODE]?.getOrNull(0) == 0x01.toByte(),
|
||||||
|
onAutomaticConnectionChanged = { setControlCommandBoolean(ControlCommandIdentifier.SMART_ROUTING_MODE, it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "spacer_microphone") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "microphoneState") {
|
||||||
|
val id = ControlCommandIdentifier.MIC_MODE
|
||||||
|
|
||||||
|
val selectedModeText =
|
||||||
|
when (state.controlStates[id]?.getOrNull(0) ?: 0x00.toByte()) {
|
||||||
|
0x00.toByte() -> stringResource(R.string.microphone_automatic)
|
||||||
|
0x01.toByte() -> stringResource(R.string.microphone_always_right)
|
||||||
|
0x02.toByte() -> stringResource(R.string.microphone_always_left)
|
||||||
|
else -> stringResource(R.string.microphone_automatic)
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.microphone_mode),
|
||||||
|
supportingText = selectedModeText,
|
||||||
|
onClick = navigateToMicrophoneSettings
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseCapabilities.contains(BaseCapability.SLEEP_DETECTION)) {
|
||||||
|
item(key = "spacer_sleep") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "sleep_detection") {
|
||||||
|
val id = ControlCommandIdentifier.SLEEP_DETECTION_CONFIG
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.sleep_detection),
|
||||||
|
checked = state.controlStates[id]?.getOrNull(0) == 0x01.toByte(),
|
||||||
|
onCheckedChange = { setControlCommandBoolean(id, it) },
|
||||||
|
enabled = uiState.isPremium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseCapabilities.contains(BaseCapability.HEAD_GESTURES)) {
|
||||||
|
item(key = "spacer_head_tracking") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "head_tracking") {
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.head_gestures),
|
||||||
|
supportingText = if (settings.headGesturesEnabled) stringResource(R.string.on) else stringResource(R.string.off),
|
||||||
|
onClick = navigateToHeadTracking
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "spacer_dynamic_end_of_charge") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "dynamic_end_of_charge") {
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.optimized_charging),
|
||||||
|
description = stringResource(R.string.optimized_charging_description),
|
||||||
|
checked = state.controlStates[ControlCommandIdentifier.DYNAMIC_END_OF_CHARGE]?.getOrNull(0) == 0x01.toByte(),
|
||||||
|
onCheckedChange = { setControlCommandBoolean(ControlCommandIdentifier.DYNAMIC_END_OF_CHARGE, it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "spacer_accessibility") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "accessibility") {
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.accessibility), onClick = navigateToAccessibility
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (baseCapabilities.contains(BaseCapability.LOUD_SOUND_REDUCTION) && (metadata.version3.startsWith("8") || metadata.version3.startsWith("9"))) {
|
||||||
|
item(key = "spacer_off_listening") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
|
item(key = "off_listening") {
|
||||||
|
val id = ControlCommandIdentifier.ALLOW_OFF_OPTION
|
||||||
|
StyledToggle(
|
||||||
|
label = stringResource(R.string.off_listening_mode),
|
||||||
|
description = stringResource(R.string.off_listening_mode_description),
|
||||||
|
checked = state.controlStates[id]?.getOrNull(0) == 0x01.toByte(),
|
||||||
|
onCheckedChange = { setControlCommandBoolean(id, it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "spacer_about") { Spacer(modifier = Modifier.height(32.dp)) }
|
||||||
|
item(key = "about") {
|
||||||
|
AboutCard(
|
||||||
|
modelName = metadata.modelName,
|
||||||
|
actualModel = metadata.modelNumber,
|
||||||
|
serialNumbers = listOf(metadata.serialNumber, metadata.leftSerialNumber, metadata.rightSerialNumber),
|
||||||
|
version = metadata.version3,
|
||||||
|
navigateToVersion = navigateToVersion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "spacer_disconnect") { Spacer(modifier = Modifier.height(28.dp)) }
|
||||||
|
item(key = "disconnect_button") {
|
||||||
StyledButton(
|
StyledButton(
|
||||||
onClick = navigateToPurchase,
|
onClick = disconnect,
|
||||||
backdrop = rememberLayerBackdrop(),
|
backdrop = rememberLayerBackdrop(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
isInteractive = false,
|
||||||
maxScale = 0.05f,
|
modifier = Modifier
|
||||||
surfaceColor = MaterialTheme.colorScheme.primary
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 56.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.unlock_advanced_features),
|
text = stringResource(R.string.disconnect),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onPrimary
|
textAlign = TextAlign.Start,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
item(key = "spacer_audio") { Spacer(modifier = Modifier.height(16.dp)) }
|
item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) }
|
||||||
item(key = "audio") {
|
|
||||||
val adaptiveVolumeCapability =
|
|
||||||
baseCapabilities.contains(BaseCapability.ADAPTIVE_VOLUME)
|
|
||||||
val conversationalAwarenessCapability =
|
|
||||||
baseCapabilities.contains(BaseCapability.CONVERSATION_AWARENESS)
|
|
||||||
val loudSoundReductionCapability =
|
|
||||||
baseCapabilities.contains(BaseCapability.LOUD_SOUND_REDUCTION)
|
|
||||||
val adaptiveAudioCapability =
|
|
||||||
baseCapabilities.contains(BaseCapability.ADAPTIVE_VOLUME)
|
|
||||||
|
|
||||||
val adaptiveVolumeChecked =
|
if (uiState.appSettings.debugMode) {
|
||||||
state.controlStates[ControlCommandIdentifier.ADAPTIVE_VOLUME_CONFIG]?.getOrNull(
|
item(key = "debug") {
|
||||||
0
|
StyledListItem(
|
||||||
) == 0x01.toByte()
|
contentText = "debug",
|
||||||
val conversationalAwarenessChecked =
|
onClick = navigateToDebugScreen
|
||||||
state.controlStates[ControlCommandIdentifier.CONVERSATION_DETECT_CONFIG]?.getOrNull(
|
|
||||||
0
|
|
||||||
) == 0x01.toByte()
|
|
||||||
|
|
||||||
AudioSettings(
|
|
||||||
adaptiveVolumeCapability = adaptiveVolumeCapability,
|
|
||||||
conversationalAwarenessCapability = conversationalAwarenessCapability,
|
|
||||||
loudSoundReductionCapability = loudSoundReductionCapability,
|
|
||||||
adaptiveAudioCapability = adaptiveAudioCapability,
|
|
||||||
customEqCapability = metadata.version3.startsWith("9"),
|
|
||||||
adaptiveVolumeChecked = adaptiveVolumeChecked,
|
|
||||||
onAdaptiveVolumeCheckedChange = { checked ->
|
|
||||||
setControlCommandBoolean(
|
|
||||||
ControlCommandIdentifier.ADAPTIVE_VOLUME_CONFIG,
|
|
||||||
checked
|
|
||||||
)
|
)
|
||||||
},
|
|
||||||
conversationalAwarenessChecked = conversationalAwarenessChecked && uiState.isPremium,
|
|
||||||
onConversationalAwarenessCheckedChange = { checked ->
|
|
||||||
setControlCommandBoolean(
|
|
||||||
ControlCommandIdentifier.CONVERSATION_DETECT_CONFIG,
|
|
||||||
checked
|
|
||||||
)
|
|
||||||
},
|
|
||||||
loudSoundReductionChecked = state.loudSoundReductionEnabled,
|
|
||||||
onLoudSoundReductionCheckedChange = { checked ->
|
|
||||||
writeATTCharacteristic(
|
|
||||||
ATTHandle.LOUD_SOUND_REDUCTION,
|
|
||||||
byteArrayOf(if (checked) 0x01.toByte() else 0x00.toByte())
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigateToAdaptiveStrength = navigateToAdaptiveStrength,
|
|
||||||
navigateToEqualizer = navigateToEqualizer,
|
|
||||||
vendorIdHook = uiState.vendorIdHook,
|
|
||||||
isPremium = uiState.isPremium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
item(key = "spacer_connection") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "connection") {
|
|
||||||
ConnectionSettings(
|
|
||||||
automaticEarDetectionEnabled = state.controlStates[ControlCommandIdentifier.EAR_DETECTION_CONFIG]?.getOrNull(0) == 0x01.toByte(),
|
|
||||||
onAutomaticEarDetectionChanged = { setControlCommandBoolean(ControlCommandIdentifier.EAR_DETECTION_CONFIG, it) },
|
|
||||||
automaticConnectionEnabled = state.controlStates[ControlCommandIdentifier.SMART_ROUTING_MODE]?.getOrNull(0) == 0x01.toByte(),
|
|
||||||
onAutomaticConnectionChanged = { setControlCommandBoolean(ControlCommandIdentifier.SMART_ROUTING_MODE, it) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
item(key = "spacer_microphone") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "microphoneState") {
|
|
||||||
val id = ControlCommandIdentifier.MIC_MODE
|
|
||||||
|
|
||||||
val selectedModeText =
|
|
||||||
when (state.controlStates[id]?.getOrNull(0) ?: 0x00.toByte()) {
|
|
||||||
0x00.toByte() -> stringResource(R.string.microphone_automatic)
|
|
||||||
0x01.toByte() -> stringResource(R.string.microphone_always_right)
|
|
||||||
0x02.toByte() -> stringResource(R.string.microphone_always_left)
|
|
||||||
else -> stringResource(R.string.microphone_automatic)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
StyledListItem(
|
|
||||||
contentText = stringResource(R.string.microphone_mode),
|
|
||||||
supportingText = selectedModeText,
|
|
||||||
onClick = navigateToMicrophoneSettings
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (baseCapabilities.contains(BaseCapability.SLEEP_DETECTION)) {
|
|
||||||
item(key = "spacer_sleep") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "sleep_detection") {
|
|
||||||
val id = ControlCommandIdentifier.SLEEP_DETECTION_CONFIG
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.sleep_detection),
|
|
||||||
checked = state.controlStates[id]?.getOrNull(0) == 0x01.toByte(),
|
|
||||||
onCheckedChange = { setControlCommandBoolean(id, it) },
|
|
||||||
enabled = uiState.isPremium
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (baseCapabilities.contains(BaseCapability.HEAD_GESTURES)) {
|
item(key = "bottom_padding") { Spacer(modifier = Modifier.height(bottomPadding)) }
|
||||||
item(key = "spacer_head_tracking") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "head_tracking") {
|
|
||||||
StyledListItem(
|
|
||||||
contentText = stringResource(R.string.head_gestures),
|
|
||||||
supportingText = if (settings.headGesturesEnabled) stringResource(R.string.on) else stringResource(R.string.off),
|
|
||||||
onClick = navigateToHeadTracking
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
item(key = "spacer_dynamic_end_of_charge") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "dynamic_end_of_charge") {
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.optimized_charging),
|
|
||||||
description = stringResource(R.string.optimized_charging_description),
|
|
||||||
checked = state.controlStates[ControlCommandIdentifier.DYNAMIC_END_OF_CHARGE]?.getOrNull(0) == 0x01.toByte(),
|
|
||||||
onCheckedChange = { setControlCommandBoolean(ControlCommandIdentifier.DYNAMIC_END_OF_CHARGE, it) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
item(key = "spacer_accessibility") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "accessibility") {
|
|
||||||
StyledListItem(
|
|
||||||
contentText = stringResource(R.string.accessibility), onClick = navigateToAccessibility
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (baseCapabilities.contains(BaseCapability.LOUD_SOUND_REDUCTION) && (metadata.version3.startsWith("8") || metadata.version3.startsWith("9"))) {
|
|
||||||
item(key = "spacer_off_listening") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "off_listening") {
|
|
||||||
val id = ControlCommandIdentifier.ALLOW_OFF_OPTION
|
|
||||||
StyledToggle(
|
|
||||||
label = stringResource(R.string.off_listening_mode),
|
|
||||||
description = stringResource(R.string.off_listening_mode_description),
|
|
||||||
checked = state.controlStates[id]?.getOrNull(0) == 0x01.toByte(),
|
|
||||||
onCheckedChange = { setControlCommandBoolean(id, it) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
item(key = "spacer_about") { Spacer(modifier = Modifier.height(32.dp)) }
|
|
||||||
item(key = "about") {
|
|
||||||
AboutCard(
|
|
||||||
modelName = metadata.modelName,
|
|
||||||
actualModel = metadata.modelNumber,
|
|
||||||
serialNumbers = listOf(metadata.serialNumber, metadata.leftSerialNumber, metadata.rightSerialNumber),
|
|
||||||
version = metadata.version3,
|
|
||||||
navigateToVersion = navigateToVersion
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
item(key = "spacer_disconnect") { Spacer(modifier = Modifier.height(28.dp)) }
|
|
||||||
item(key = "disconnect_button") {
|
|
||||||
StyledButton(
|
|
||||||
onClick = disconnect,
|
|
||||||
backdrop = rememberLayerBackdrop(),
|
|
||||||
isInteractive = false,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.heightIn(min = 56.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.disconnect),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
textAlign = TextAlign.Start,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) }
|
|
||||||
item(key = "debug") {
|
|
||||||
StyledListItem(
|
|
||||||
contentText = "debug",
|
|
||||||
onClick = navigateToDebugScreen
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
item(key = "bottom_padding") { Spacer(modifier = Modifier.height(bottomPadding)) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview(name = "Apple")
|
@Preview(name = "Apple")
|
||||||
@Composable
|
@Composable
|
||||||
fun AirPodsSettingsScreenPreviewApple() {
|
fun AppleSettingsScreenPreviewApple() {
|
||||||
LibrePodsTheme(
|
LibrePodsTheme(
|
||||||
designSystem = DesignSystem.Apple
|
designSystem = DesignSystem.Apple
|
||||||
) {
|
) {
|
||||||
@@ -515,7 +510,7 @@ fun AirPodsSettingsScreenPreviewApple() {
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
) {
|
) {
|
||||||
AirPodsSettingsScreen(
|
AppleSettingsScreen(
|
||||||
uiState = AppleUiState(),
|
uiState = AppleUiState(),
|
||||||
|
|
||||||
setControlCommandInt = { _, _ -> },
|
setControlCommandInt = { _, _ -> },
|
||||||
@@ -524,6 +519,7 @@ fun AirPodsSettingsScreenPreviewApple() {
|
|||||||
|
|
||||||
disconnect = {},
|
disconnect = {},
|
||||||
|
|
||||||
|
navigateBack = null,
|
||||||
navigateToRename = {},
|
navigateToRename = {},
|
||||||
navigateToHearingProtection = {},
|
navigateToHearingProtection = {},
|
||||||
navigateToHearingAid = {},
|
navigateToHearingAid = {},
|
||||||
@@ -538,6 +534,7 @@ fun AirPodsSettingsScreenPreviewApple() {
|
|||||||
navigateToCallControlScreen = {},
|
navigateToCallControlScreen = {},
|
||||||
navigateToMicrophoneSettings = {},
|
navigateToMicrophoneSettings = {},
|
||||||
navigateToRecordingScreen = {},
|
navigateToRecordingScreen = {},
|
||||||
|
navigateToHeartRateScreen = {},
|
||||||
navigateToDebugScreen = {}
|
navigateToDebugScreen = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -547,7 +544,7 @@ fun AirPodsSettingsScreenPreviewApple() {
|
|||||||
|
|
||||||
@Preview(name = "Material")
|
@Preview(name = "Material")
|
||||||
@Composable
|
@Composable
|
||||||
fun AirPodsSettingsScreenPreviewMaterial() {
|
fun AppleSettingsScreenPreviewMaterial() {
|
||||||
LibrePodsTheme(
|
LibrePodsTheme(
|
||||||
designSystem = DesignSystem.Material
|
designSystem = DesignSystem.Material
|
||||||
) {
|
) {
|
||||||
@@ -555,7 +552,7 @@ fun AirPodsSettingsScreenPreviewMaterial() {
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
) {
|
) {
|
||||||
AirPodsSettingsScreen(
|
AppleSettingsScreen(
|
||||||
uiState = AppleUiState(),
|
uiState = AppleUiState(),
|
||||||
|
|
||||||
setControlCommandInt = { _, _ -> },
|
setControlCommandInt = { _, _ -> },
|
||||||
@@ -564,6 +561,7 @@ fun AirPodsSettingsScreenPreviewMaterial() {
|
|||||||
|
|
||||||
disconnect = {},
|
disconnect = {},
|
||||||
|
|
||||||
|
navigateBack = null,
|
||||||
navigateToRename = {},
|
navigateToRename = {},
|
||||||
navigateToHearingProtection = {},
|
navigateToHearingProtection = {},
|
||||||
navigateToHearingAid = {},
|
navigateToHearingAid = {},
|
||||||
@@ -578,6 +576,7 @@ fun AirPodsSettingsScreenPreviewMaterial() {
|
|||||||
navigateToCallControlScreen = {},
|
navigateToCallControlScreen = {},
|
||||||
navigateToMicrophoneSettings = {},
|
navigateToMicrophoneSettings = {},
|
||||||
navigateToRecordingScreen = {},
|
navigateToRecordingScreen = {},
|
||||||
|
navigateToHeartRateScreen = {},
|
||||||
navigateToDebugScreen = {}
|
navigateToDebugScreen = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-47
@@ -1,19 +1,12 @@
|
|||||||
package me.kavishdevar.librepods.presentation.screens.apple
|
package me.kavishdevar.librepods.presentation.screens.apple
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBars
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
@@ -28,27 +21,23 @@ import me.kavishdevar.librepods.R
|
|||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
|
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledList
|
import me.kavishdevar.librepods.presentation.components.StyledList
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CallControlScreen(viewModel: AppleViewModel, action: String, onCallControlValueChanged: (Boolean) -> Unit) {
|
fun CallControlScreen(
|
||||||
|
viewModel: AppleViewModel,
|
||||||
|
action: String,
|
||||||
|
navigateBack: (() -> Unit)?,
|
||||||
|
onCallControlValueChanged: (Boolean) -> Unit
|
||||||
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
val state = uiState.state
|
val state = uiState.state
|
||||||
|
|
||||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
|
||||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
|
||||||
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
|
||||||
|
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
val bytes =
|
val bytes = state.controlStates[ControlCommandIdentifier.CALL_MANAGEMENT_CONFIG]?.take(2)?.toByteArray() ?: byteArrayOf(0x00, 0x00)
|
||||||
state.controlStates[ControlCommandIdentifier.CALL_MANAGEMENT_CONFIG]?.take(
|
|
||||||
2
|
|
||||||
)?.toByteArray() ?: byteArrayOf(0x00, 0x00)
|
|
||||||
val flipped = try {
|
val flipped = try {
|
||||||
bytes[1] == 0x02.toByte()
|
bytes[1] == 0x02.toByte()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -64,36 +53,40 @@ fun CallControlScreen(viewModel: AppleViewModel, action: String, onCallControlVa
|
|||||||
val pressOnceIsAction by remember { derivedStateOf { singlePressAction == pressOnceText } }
|
val pressOnceIsAction by remember { derivedStateOf { singlePressAction == pressOnceText } }
|
||||||
val flippedValue = action != muteUnmuteText
|
val flippedValue = action != muteUnmuteText
|
||||||
|
|
||||||
Column(
|
StyledScaffold(
|
||||||
modifier = Modifier
|
title = action,
|
||||||
.fillMaxSize()
|
navigateBack = navigateBack
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
) { topPadding, bottomPadding ->
|
||||||
.verticalScroll(scrollState)
|
Column(
|
||||||
.padding(top = 8.dp)
|
modifier = Modifier
|
||||||
.padding(horizontal = 16.dp)
|
.fillMaxSize()
|
||||||
) {
|
.verticalScroll(scrollState)
|
||||||
Spacer(modifier = Modifier.height(topPadding))
|
.padding(top = 8.dp)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
StyledList {
|
StyledList {
|
||||||
StyledListItem(
|
StyledListItem(
|
||||||
contentText = pressOnceText,
|
contentText = pressOnceText,
|
||||||
selected = pressOnceIsAction,
|
selected = pressOnceIsAction,
|
||||||
onClick = {
|
onClick = {
|
||||||
singlePressAction = pressOnceText
|
singlePressAction = pressOnceText
|
||||||
onCallControlValueChanged(flippedValue)
|
onCallControlValueChanged(flippedValue)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
StyledListItem(
|
StyledListItem(
|
||||||
contentText = pressTwiceText,
|
contentText = pressTwiceText,
|
||||||
selected = !pressOnceIsAction,
|
selected = !pressOnceIsAction,
|
||||||
onClick = {
|
onClick = {
|
||||||
singlePressAction = pressTwiceText
|
singlePressAction = pressTwiceText
|
||||||
onCallControlValueChanged(!flippedValue)
|
onCallControlValueChanged(!flippedValue)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(bottomPadding))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+259
-210
@@ -1,18 +1,12 @@
|
|||||||
package me.kavishdevar.librepods.presentation.screens.apple
|
package me.kavishdevar.librepods.presentation.screens.apple
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBars
|
|
||||||
import androidx.compose.foundation.text.input.TextFieldState
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
import androidx.compose.foundation.text.input.delete
|
import androidx.compose.foundation.text.input.delete
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -26,247 +20,302 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.packet.AACPPacketType
|
import me.kavishdevar.librepods.bluetooth.aacp.packet.AACPPacketType
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.packet.BatteryInfoPacket
|
import me.kavishdevar.librepods.bluetooth.aacp.packet.BatteryInfoPacket
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.packet.ControlCommandPacket
|
import me.kavishdevar.librepods.bluetooth.aacp.packet.ControlCommandPacket
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.packet.EarDetectionResponsePacket
|
import me.kavishdevar.librepods.bluetooth.aacp.packet.EarDetectionResponsePacket
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.packet.MagicKeyResponsePacket
|
import me.kavishdevar.librepods.bluetooth.aacp.packet.MagicKeyResponsePacket
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.packet.RTBuddyPacket
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.packet.RenamePacket
|
import me.kavishdevar.librepods.bluetooth.aacp.packet.RenamePacket
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto.SensorServiceType
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
|
import me.kavishdevar.librepods.bluetooth.aacp.types.MessageOpcode
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.types.RTBuddyDescriptor
|
||||||
|
import me.kavishdevar.librepods.bluetooth.aacp.types.SensorDataWxBuddyPayload
|
||||||
import me.kavishdevar.librepods.devices.DeviceComponent
|
import me.kavishdevar.librepods.devices.DeviceComponent
|
||||||
import me.kavishdevar.librepods.devices.PacketDestination
|
import me.kavishdevar.librepods.devices.PacketDestination
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
|
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledInputField
|
import me.kavishdevar.librepods.presentation.components.StyledInputField
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledList
|
import me.kavishdevar.librepods.presentation.components.StyledList
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.presentation.icons.richText
|
import me.kavishdevar.librepods.presentation.icons.richText
|
||||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
|
||||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState
|
import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
||||||
import me.kavishdevar.librepods.utils.nonScaledSp
|
import me.kavishdevar.librepods.utils.nonScaledSp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DebugRoute(viewModel: AppleViewModel) {
|
fun DebugRoute(
|
||||||
|
viewModel: AppleViewModel,
|
||||||
|
navigateBack: (() -> Unit)?
|
||||||
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
DebugScreen(
|
||||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
uiState = uiState,
|
||||||
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
navigateBack = navigateBack,
|
||||||
Box (
|
sendPacket = viewModel::sendRawPacket,
|
||||||
modifier = Modifier
|
)
|
||||||
.fillMaxSize()
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
|
||||||
) {
|
|
||||||
DebugScreen(
|
|
||||||
uiState = uiState,
|
|
||||||
topPadding = topPadding,
|
|
||||||
bottomPadding = bottomPadding,
|
|
||||||
sendPacket = viewModel::sendRawPacket
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DebugScreen(
|
fun DebugScreen(
|
||||||
uiState: AppleUiState,
|
uiState: AppleUiState,
|
||||||
topPadding: Dp = 16.dp,
|
navigateBack: (() -> Unit)?,
|
||||||
bottomPadding: Dp = 16.dp,
|
sendPacket: (ByteArray) -> Boolean,
|
||||||
sendPacket: (ByteArray) -> Boolean = { false }
|
|
||||||
) {
|
) {
|
||||||
val state = uiState.state
|
val state = uiState.state
|
||||||
|
|
||||||
Log.d("DebugScreen", "Screen ${state.aacpPackets.size}")
|
// Log.d("DebugScreen", "Screen ${state.aacpPackets.size}")
|
||||||
|
|
||||||
Column(
|
StyledScaffold(
|
||||||
modifier = Modifier
|
title = "debug",
|
||||||
.padding(horizontal = 16.dp)
|
navigateBack = navigateBack
|
||||||
.fillMaxSize(),
|
) { topPadding, bottomPadding ->
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
Column(
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.padding(top = topPadding))
|
|
||||||
|
|
||||||
val inputState = remember { TextFieldState() }
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
|
||||||
|
|
||||||
val success = remember { mutableStateOf(false) }
|
|
||||||
val firstPacketSent = remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
focusRequester.requestFocus()
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(inputState.text) {
|
|
||||||
val inputText = inputState.text
|
|
||||||
if (inputText.isNotEmpty()) {
|
|
||||||
val hexRegex = Regex("^[0-9A-Fa-f ]+$")
|
|
||||||
if (!hexRegex.matches(inputText)) {
|
|
||||||
inputState.edit {
|
|
||||||
delete(inputText.length - 1, inputText.length)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledInputField(
|
|
||||||
inputState = inputState,
|
|
||||||
focusRequester = focusRequester,
|
|
||||||
placeholder = "data (in hex)",
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledButton(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(),
|
.padding(horizontal = 16.dp)
|
||||||
onClick = {
|
.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.padding(top = topPadding))
|
||||||
|
|
||||||
|
val inputState = remember { TextFieldState() }
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
val success = remember { mutableStateOf(false) }
|
||||||
|
val firstPacketSent = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(inputState.text) {
|
||||||
val inputText = inputState.text
|
val inputText = inputState.text
|
||||||
if (inputText.isNotEmpty()) {
|
if (inputText.isNotEmpty()) {
|
||||||
firstPacketSent.value = true
|
val hexRegex = Regex("^[0-9A-Fa-f ]+$")
|
||||||
|
if (!hexRegex.matches(inputText)) {
|
||||||
val hexString = inputText.toString().replace(" ", "")
|
inputState.edit {
|
||||||
if (hexString.length % 2 != 0) {
|
delete(inputText.length - 1, inputText.length)
|
||||||
Log.d("DebugScreen", "Invalid hex string length: ${hexString.length}")
|
|
||||||
success.value = false
|
|
||||||
return@StyledButton
|
|
||||||
}
|
|
||||||
|
|
||||||
val byteArray = hexString.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
|
||||||
|
|
||||||
Log.d("DebugScreen", "Sending packet: ${byteArray.toHexString()}")
|
|
||||||
|
|
||||||
success.value = sendPacket(byteArray)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = inputState.text.isNotEmpty() && inputState.text.toString().replace(" ", "").length % 2 == 0 && inputState.text.matches(Regex("^[0-9A-Fa-f ]+$"))
|
|
||||||
) {
|
|
||||||
val text = richText("\\icon{Send,onPrimary} Send Packet")
|
|
||||||
Text(
|
|
||||||
text = text.text,
|
|
||||||
inlineContent = text.inlineContent,
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (firstPacketSent.value) {
|
|
||||||
Text(
|
|
||||||
text = if (success.value) "Sent" else "Failed",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = if (success.value) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledList(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f),
|
|
||||||
scrollEnabled = true,
|
|
||||||
title = "Packets ${state.aacpPackets.size}",
|
|
||||||
) {
|
|
||||||
state.aacpPackets.reversed().forEach { packet ->
|
|
||||||
StyledListItem(
|
|
||||||
content = {
|
|
||||||
val text = richText(
|
|
||||||
if (packet.type == AACPPacketType.MESSAGE) {
|
|
||||||
when (packet) {
|
|
||||||
is EarDetectionResponsePacket -> {
|
|
||||||
val left = packet.componentStates.find { it.component == DeviceComponent.LEFT }
|
|
||||||
val right = packet.componentStates.find { it.component == DeviceComponent.RIGHT }
|
|
||||||
val HEADSET = packet.componentStates.find { it.component == DeviceComponent.HEADSET }
|
|
||||||
|
|
||||||
if (left != null && right != null) {
|
|
||||||
"\\icon{LeftCircleFill} ${left.status.name} " +
|
|
||||||
"\\icon{RightCircleFill} ${right.status.name}"
|
|
||||||
} else {
|
|
||||||
HEADSET?.status?.name ?: "Unknown"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is ControlCommandPacket -> {
|
|
||||||
val controlCommand = packet.controlCommand
|
|
||||||
"${controlCommand.identifier.name} - ${controlCommand.value.toHexString()}"
|
|
||||||
}
|
|
||||||
|
|
||||||
is BatteryInfoPacket -> {
|
|
||||||
"Battery Info"
|
|
||||||
}
|
|
||||||
|
|
||||||
is RenamePacket -> {
|
|
||||||
"Rename to ${packet.name}"
|
|
||||||
}
|
|
||||||
|
|
||||||
is MagicKeyResponsePacket -> {
|
|
||||||
"Magic Keys (${packet.magicKeys.size})"
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> packet.opcode.toString()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
packet.type.toString()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = text.text,
|
|
||||||
inlineContent = text.inlineContent,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
},
|
|
||||||
supportingContent = {
|
|
||||||
val text = richText(
|
|
||||||
when (packet) {
|
|
||||||
is BatteryInfoPacket -> buildString {
|
|
||||||
for (battery in packet.batteries) {
|
|
||||||
append("${battery.component} ${battery.status} ${battery.level}\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is MagicKeyResponsePacket -> buildString {
|
|
||||||
packet.magicKeys.entries.forEach { (key, value) ->
|
|
||||||
append("${key.name}: ${value.toHexString()}\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> packet.payload.toHexString()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = text.text,
|
|
||||||
inlineContent = text.inlineContent,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onBackground.copy(0.8f)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
leadingContent = {
|
|
||||||
val text = richText(
|
|
||||||
when (packet.destination) {
|
|
||||||
PacketDestination.HOST -> "\\icon{Incoming,primary}"
|
|
||||||
PacketDestination.DEVICE -> "\\icon{Outgoing,tertiary}"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = text.text,
|
|
||||||
inlineContent = text.inlineContent,
|
|
||||||
style = MaterialTheme.typography.labelMedium.copy(fontSize = 28.nonScaledSp()),
|
|
||||||
color = MaterialTheme.colorScheme.onBackground
|
|
||||||
)
|
|
||||||
},
|
|
||||||
orientation = StyledListItemOrientation.Vertical,
|
|
||||||
onClick = if (packet.destination == PacketDestination.DEVICE || packet.opcode == MessageOpcode.CONTROL_COMMAND) {
|
|
||||||
{
|
|
||||||
inputState.edit {
|
|
||||||
delete(0, inputState.text.length)
|
|
||||||
append(packet.rawPacket.toHexString())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else null
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledInputField(
|
||||||
|
inputState = inputState,
|
||||||
|
focusRequester = focusRequester,
|
||||||
|
placeholder = "data (in hex)",
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledButton(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(),
|
||||||
|
onClick = {
|
||||||
|
val inputText = inputState.text
|
||||||
|
if (inputText.isNotEmpty()) {
|
||||||
|
firstPacketSent.value = true
|
||||||
|
|
||||||
|
val hexString = inputText.toString().replace(" ", "")
|
||||||
|
if (hexString.length % 2 != 0) {
|
||||||
|
Log.d("DebugScreen", "Invalid hex string length: ${hexString.length}")
|
||||||
|
success.value = false
|
||||||
|
return@StyledButton
|
||||||
|
}
|
||||||
|
|
||||||
|
val byteArray = hexString.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||||
|
|
||||||
|
Log.d("DebugScreen", "Sending packet: ${byteArray.toHexString()}")
|
||||||
|
|
||||||
|
success.value = sendPacket(byteArray)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = inputState.text.isNotEmpty() && inputState.text.toString().replace(" ", "").length % 2 == 0 && inputState.text.matches(Regex("^[0-9A-Fa-f ]+$"))
|
||||||
|
) {
|
||||||
|
val text = richText("\\icon{Send,onPrimary} Send Packet")
|
||||||
|
Text(
|
||||||
|
text = text.text,
|
||||||
|
inlineContent = text.inlineContent,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.padding(top = bottomPadding))
|
if (firstPacketSent.value) {
|
||||||
|
Text(
|
||||||
|
text = if (success.value) "Sent" else "Failed",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = if (success.value) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledList(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f),
|
||||||
|
scrollEnabled = true,
|
||||||
|
title = "Packets ${state.aacpPackets.size}",
|
||||||
|
) {
|
||||||
|
state.aacpPackets.reversed().forEach { packet ->
|
||||||
|
StyledListItem(
|
||||||
|
content = {
|
||||||
|
val text = richText(
|
||||||
|
if (packet.type == AACPPacketType.MESSAGE) {
|
||||||
|
when (packet) {
|
||||||
|
is EarDetectionResponsePacket -> {
|
||||||
|
val left = packet.componentStates.find { it.component == DeviceComponent.LEFT }
|
||||||
|
val right = packet.componentStates.find { it.component == DeviceComponent.RIGHT }
|
||||||
|
val HEADSET = packet.componentStates.find { it.component == DeviceComponent.HEADSET }
|
||||||
|
|
||||||
|
if (left != null && right != null) {
|
||||||
|
"\\icon{LeftCircleFill} ${left.status.name} " +
|
||||||
|
"\\icon{RightCircleFill} ${right.status.name}"
|
||||||
|
} else {
|
||||||
|
HEADSET?.status?.name ?: "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is ControlCommandPacket -> {
|
||||||
|
val controlCommand = packet.controlCommand
|
||||||
|
"${controlCommand.identifier.name} - ${controlCommand.value.toHexString()}"
|
||||||
|
}
|
||||||
|
|
||||||
|
is BatteryInfoPacket -> {
|
||||||
|
"Battery Info"
|
||||||
|
}
|
||||||
|
|
||||||
|
is RenamePacket -> {
|
||||||
|
"Rename to ${packet.name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
is MagicKeyResponsePacket -> {
|
||||||
|
"Magic Keys (${packet.magicKeys.size})"
|
||||||
|
}
|
||||||
|
|
||||||
|
is RTBuddyPacket -> {
|
||||||
|
val rtBuddyPayload = packet.rtBuddyPayload
|
||||||
|
when (rtBuddyPayload.descriptor) {
|
||||||
|
RTBuddyDescriptor.SENSOR_DATA_WX -> {
|
||||||
|
val sensorDataWxBuddyPayload =
|
||||||
|
rtBuddyPayload as SensorDataWxBuddyPayload
|
||||||
|
val data = sensorDataWxBuddyPayload.data
|
||||||
|
|
||||||
|
if (data.hasCommand()) {
|
||||||
|
when (data.command.service) {
|
||||||
|
SensorServiceType.ACTIVITY, SensorServiceType.DEVMOTION6 -> {
|
||||||
|
val payload = data.command.payload.toByteArray()
|
||||||
|
|
||||||
|
fun i16(offset: Int): Int =
|
||||||
|
(payload[offset].toInt() and 0xFF) or
|
||||||
|
((payload[offset + 1].toInt() and 0xFF) shl 8)
|
||||||
|
.let { value ->
|
||||||
|
if (value and 0x8000 != 0) value - 0x10000 else value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun vec3(offset: Int): String =
|
||||||
|
"(${i16(offset)}, ${i16(offset + 2)}, ${i16(offset + 4)})"
|
||||||
|
|
||||||
|
"""
|
||||||
|
DEVMOTION
|
||||||
|
v0: ${vec3(20)}
|
||||||
|
v1: ${vec3(26)}
|
||||||
|
v2: ${vec3(32)}
|
||||||
|
v3: ${vec3(38)}
|
||||||
|
v4: ${vec3(44)}
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
|
||||||
|
SensorServiceType.HEARTRATEv2 -> {
|
||||||
|
val payload = data.command.payload.toByteArray()
|
||||||
|
|
||||||
|
if (payload.size >= 11) {
|
||||||
|
val heartRate = payload[1].toInt() and 0xFF
|
||||||
|
|
||||||
|
"Possible HR: $heartRate, payload=${payload.toHexString()}"
|
||||||
|
} else "???"
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
"Unhandled sensor command service: ${data.command.service}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else "?????"
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
"Unhandled descriptor: ${rtBuddyPayload.descriptor}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> packet.opcode.toString()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
packet.type.toString()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = text.text,
|
||||||
|
inlineContent = text.inlineContent,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
val text = richText(
|
||||||
|
when (packet) {
|
||||||
|
is BatteryInfoPacket -> buildString {
|
||||||
|
for (battery in packet.batteries) {
|
||||||
|
append("${battery.component} ${battery.status} ${battery.level}\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is MagicKeyResponsePacket -> buildString {
|
||||||
|
packet.magicKeys.entries.forEach { (key, value) ->
|
||||||
|
append("${key.name}: ${value.toHexString()}\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> packet.payload.toHexString()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = text.text,
|
||||||
|
inlineContent = text.inlineContent,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground.copy(0.8f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
val text = richText(
|
||||||
|
when (packet.destination) {
|
||||||
|
PacketDestination.HOST -> "\\icon{Incoming,primary}"
|
||||||
|
PacketDestination.DEVICE -> "\\icon{Outgoing,tertiary}"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = text.text,
|
||||||
|
inlineContent = text.inlineContent,
|
||||||
|
style = MaterialTheme.typography.labelMedium.copy(fontSize = 28.nonScaledSp()),
|
||||||
|
color = MaterialTheme.colorScheme.onBackground
|
||||||
|
)
|
||||||
|
},
|
||||||
|
orientation = StyledListItemOrientation.Vertical,
|
||||||
|
onClick = if (packet.destination == PacketDestination.DEVICE || packet.opcode == MessageOpcode.CONTROL_COMMAND) {
|
||||||
|
{
|
||||||
|
inputState.edit {
|
||||||
|
delete(0, inputState.text.length)
|
||||||
|
append(packet.rawPacket.toHexString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.padding(top = bottomPadding))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+119
-136
@@ -30,20 +30,15 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.statusBars
|
|
||||||
import androidx.compose.foundation.layout.visible
|
import androidx.compose.foundation.layout.visible
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -72,7 +67,6 @@ import androidx.compose.ui.platform.LocalDensity
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.util.lerp
|
import androidx.compose.ui.util.lerp
|
||||||
@@ -86,9 +80,9 @@ import me.kavishdevar.librepods.R
|
|||||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledList
|
import me.kavishdevar.librepods.presentation.components.StyledList
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||||
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState
|
import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
@@ -96,32 +90,23 @@ import kotlin.math.roundToInt
|
|||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun EqualizerRoute(viewModel: AppleViewModel) {
|
fun EqualizerRoute(
|
||||||
|
viewModel: AppleViewModel,
|
||||||
|
navigateBack: (() -> Unit)?
|
||||||
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
EqualizerScreen(
|
||||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
uiState = uiState,
|
||||||
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
navigateBack = navigateBack,
|
||||||
|
setCustomEqEnabled = viewModel::setCustomEqEnabled,
|
||||||
Box (
|
setCustomEq = viewModel::setCustomEq
|
||||||
modifier = Modifier
|
)
|
||||||
.fillMaxSize()
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
|
||||||
) {
|
|
||||||
EqualizerScreen(
|
|
||||||
uiState = uiState,
|
|
||||||
topPadding = topPadding,
|
|
||||||
bottomPadding = bottomPadding,
|
|
||||||
setCustomEqEnabled = viewModel::setCustomEqEnabled,
|
|
||||||
setCustomEq = viewModel::setCustomEq
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@Composable
|
@Composable
|
||||||
fun EqualizerScreen(
|
fun EqualizerScreen(
|
||||||
uiState: AppleUiState,
|
uiState: AppleUiState,
|
||||||
topPadding: Dp = 16.dp,
|
navigateBack: (() -> Unit)?,
|
||||||
bottomPadding: Dp = 16.dp,
|
|
||||||
setCustomEqEnabled: (Boolean) -> Unit,
|
setCustomEqEnabled: (Boolean) -> Unit,
|
||||||
setCustomEq: (Int, Int, Int) -> Unit
|
setCustomEq: (Int, Int, Int) -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -131,102 +116,107 @@ fun EqualizerScreen(
|
|||||||
|
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
Column(
|
StyledScaffold(
|
||||||
modifier = Modifier
|
title = stringResource(R.string.equalizer),
|
||||||
.padding(horizontal = 16.dp)
|
navigateBack = navigateBack
|
||||||
.verticalScroll(scrollState),
|
) { topPadding, bottomPadding ->
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
Column(
|
||||||
) {
|
modifier = Modifier
|
||||||
val height = 200.dp
|
.padding(horizontal = 16.dp)
|
||||||
val maxOffset = with(LocalDensity.current) { height.toPx() } / 2
|
.verticalScroll(scrollState),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
val height = 200.dp
|
||||||
|
val maxOffset = with(LocalDensity.current) { height.toPx() } / 2
|
||||||
|
|
||||||
val offsets = remember(state.customEq) {
|
val offsets = remember(state.customEq) {
|
||||||
listOf(
|
listOf(
|
||||||
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.low.toFloat() / 100)),
|
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.low.toFloat() / 100)),
|
||||||
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.mid.toFloat() / 100)),
|
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.mid.toFloat() / 100)),
|
||||||
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.high.toFloat() / 100))
|
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.high.toFloat() / 100))
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(offsets) {
|
|
||||||
snapshotFlow {
|
|
||||||
Triple(
|
|
||||||
offsets[0].floatValue,
|
|
||||||
offsets[1].floatValue,
|
|
||||||
offsets[2].floatValue
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.debounce(100.milliseconds) // nice, should've been using this since the very beginning
|
|
||||||
.collect { (lowF, midF, highF) ->
|
|
||||||
val low =
|
|
||||||
100 - ((lowF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
|
|
||||||
val mid =
|
|
||||||
100 - ((midF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
|
|
||||||
val high =
|
|
||||||
100 - ((highF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
|
|
||||||
|
|
||||||
setCustomEq(low, mid, high)
|
LaunchedEffect(offsets) {
|
||||||
}
|
snapshotFlow {
|
||||||
}
|
Triple(
|
||||||
|
offsets[0].floatValue,
|
||||||
Spacer(modifier = Modifier.height(topPadding))
|
offsets[1].floatValue,
|
||||||
|
offsets[2].floatValue
|
||||||
val enabled = customEq.isEnabled()
|
|
||||||
|
|
||||||
StyledList {
|
|
||||||
StyledListItem(
|
|
||||||
contentText = stringResource(R.string.recommended),
|
|
||||||
selected = !enabled,
|
|
||||||
onClick = { setCustomEqEnabled(false) }
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledListItem(
|
|
||||||
contentText = stringResource(R.string.custom),
|
|
||||||
selected = enabled,
|
|
||||||
onClick = { setCustomEqEnabled(true) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
Crossfade (
|
|
||||||
customEq.isEnabled()
|
|
||||||
) { visible ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.visible(visible),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
|
|
||||||
EqualizerCard(
|
|
||||||
lowOffset = offsets[0],
|
|
||||||
midOffset = offsets[1],
|
|
||||||
highOffset = offsets[2]
|
|
||||||
)
|
|
||||||
|
|
||||||
val resetButtonEnabled = remember { derivedStateOf { !offsets.all { it.floatValue == 0f } } }
|
|
||||||
|
|
||||||
StyledButton(
|
|
||||||
onClick = {
|
|
||||||
offsets[0].floatValue = 0f
|
|
||||||
offsets[1].floatValue = 0f
|
|
||||||
offsets[2].floatValue = 0f
|
|
||||||
},
|
|
||||||
backdrop = rememberLayerBackdrop(),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
isInteractive = false,
|
|
||||||
enabled = resetButtonEnabled.value
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.reset),
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
.debounce(100.milliseconds) // nice, should've been using this since the very beginning
|
||||||
}
|
.collect { (lowF, midF, highF) ->
|
||||||
|
val low =
|
||||||
|
100 - ((lowF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
|
||||||
|
val mid =
|
||||||
|
100 - ((midF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
|
||||||
|
val high =
|
||||||
|
100 - ((highF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(bottomPadding))
|
setCustomEq(low, mid, high)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
|
val enabled = customEq.isEnabled()
|
||||||
|
|
||||||
|
StyledList {
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.recommended),
|
||||||
|
selected = !enabled,
|
||||||
|
onClick = { setCustomEqEnabled(false) }
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.custom),
|
||||||
|
selected = enabled,
|
||||||
|
onClick = { setCustomEqEnabled(true) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Crossfade (
|
||||||
|
customEq.isEnabled()
|
||||||
|
) { visible ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.visible(visible),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
|
||||||
|
EqualizerCard(
|
||||||
|
lowOffset = offsets[0],
|
||||||
|
midOffset = offsets[1],
|
||||||
|
highOffset = offsets[2]
|
||||||
|
)
|
||||||
|
|
||||||
|
val resetButtonEnabled = remember { derivedStateOf { !offsets.all { it.floatValue == 0f } } }
|
||||||
|
|
||||||
|
StyledButton(
|
||||||
|
onClick = {
|
||||||
|
offsets[0].floatValue = 0f
|
||||||
|
offsets[1].floatValue = 0f
|
||||||
|
offsets[2].floatValue = 0f
|
||||||
|
},
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
isInteractive = false,
|
||||||
|
enabled = resetButtonEnabled.value
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.reset),
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,15 +688,12 @@ fun EqualizerScreenPreviewApple() {
|
|||||||
) {
|
) {
|
||||||
val state = remember { mutableStateOf(AppleUiState()) }
|
val state = remember { mutableStateOf(AppleUiState()) }
|
||||||
|
|
||||||
Box (
|
EqualizerScreen(
|
||||||
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
|
uiState = state.value,
|
||||||
) {
|
navigateBack = null,
|
||||||
EqualizerScreen(
|
setCustomEqEnabled = { state.value = state.value.copy(state = state.value.state.copy(customEq = state.value.state.customEq.copy(state = if (it) 2 else 1))) },
|
||||||
uiState = state.value,
|
setCustomEq = {low, mid, high -> state.value = state.value.copy(state = state.value.state.copy(customEq = state.value.state.customEq.copy(low = low, mid = mid, high = high)))}
|
||||||
setCustomEqEnabled = { state.value = state.value.copy(state = state.value.state.copy(customEq = state.value.state.customEq.copy(state = if (it) 2 else 1))) },
|
)
|
||||||
setCustomEq = {low, mid, high -> state.value = state.value.copy(state = state.value.state.copy(customEq = state.value.state.customEq.copy(low = low, mid = mid, high = high)))}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -717,16 +704,12 @@ fun EqualizerScreenPreviewMaterial() {
|
|||||||
designSystem = DesignSystem.Material
|
designSystem = DesignSystem.Material
|
||||||
) {
|
) {
|
||||||
val state = remember { mutableStateOf(AppleUiState()) }
|
val state = remember { mutableStateOf(AppleUiState()) }
|
||||||
Box (
|
|
||||||
modifier = Modifier
|
EqualizerScreen(
|
||||||
.wrapContentHeight()
|
uiState = state.value,
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
navigateBack = null,
|
||||||
) {
|
setCustomEqEnabled = { state.value = state.value.copy(state = state.value.state.copy(customEq = state.value.state.customEq.copy(state = if (it) 2 else 1))) },
|
||||||
EqualizerScreen(
|
setCustomEq = {low, mid, high -> state.value = state.value.copy(state = state.value.state.copy(customEq = state.value.state.customEq.copy(low = low, mid = mid, high = high)))}
|
||||||
uiState = state.value,
|
)
|
||||||
setCustomEqEnabled = { state.value = state.value.copy(state = state.value.state.copy(customEq = state.value.state.customEq.copy(state = if (it) 2 else 1))) },
|
|
||||||
setCustomEq = {low, mid, high -> state.value = state.value.copy(state = state.value.state.copy(customEq = state.value.state.customEq.copy(low = low, mid = mid, high = high)))}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+202
-133
@@ -32,26 +32,28 @@ import androidx.compose.animation.fadeOut
|
|||||||
import androidx.compose.animation.slideInVertically
|
import androidx.compose.animation.slideInVertically
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBars
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalIconToggleButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -64,6 +66,7 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
@@ -78,14 +81,17 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
|||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.filterIsInstance
|
import kotlinx.coroutines.flow.debounce
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.AppleEvent
|
|
||||||
import me.kavishdevar.librepods.presentation.components.MaterialButtonStyle
|
import me.kavishdevar.librepods.presentation.components.MaterialButtonStyle
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledIconButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
|
import me.kavishdevar.librepods.presentation.icons.LocalIcons
|
||||||
|
import me.kavishdevar.librepods.presentation.icons.MaterialIcons
|
||||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
||||||
@@ -98,7 +104,11 @@ import kotlin.time.Duration.Companion.seconds
|
|||||||
@ExperimentalHazeMaterialsApi
|
@ExperimentalHazeMaterialsApi
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun HeadTrackingScreen(viewModel: AppleViewModel, navigateToPurchase: () -> Unit) {
|
fun HeadTrackingScreen(
|
||||||
|
viewModel: AppleViewModel,
|
||||||
|
navigateBack: (() -> Unit)?,
|
||||||
|
navigateToPurchase: () -> Unit
|
||||||
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
val settings = uiState.settings
|
val settings = uiState.settings
|
||||||
@@ -112,10 +122,6 @@ fun HeadTrackingScreen(viewModel: AppleViewModel, navigateToPurchase: () -> Unit
|
|||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
|
||||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
|
||||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
|
||||||
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
|
||||||
|
|
||||||
var gestureText by remember { mutableStateOf("") }
|
var gestureText by remember { mutableStateOf("") }
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
@@ -124,137 +130,200 @@ fun HeadTrackingScreen(viewModel: AppleViewModel, navigateToPurchase: () -> Unit
|
|||||||
|
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
Column(
|
StyledScaffold(
|
||||||
modifier = Modifier
|
title = stringResource(R.string.head_gestures),
|
||||||
.fillMaxSize()
|
navigateBack = navigateBack,
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
actionButtons = listOf(
|
||||||
.verticalScroll(scrollState),
|
{ scaffoldBackdrop ->
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
if (LocalDesignSystem.current == DesignSystem.Material) {
|
||||||
) {
|
FilledTonalIconToggleButton(
|
||||||
Spacer(modifier = Modifier.height(topPadding))
|
checked = uiState.state.headTrackingActive,
|
||||||
|
onCheckedChange = { if (it) viewModel.startHeadTracking() else viewModel.stopHeadTracking() },
|
||||||
Column (
|
modifier = Modifier
|
||||||
modifier = Modifier
|
.minimumInteractiveComponentSize()
|
||||||
.fillMaxWidth()
|
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
|
||||||
.layerBackdrop(backdrop)
|
shape = IconButtonDefaults.mediumRoundShape
|
||||||
.padding(top = 8.dp)
|
) {
|
||||||
.padding(horizontal = 16.dp)
|
Icon(
|
||||||
) {
|
imageVector = if (uiState.state.headTrackingActive) MaterialIcons.Pause else Icons.Default.PlayArrow,
|
||||||
|
contentDescription = "Play/Pause",
|
||||||
if (!uiState.isPremium) {
|
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
|
||||||
StyledButton(
|
)
|
||||||
onClick = navigateToPurchase,
|
}
|
||||||
backdrop = rememberLayerBackdrop(),
|
} else {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
StyledIconButton(
|
||||||
maxScale = 0.05f,
|
onClick = if (!uiState.state.headTrackingActive) viewModel::startHeadTracking else viewModel::stopHeadTracking,
|
||||||
surfaceColor = MaterialTheme.colorScheme.primary
|
backdrop = scaffoldBackdrop
|
||||||
) {
|
) {
|
||||||
Text(
|
Icon(
|
||||||
stringResource(R.string.unlock_advanced_features),
|
imageVector = if (uiState.state.headTrackingActive) LocalIcons.current.Pause else LocalIcons.current.Play,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
contentDescription = "Play/Pause",
|
||||||
color = MaterialTheme.colorScheme.onPrimary
|
modifier = Modifier.size(16.dp),
|
||||||
)
|
tint = MaterialTheme.colorScheme.onBackground
|
||||||
}
|
)
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledToggle(
|
|
||||||
label = "Head Gestures",
|
|
||||||
checked = settings.headGesturesEnabled,
|
|
||||||
onCheckedChange = { viewModel.setHeadGesturesEnabled(it) },
|
|
||||||
enabled = uiState.isPremium || settings.headGesturesEnabled,
|
|
||||||
description = stringResource(R.string.head_gestures_details),
|
|
||||||
header = true
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
Text(
|
|
||||||
"Velocity",
|
|
||||||
style = MaterialTheme.typography.labelSmallEmphasized,
|
|
||||||
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
|
|
||||||
)
|
|
||||||
Plot()
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
LaunchedEffect(gestureText) {
|
|
||||||
if (gestureText.isNotEmpty()) {
|
|
||||||
lastClickTime = System.currentTimeMillis()
|
|
||||||
delay(3.seconds)
|
|
||||||
if (System.currentTimeMillis() - lastClickTime >= 3000) {
|
|
||||||
shouldExplode = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
val gestureTextValue = stringResource(R.string.shake_your_head_or_nod)
|
) { topPadding, bottomPadding ->
|
||||||
StyledButton(
|
Column(
|
||||||
onClick = {
|
|
||||||
gestureText = gestureTextValue
|
|
||||||
coroutineScope.launch {
|
|
||||||
viewModel.testHeadGestures()
|
|
||||||
val accepted = viewModel.events
|
|
||||||
.filterIsInstance<AppleEvent.HeadGesturesResult>()
|
|
||||||
.first()
|
|
||||||
.yes
|
|
||||||
gestureText = if (accepted) "\"Yes\" gesture detected." else "\"No\" gesture detected."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
backdrop = backdrop,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxSize()
|
||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp)
|
||||||
maxScale = 0.05f,
|
.verticalScroll(scrollState),
|
||||||
materialButtonStyle = MaterialButtonStyle.Outlined
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Text(
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
"Test Head Gestures",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
Column (
|
||||||
color = MaterialTheme.colorScheme.primary
|
modifier = Modifier
|
||||||
)
|
.fillMaxWidth()
|
||||||
}
|
.layerBackdrop(backdrop)
|
||||||
Box(
|
.padding(top = 8.dp)
|
||||||
contentAlignment = Alignment.Center,
|
) {
|
||||||
modifier = Modifier.padding(top = 12.dp, bottom = 24.dp)
|
|
||||||
) {
|
if (!uiState.isPremium) {
|
||||||
AnimatedContent(
|
StyledButton(
|
||||||
targetState = gestureText,
|
onClick = navigateToPurchase,
|
||||||
transitionSpec = {
|
backdrop = rememberLayerBackdrop(),
|
||||||
(fadeIn(
|
modifier = Modifier.fillMaxWidth(),
|
||||||
animationSpec = tween(300)
|
maxScale = 0.05f,
|
||||||
) + slideInVertically(
|
surfaceColor = MaterialTheme.colorScheme.primary
|
||||||
initialOffsetY = { 40 },
|
) {
|
||||||
animationSpec = tween(300)
|
Text(
|
||||||
)).togetherWith(fadeOut(animationSpec = tween(150)))
|
stringResource(R.string.unlock_advanced_features),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
) { text ->
|
|
||||||
if (shouldExplode) {
|
StyledToggle(
|
||||||
LaunchedEffect(Unit) {
|
label = "Head Gestures",
|
||||||
CoroutineScope(coroutineScope.coroutineContext).launch {
|
checked = settings.headGesturesEnabled,
|
||||||
delay(750.milliseconds)
|
onCheckedChange = { viewModel.setHeadGesturesEnabled(it) },
|
||||||
gestureText = ""
|
enabled = uiState.isPremium || settings.headGesturesEnabled,
|
||||||
|
description = stringResource(R.string.head_gestures_details),
|
||||||
|
header = true
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
"Velocity",
|
||||||
|
style = MaterialTheme.typography.labelSmallEmphasized,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Plot()
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
LaunchedEffect(gestureText) {
|
||||||
|
if (gestureText.isNotEmpty()) {
|
||||||
|
lastClickTime = System.currentTimeMillis()
|
||||||
|
delay(3.seconds)
|
||||||
|
if (System.currentTimeMillis() - lastClickTime >= 3000) {
|
||||||
|
shouldExplode = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onBackground,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val gestureTextValue = stringResource(R.string.shake_your_head_or_nod)
|
||||||
|
StyledButton(
|
||||||
|
onClick = {
|
||||||
|
gestureText = gestureTextValue
|
||||||
|
coroutineScope.launch {
|
||||||
|
viewModel.detectHeadGestures { gestureText = if (it) "\"Yes\" gesture detected." else "\"No\" gesture detected." }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
backdrop = backdrop,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(),
|
||||||
|
maxScale = 0.05f,
|
||||||
|
materialButtonStyle = MaterialButtonStyle.Outlined
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Test Head Gestures",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.padding(top = 12.dp, bottom = 24.dp)
|
||||||
|
) {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = gestureText,
|
||||||
|
transitionSpec = {
|
||||||
|
(fadeIn(
|
||||||
|
animationSpec = tween(300)
|
||||||
|
) + slideInVertically(
|
||||||
|
initialOffsetY = { 40 },
|
||||||
|
animationSpec = tween(300)
|
||||||
|
)).togetherWith(fadeOut(animationSpec = tween(150)))
|
||||||
|
}
|
||||||
|
) { text ->
|
||||||
|
if (shouldExplode) {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
CoroutineScope(coroutineScope.coroutineContext).launch {
|
||||||
|
delay(750.milliseconds)
|
||||||
|
gestureText = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.appSettings.debugMode) {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
StyledToggle(
|
||||||
|
label = "[debug] alternate horizontal byte offset",
|
||||||
|
checked = settings.headGesturesHorizontalOffset == 26,
|
||||||
|
onCheckedChange = { viewModel.setHeadGesturesHorizontalOffset(if (it) 26 else 28) }
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
val sliderValue = remember {
|
||||||
|
mutableFloatStateOf(settings.headTrackingInterval.inWholeMilliseconds.toFloat())
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(sliderValue) {
|
||||||
|
snapshotFlow { sliderValue.floatValue }
|
||||||
|
.debounce(100.milliseconds)
|
||||||
|
.collect { value ->
|
||||||
|
viewModel.setHeadTrackingInterval(value.toInt().milliseconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledSlider(
|
||||||
|
label = "[debug] head tracking interval",
|
||||||
|
value = sliderValue.floatValue,
|
||||||
|
onValueChange = { sliderValue.floatValue = it },
|
||||||
|
valueRange = 10f..200f,
|
||||||
|
snapPoints = listOf(40f),
|
||||||
|
independent = true,
|
||||||
|
description = "how often airpods report sensor information",
|
||||||
|
enabled = uiState.isPremium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(bottomPadding))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+77
-78
@@ -23,13 +23,9 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBars
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -52,17 +48,19 @@ import me.kavishdevar.librepods.bluetooth.att.ATTHandle
|
|||||||
import me.kavishdevar.librepods.bluetooth.att.types.HearingAidSettings
|
import me.kavishdevar.librepods.bluetooth.att.types.HearingAidSettings
|
||||||
import me.kavishdevar.librepods.bluetooth.att.types.parseHearingAidSettingsResponse
|
import me.kavishdevar.librepods.bluetooth.att.types.parseHearingAidSettingsResponse
|
||||||
import me.kavishdevar.librepods.bluetooth.att.types.sendHearingAidSettings
|
import me.kavishdevar.librepods.bluetooth.att.types.sendHearingAidSettings
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
import me.kavishdevar.librepods.presentation.icons.LocalIcons
|
import me.kavishdevar.librepods.presentation.icons.LocalIcons
|
||||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
|
||||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
||||||
|
|
||||||
private const val TAG = "HearingAidAdjustments"
|
private const val TAG = "HearingAidAdjustments"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HearingAidAdjustmentsScreen(viewModel: AppleViewModel) {
|
fun HearingAidAdjustmentsScreen(
|
||||||
|
viewModel: AppleViewModel,
|
||||||
|
navigateBack: (() -> Unit)?
|
||||||
|
) {
|
||||||
val verticalScrollState = rememberScrollState()
|
val verticalScrollState = rememberScrollState()
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
@@ -149,83 +147,84 @@ fun HearingAidAdjustmentsScreen(viewModel: AppleViewModel) {
|
|||||||
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::writeATTCharacteristic)
|
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::writeATTCharacteristic)
|
||||||
}
|
}
|
||||||
|
|
||||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
StyledScaffold(
|
||||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
title = stringResource(R.string.adjustments),
|
||||||
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
navigateBack = navigateBack
|
||||||
|
) { topPadding, bottomPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
.verticalScroll(verticalScrollState)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
Column(
|
StyledSlider(
|
||||||
modifier = Modifier
|
label = stringResource(R.string.amplification),
|
||||||
.fillMaxSize()
|
valueRange = -1f..1f,
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
value = amplificationSliderValue.floatValue,
|
||||||
.verticalScroll(verticalScrollState)
|
onValueChange = {
|
||||||
.padding(horizontal = 16.dp),
|
amplificationSliderValue.floatValue = it
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
},
|
||||||
) {
|
startImageVector = LocalIcons.current.SpeakerMin,
|
||||||
Spacer(modifier = Modifier.height(topPadding))
|
endImageVector = LocalIcons.current.SpeakerMax,
|
||||||
|
independent = true,
|
||||||
|
)
|
||||||
|
|
||||||
StyledSlider(
|
StyledToggle(
|
||||||
label = stringResource(R.string.amplification),
|
label = stringResource(R.string.swipe_to_control_amplification),
|
||||||
valueRange = -1f..1f,
|
checked = state.controlStates[ControlCommandIdentifier.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),
|
||||||
value = amplificationSliderValue.floatValue,
|
onCheckedChange = { viewModel.setControlCommand(ControlCommandIdentifier.HPS_GAIN_SWIPE, it) },
|
||||||
onValueChange = {
|
description = stringResource(R.string.swipe_amplification_description)
|
||||||
amplificationSliderValue.floatValue = it
|
)
|
||||||
},
|
|
||||||
startImageVector = LocalIcons.current.SpeakerMin,
|
|
||||||
endImageVector = LocalIcons.current.SpeakerMax,
|
|
||||||
independent = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledToggle(
|
StyledSlider(
|
||||||
label = stringResource(R.string.swipe_to_control_amplification),
|
label = stringResource(R.string.balance),
|
||||||
checked = state.controlStates[ControlCommandIdentifier.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),
|
valueRange = -1f..1f,
|
||||||
onCheckedChange = { viewModel.setControlCommand(ControlCommandIdentifier.HPS_GAIN_SWIPE, it) },
|
value = balanceSliderValue.floatValue,
|
||||||
description = stringResource(R.string.swipe_amplification_description)
|
onValueChange = {
|
||||||
)
|
balanceSliderValue.floatValue = it
|
||||||
|
},
|
||||||
|
snapPoints = listOf(-1f, 0f, 1f),
|
||||||
|
startLabel = stringResource(R.string.left),
|
||||||
|
endLabel = stringResource(R.string.right),
|
||||||
|
independent = true,
|
||||||
|
)
|
||||||
|
|
||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.balance),
|
label = stringResource(R.string.tone),
|
||||||
valueRange = -1f..1f,
|
valueRange = -1f..1f,
|
||||||
value = balanceSliderValue.floatValue,
|
value = toneSliderValue.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
balanceSliderValue.floatValue = it
|
toneSliderValue.floatValue = it
|
||||||
},
|
},
|
||||||
snapPoints = listOf(-1f, 0f, 1f),
|
startLabel = stringResource(R.string.darker),
|
||||||
startLabel = stringResource(R.string.left),
|
endLabel = stringResource(R.string.brighter),
|
||||||
endLabel = stringResource(R.string.right),
|
independent = true,
|
||||||
independent = true,
|
)
|
||||||
)
|
|
||||||
|
|
||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.tone),
|
label = stringResource(R.string.ambient_noise_reduction),
|
||||||
valueRange = -1f..1f,
|
valueRange = 0f..1f,
|
||||||
value = toneSliderValue.floatValue,
|
value = ambientNoiseReductionSliderValue.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
toneSliderValue.floatValue = it
|
ambientNoiseReductionSliderValue.floatValue = it
|
||||||
},
|
},
|
||||||
startLabel = stringResource(R.string.darker),
|
startLabel = stringResource(R.string.less),
|
||||||
endLabel = stringResource(R.string.brighter),
|
endLabel = stringResource(R.string.more),
|
||||||
independent = true,
|
independent = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
StyledSlider(
|
StyledToggle(
|
||||||
label = stringResource(R.string.ambient_noise_reduction),
|
label = stringResource(R.string.conversation_boost),
|
||||||
valueRange = 0f..1f,
|
checked = conversationBoostEnabled.value,
|
||||||
value = ambientNoiseReductionSliderValue.floatValue,
|
onCheckedChange = { conversationBoostEnabled.value = it },
|
||||||
onValueChange = {
|
description = stringResource(R.string.conversation_boost_description)
|
||||||
ambientNoiseReductionSliderValue.floatValue = it
|
)
|
||||||
},
|
|
||||||
startLabel = stringResource(R.string.less),
|
|
||||||
endLabel = stringResource(R.string.more),
|
|
||||||
independent = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledToggle(
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
label = stringResource(R.string.conversation_boost),
|
}
|
||||||
checked = conversationBoostEnabled.value,
|
|
||||||
onCheckedChange = { conversationBoostEnabled.value = it },
|
|
||||||
description = stringResource(R.string.conversation_boost_description)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(bottomPadding))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+93
-95
@@ -24,16 +24,11 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBars
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -48,7 +43,6 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -59,17 +53,20 @@ import me.kavishdevar.librepods.bluetooth.att.types.sendTransparencySettings
|
|||||||
import me.kavishdevar.librepods.presentation.components.ConfirmationDialog
|
import me.kavishdevar.librepods.presentation.components.ConfirmationDialog
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledList
|
import me.kavishdevar.librepods.presentation.components.StyledList
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
|
||||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
private const val TAG = "AccessibilitySettings"
|
private const val TAG = "HearingAidScreen"
|
||||||
|
|
||||||
@SuppressLint("DefaultLocale")
|
@SuppressLint("DefaultLocale")
|
||||||
@Composable
|
@Composable
|
||||||
fun HearingAidScreen(viewModel: AppleViewModel, onNavigateHearingAidAdjustments: () -> Unit, onNavigateHearingTest: () -> Unit) {
|
fun HearingAidScreen(
|
||||||
|
viewModel: AppleViewModel,
|
||||||
|
navigateBack: (() -> Unit)?,
|
||||||
|
navigateToHearingAidAdjustments: () -> Unit,
|
||||||
|
navigateToHearingTest: () -> Unit
|
||||||
|
) {
|
||||||
val verticalScrollState = rememberScrollState()
|
val verticalScrollState = rememberScrollState()
|
||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
|
||||||
@@ -86,35 +83,35 @@ fun HearingAidScreen(viewModel: AppleViewModel, onNavigateHearingAidAdjustments:
|
|||||||
mutableStateOf((aidStatus?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.getOrNull(0) == 0x01.toByte()))
|
mutableStateOf((aidStatus?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.getOrNull(0) == 0x01.toByte()))
|
||||||
}
|
}
|
||||||
|
|
||||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
StyledScaffold(
|
||||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
title = stringResource(R.string.hearing_aid),
|
||||||
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
navigateBack = navigateBack
|
||||||
|
) { topPadding, bottomPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.layerBackdrop(backdrop)
|
.layerBackdrop(backdrop)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
.verticalScroll(verticalScrollState)
|
.verticalScroll(verticalScrollState)
|
||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(topPadding))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
// val mediaAssistEnabled = remember { mutableStateOf(false) }
|
// val mediaAssistEnabled = remember { mutableStateOf(false) }
|
||||||
// val adjustMediaEnabled = remember { mutableStateOf(false) }
|
// val adjustMediaEnabled = remember { mutableStateOf(false) }
|
||||||
// val adjustPhoneEnabled = remember { mutableStateOf(false) }
|
// val adjustPhoneEnabled = remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(hearingAidEnabled.value) {
|
LaunchedEffect(hearingAidEnabled.value) {
|
||||||
if (hearingAidEnabled.value && !initialLoad.value) {
|
if (hearingAidEnabled.value && !initialLoad.value) {
|
||||||
showDialog.value = true
|
showDialog.value = true
|
||||||
} else if (!hearingAidEnabled.value && !initialLoad.value) {
|
} else if (!hearingAidEnabled.value && !initialLoad.value) {
|
||||||
viewModel.setControlCommand(ControlCommandIdentifier.HEARING_AID, byteArrayOf(0x01, 0x02))
|
viewModel.setControlCommand(ControlCommandIdentifier.HEARING_AID, byteArrayOf(0x01, 0x02))
|
||||||
viewModel.setControlCommand(ControlCommandIdentifier.HEARING_ASSIST_CONFIG, 0x02.toByte())
|
viewModel.setControlCommand(ControlCommandIdentifier.HEARING_ASSIST_CONFIG, 0x02.toByte())
|
||||||
hearingAidEnabled.value = false
|
hearingAidEnabled.value = false
|
||||||
|
}
|
||||||
|
initialLoad.value = false
|
||||||
}
|
}
|
||||||
initialLoad.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// fun onAdjustPhoneChange(value: Boolean) {
|
// fun onAdjustPhoneChange(value: Boolean) {
|
||||||
// // TODO
|
// // TODO
|
||||||
@@ -124,69 +121,70 @@ fun HearingAidScreen(viewModel: AppleViewModel, onNavigateHearingAidAdjustments:
|
|||||||
// // TODO
|
// // TODO
|
||||||
// }
|
// }
|
||||||
|
|
||||||
StyledList (title = stringResource(R.string.hearing_aid)) {
|
StyledList (title = stringResource(R.string.hearing_aid)) {
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.hearing_aid),
|
label = stringResource(R.string.hearing_aid),
|
||||||
checked = hearingAidEnabled.value,
|
checked = hearingAidEnabled.value,
|
||||||
onCheckedChange = { hearingAidEnabled.value = it },
|
onCheckedChange = { hearingAidEnabled.value = it },
|
||||||
|
)
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.adjustments),
|
||||||
|
onClick = navigateToHearingAidAdjustments,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.hearing_aid_description),
|
||||||
|
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Light),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
StyledListItem(
|
StyledListItem(
|
||||||
contentText = stringResource(R.string.adjustments),
|
contentText = stringResource(R.string.update_hearing_test),
|
||||||
onClick = onNavigateHearingAidAdjustments,
|
onClick = navigateToHearingTest,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// not implemented yet
|
||||||
|
|
||||||
|
// StyledToggle(
|
||||||
|
// titleRes = stringResource(R.string.media_assist),
|
||||||
|
// label = stringResource(R.string.media_assist),
|
||||||
|
// checkedState = mediaAssistEnabled,
|
||||||
|
// independent = true,
|
||||||
|
// descriptionRes = stringResource(R.string.media_assist_description)
|
||||||
|
// )
|
||||||
|
|
||||||
|
// Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// Column (
|
||||||
|
// modifier = Modifier
|
||||||
|
// .fillMaxWidth()
|
||||||
|
// .background(backgroundColor, RoundedCornerShape(28.dp))
|
||||||
|
// ) {
|
||||||
|
// StyledToggle(
|
||||||
|
// label = stringResource(R.string.adjust_media),
|
||||||
|
// checkedState = adjustMediaEnabled,
|
||||||
|
// onCheckedChange = { onAdjustMediaChange(it) },
|
||||||
|
// independent = false
|
||||||
|
// )
|
||||||
|
// HorizontalDivider(
|
||||||
|
// thickness = 1.dp,
|
||||||
|
// color = Color(0x40888888),
|
||||||
|
// modifier = Modifier
|
||||||
|
// .padding(horizontal = 12.dp)
|
||||||
|
// )
|
||||||
|
|
||||||
|
// StyledToggle(
|
||||||
|
// label = stringResource(R.string.adjust_calls),
|
||||||
|
// checkedState = adjustPhoneEnabled,
|
||||||
|
// onCheckedChange = { onAdjustPhoneChange(it) },
|
||||||
|
// independent = false
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.hearing_aid_description),
|
|
||||||
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Light),
|
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
StyledListItem(
|
|
||||||
contentText = stringResource(R.string.update_hearing_test),
|
|
||||||
onClick = onNavigateHearingTest,
|
|
||||||
)
|
|
||||||
|
|
||||||
// not implemented yet
|
|
||||||
|
|
||||||
// StyledToggle(
|
|
||||||
// titleRes = stringResource(R.string.media_assist),
|
|
||||||
// label = stringResource(R.string.media_assist),
|
|
||||||
// checkedState = mediaAssistEnabled,
|
|
||||||
// independent = true,
|
|
||||||
// descriptionRes = stringResource(R.string.media_assist_description)
|
|
||||||
// )
|
|
||||||
|
|
||||||
// Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
// Column (
|
|
||||||
// modifier = Modifier
|
|
||||||
// .fillMaxWidth()
|
|
||||||
// .background(backgroundColor, RoundedCornerShape(28.dp))
|
|
||||||
// ) {
|
|
||||||
// StyledToggle(
|
|
||||||
// label = stringResource(R.string.adjust_media),
|
|
||||||
// checkedState = adjustMediaEnabled,
|
|
||||||
// onCheckedChange = { onAdjustMediaChange(it) },
|
|
||||||
// independent = false
|
|
||||||
// )
|
|
||||||
// HorizontalDivider(
|
|
||||||
// thickness = 1.dp,
|
|
||||||
// color = Color(0x40888888),
|
|
||||||
// modifier = Modifier
|
|
||||||
// .padding(horizontal = 12.dp)
|
|
||||||
// )
|
|
||||||
|
|
||||||
// StyledToggle(
|
|
||||||
// label = stringResource(R.string.adjust_calls),
|
|
||||||
// checkedState = adjustPhoneEnabled,
|
|
||||||
// onCheckedChange = { onAdjustPhoneChange(it) },
|
|
||||||
// independent = false
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
Spacer(modifier = Modifier.height(bottomPadding))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ConfirmationDialog(
|
ConfirmationDialog(
|
||||||
@@ -213,13 +211,13 @@ fun HearingAidScreen(viewModel: AppleViewModel, onNavigateHearingAidAdjustments:
|
|||||||
}
|
}
|
||||||
val parsed = parseTransparencySettingsResponse(state.hearingAidData)
|
val parsed = parseTransparencySettingsResponse(state.hearingAidData)
|
||||||
if (parsed == null) {
|
if (parsed == null) {
|
||||||
Log.w(TAG, "transparency parse failed")
|
Log.w(TAG, "hearingaid parse failed")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val disabledSettings = parsed.copy(enabled = false)
|
val disabledSettings = parsed.copy(enabled = false)
|
||||||
sendTransparencySettings(viewModel::writeATTCharacteristic, disabledSettings)
|
sendTransparencySettings(viewModel::writeATTCharacteristic, disabledSettings)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Error disabling transparency: ${e.message}")
|
Log.e(TAG, "Error disabling hearingaid: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+60
-60
@@ -21,14 +21,10 @@ package me.kavishdevar.librepods.presentation.screens.apple
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBars
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -40,81 +36,85 @@ import androidx.compose.ui.unit.dp
|
|||||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.bluetooth.att.ATTHandle
|
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
|
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
|
||||||
|
import me.kavishdevar.librepods.bluetooth.att.ATTHandle
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
|
||||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HearingProtectionScreen(viewModel: AppleViewModel, navigateToPurchase: () -> Unit) {
|
fun HearingProtectionScreen(
|
||||||
|
viewModel: AppleViewModel,
|
||||||
|
navigateBack: (() -> Unit)?,
|
||||||
|
navigateToPurchase: () -> Unit
|
||||||
|
) {
|
||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
val state = uiState.state
|
val state = uiState.state
|
||||||
|
|
||||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
StyledScaffold(
|
||||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
title = stringResource(R.string.hearing_protection),
|
||||||
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
navigateBack = navigateBack
|
||||||
|
) { topPadding, bottomPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
.layerBackdrop(backdrop)
|
.layerBackdrop(backdrop)
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(topPadding))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
if (!uiState.isPremium) {
|
if (!uiState.isPremium) {
|
||||||
StyledButton(
|
StyledButton(
|
||||||
onClick = navigateToPurchase,
|
onClick = navigateToPurchase,
|
||||||
backdrop = rememberLayerBackdrop(),
|
backdrop = rememberLayerBackdrop(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
maxScale = 0.05f,
|
maxScale = 0.05f,
|
||||||
surfaceColor = MaterialTheme.colorScheme.primary
|
surfaceColor = MaterialTheme.colorScheme.primary
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.unlock_advanced_features),
|
stringResource(R.string.unlock_advanced_features),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onPrimary
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uiState.vendorIdHook) {
|
if (uiState.vendorIdHook) {
|
||||||
|
StyledToggle(
|
||||||
|
title = stringResource(R.string.environmental_noise),
|
||||||
|
label = stringResource(R.string.loud_sound_reduction),
|
||||||
|
description = stringResource(R.string.loud_sound_reduction_description),
|
||||||
|
checked = state.loudSoundReductionEnabled,
|
||||||
|
onCheckedChange = {
|
||||||
|
viewModel.writeATTCharacteristic(
|
||||||
|
ATTHandle.LOUD_SOUND_REDUCTION,
|
||||||
|
byteArrayOf(if (it) 1.toByte() else 0.toByte())
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = uiState.isPremium
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
title = stringResource(R.string.environmental_noise),
|
title = stringResource(R.string.workspace_use),
|
||||||
label = stringResource(R.string.loud_sound_reduction),
|
label = stringResource(R.string.ppe),
|
||||||
description = stringResource(R.string.loud_sound_reduction_description),
|
description = stringResource(R.string.workspace_use_description),
|
||||||
checked = state.loudSoundReductionEnabled,
|
checked = state.controlStates[ControlCommandIdentifier.PPE_TOGGLE_CONFIG]?.getOrNull(
|
||||||
|
0
|
||||||
|
)?.toInt() == 1,
|
||||||
onCheckedChange = {
|
onCheckedChange = {
|
||||||
viewModel.writeATTCharacteristic(
|
viewModel.setControlCommand(
|
||||||
ATTHandle.LOUD_SOUND_REDUCTION,
|
ControlCommandIdentifier.PPE_TOGGLE_CONFIG, it
|
||||||
byteArrayOf(if (it) 1.toByte() else 0.toByte())
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
enabled = uiState.isPremium
|
enabled = uiState.isPremium
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
}
|
}
|
||||||
StyledToggle(
|
|
||||||
title = stringResource(R.string.workspace_use),
|
|
||||||
label = stringResource(R.string.ppe),
|
|
||||||
description = stringResource(R.string.workspace_use_description),
|
|
||||||
checked = state.controlStates[ControlCommandIdentifier.PPE_TOGGLE_CONFIG]?.getOrNull(
|
|
||||||
0
|
|
||||||
)?.toInt() == 1,
|
|
||||||
onCheckedChange = {
|
|
||||||
viewModel.setControlCommand(
|
|
||||||
ControlCommandIdentifier.PPE_TOGGLE_CONFIG, it
|
|
||||||
)
|
|
||||||
},
|
|
||||||
enabled = uiState.isPremium
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(bottomPadding))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+253
@@ -0,0 +1,253 @@
|
|||||||
|
package me.kavishdevar.librepods.presentation.screens.apple
|
||||||
|
|
||||||
|
import android.text.format.DateFormat
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material3.FilledTonalIconToggleButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialShapes
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.minimumInteractiveComponentSize
|
||||||
|
import androidx.compose.material3.toShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalLocale
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.health.connect.client.permission.HealthPermission
|
||||||
|
import androidx.health.connect.client.records.HeartRateRecord
|
||||||
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
|
import com.google.accompanist.permissions.isGranted
|
||||||
|
import com.google.accompanist.permissions.rememberPermissionState
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledIconButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
|
import me.kavishdevar.librepods.presentation.icons.LocalIcons
|
||||||
|
import me.kavishdevar.librepods.presentation.icons.MaterialIcons
|
||||||
|
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||||
|
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HeartRateRoute(
|
||||||
|
viewModel: AppleViewModel,
|
||||||
|
navigateBack: (() -> Unit)?
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
HeartRateScreen(
|
||||||
|
uiState = uiState,
|
||||||
|
navigateBack = navigateBack,
|
||||||
|
startHr = viewModel::startHr,
|
||||||
|
stopHr = viewModel::stopHr,
|
||||||
|
setHrRange = viewModel::setHrRange
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
|
@Composable
|
||||||
|
fun HeartRateScreen(
|
||||||
|
uiState: AppleUiState,
|
||||||
|
navigateBack: (() -> Unit)?,
|
||||||
|
startHr: () -> Unit,
|
||||||
|
stopHr: () -> Unit,
|
||||||
|
setHrRange: (ClosedRange<Long>) -> Unit
|
||||||
|
) {
|
||||||
|
val state = uiState.state
|
||||||
|
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
|
StyledScaffold(
|
||||||
|
title = stringResource(R.string.heart_rate),
|
||||||
|
navigateBack = navigateBack,
|
||||||
|
actionButtons = listOf(
|
||||||
|
{ scaffoldBackdrop ->
|
||||||
|
if (LocalDesignSystem.current == DesignSystem.Material) {
|
||||||
|
FilledTonalIconToggleButton(
|
||||||
|
checked = state.hrmActive,
|
||||||
|
onCheckedChange = { if (it) startHr() else stopHr() },
|
||||||
|
modifier = Modifier
|
||||||
|
.minimumInteractiveComponentSize()
|
||||||
|
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
|
||||||
|
shape = IconButtonDefaults.mediumRoundShape
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (state.hrmActive) MaterialIcons.Pause else Icons.Default.PlayArrow,
|
||||||
|
contentDescription = "Start/Stop",
|
||||||
|
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
StyledIconButton(
|
||||||
|
onClick = { if (!state.hrmActive) startHr() else stopHr() },
|
||||||
|
backdrop = scaffoldBackdrop
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (state.hrmActive) LocalIcons.current.Pause else LocalIcons.current.Play,
|
||||||
|
contentDescription = "Start/Stop",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onBackground
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) { topPadding, bottomPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
|
val sliderValue = remember {
|
||||||
|
mutableFloatStateOf(state.heartRateInterval.inWholeMilliseconds.toFloat())
|
||||||
|
}
|
||||||
|
|
||||||
|
val healthPermissions = rememberPermissionState(
|
||||||
|
HealthPermission.getWritePermission(HeartRateRecord::class)
|
||||||
|
)
|
||||||
|
|
||||||
|
AnimatedVisibility(visible = !healthPermissions.status.isGranted) {
|
||||||
|
if (healthPermissions.status.isGranted) return@AnimatedVisibility
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.permission_healthconnect),
|
||||||
|
onClick = { healthPermissions.launchPermissionRequest() },
|
||||||
|
supportingText = stringResource(R.string.permission_description_healthconnect),
|
||||||
|
leadingContent = {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerLow,
|
||||||
|
MaterialShapes.SoftBurst.normalized()
|
||||||
|
.toShape()
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = LocalIcons.current.VitalSigns,
|
||||||
|
contentDescription = "vital signs",
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orientation = StyledListItemOrientation.Vertical
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// saw this while using one of the forks. 1 minute doesn't seem to work for me
|
||||||
|
// StyledList(
|
||||||
|
// title = stringResource(R.string.interval),
|
||||||
|
// description = stringResource(R.string.heart_rate_interval_description),
|
||||||
|
// ) {
|
||||||
|
// StyledListItem(
|
||||||
|
// onClick = { setHrInterval(1.seconds) },
|
||||||
|
// contentText = stringResource(R.string.one_second),
|
||||||
|
// selected = state.heartRateInterval == 1.seconds
|
||||||
|
// )
|
||||||
|
// StyledListItem(
|
||||||
|
// onClick = { setHrInterval(1.minutes) },
|
||||||
|
// contentText = stringResource(R.string.one_minute),
|
||||||
|
// selected = state.heartRateInterval == 1.minutes
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
|
||||||
|
AnimatedVisibility(state.currentHeartRate != null) {
|
||||||
|
if (state.currentHeartRate == null) return@AnimatedVisibility
|
||||||
|
StyledListItem(
|
||||||
|
onClick = null,
|
||||||
|
content = {
|
||||||
|
Text(
|
||||||
|
text = "${state.currentHeartRate.bpm} bpm",
|
||||||
|
style = MaterialTheme.typography.bodyLargeEmphasized,
|
||||||
|
modifier = Modifier.fillMaxHeight()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
val locale = LocalLocale.current.platformLocale
|
||||||
|
val timePattern = DateFormat.getBestDateTimePattern(
|
||||||
|
locale,
|
||||||
|
"jms"
|
||||||
|
)
|
||||||
|
val formatter = DateTimeFormatter.ofPattern(timePattern)
|
||||||
|
|
||||||
|
val timeString = formatter.format(
|
||||||
|
Instant.ofEpochMilli(state.currentHeartRate.timestamp.toEpochMilliseconds())
|
||||||
|
.atZone(ZoneId.systemDefault())
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = timeString,
|
||||||
|
style = MaterialTheme.typography.bodySmall
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
MaterialShapes.SoftBurst.normalized()
|
||||||
|
.toShape()
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = LocalIcons.current.VitalSigns,
|
||||||
|
contentDescription = "vital signs",
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orientation = StyledListItemOrientation.Vertical
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 350.dp)
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||||
|
RoundedCornerShape(28.dp)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// TODO: graph or something
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
-56
@@ -1,16 +1,11 @@
|
|||||||
package me.kavishdevar.librepods.presentation.screens.apple
|
package me.kavishdevar.librepods.presentation.screens.apple
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBars
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -19,85 +14,77 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
|
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledList
|
import me.kavishdevar.librepods.presentation.components.StyledList
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MicrophoneSettingsRoute(
|
fun MicrophoneSettingsRoute(
|
||||||
viewModel: AppleViewModel
|
viewModel: AppleViewModel,
|
||||||
|
navigateBack: (() -> Unit)?
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
val state = uiState.state
|
val state = uiState.state
|
||||||
|
|
||||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
|
||||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
|
||||||
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
|
||||||
|
|
||||||
val id = ControlCommandIdentifier.MIC_MODE
|
val id = ControlCommandIdentifier.MIC_MODE
|
||||||
|
|
||||||
Box (
|
MicrophoneSettingsScreen(
|
||||||
modifier = Modifier
|
navigateBack = navigateBack,
|
||||||
.fillMaxSize()
|
selectedMode = state.controlStates[id]?.getOrNull(0)?.toInt() ?: 0,
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
onMicrophoneSettingsChanged = {
|
||||||
) {
|
viewModel.setControlCommand(id, it)
|
||||||
MicrophoneSettingsScreen(
|
}
|
||||||
selectedMode = state.controlStates[id]?.getOrNull(0)?.toInt() ?: 0,
|
)
|
||||||
topPadding = topPadding,
|
|
||||||
bottomPadding = bottomPadding,
|
|
||||||
onMicrophoneSettingsChanged = {
|
|
||||||
viewModel.setControlCommand(id, it)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MicrophoneSettingsScreen(
|
fun MicrophoneSettingsScreen(
|
||||||
|
navigateBack: (() -> Unit)?,
|
||||||
selectedMode: Int,
|
selectedMode: Int,
|
||||||
topPadding: Dp = 16.dp,
|
|
||||||
bottomPadding: Dp = 16.dp,
|
|
||||||
onMicrophoneSettingsChanged: (Int) -> Unit
|
onMicrophoneSettingsChanged: (Int) -> Unit
|
||||||
) {
|
) {
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
Column(
|
StyledScaffold(
|
||||||
modifier = Modifier
|
title = stringResource(R.string.microphone_mode),
|
||||||
.fillMaxSize()
|
navigateBack = navigateBack
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
) { topPadding, bottomPadding ->
|
||||||
.verticalScroll(scrollState)
|
Column(
|
||||||
.padding(top = 8.dp)
|
modifier = Modifier
|
||||||
.padding(horizontal = 16.dp)
|
.fillMaxSize()
|
||||||
) {
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
Spacer(modifier = Modifier.height(topPadding))
|
.verticalScroll(scrollState)
|
||||||
|
.padding(top = 8.dp)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
StyledList {
|
StyledList {
|
||||||
StyledListItem(
|
StyledListItem(
|
||||||
contentText = stringResource(R.string.microphone_automatic),
|
contentText = stringResource(R.string.microphone_automatic),
|
||||||
selected = selectedMode == 0,
|
selected = selectedMode == 0,
|
||||||
onClick = { onMicrophoneSettingsChanged(0) }
|
onClick = { onMicrophoneSettingsChanged(0) }
|
||||||
)
|
)
|
||||||
|
|
||||||
StyledListItem(
|
StyledListItem(
|
||||||
contentText = stringResource(R.string.microphone_always_right),
|
contentText = stringResource(R.string.microphone_always_right),
|
||||||
selected = selectedMode == 1,
|
selected = selectedMode == 1,
|
||||||
onClick = { onMicrophoneSettingsChanged(1) }
|
onClick = { onMicrophoneSettingsChanged(1) }
|
||||||
)
|
)
|
||||||
|
|
||||||
StyledListItem(
|
StyledListItem(
|
||||||
contentText = stringResource(R.string.microphone_always_left),
|
contentText = stringResource(R.string.microphone_always_left),
|
||||||
selected = selectedMode == 2,
|
selected = selectedMode == 2,
|
||||||
onClick = { onMicrophoneSettingsChanged(2) }
|
onClick = { onMicrophoneSettingsChanged(2) }
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(bottomPadding))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+135
-138
@@ -24,18 +24,13 @@ import android.util.Log
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBars
|
|
||||||
import androidx.compose.foundation.layout.wrapContentWidth
|
import androidx.compose.foundation.layout.wrapContentWidth
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -47,24 +42,25 @@ import androidx.compose.ui.res.painterResource
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
|
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
|
||||||
import me.kavishdevar.librepods.data.StemAction
|
import me.kavishdevar.librepods.data.StemAction
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
|
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledList
|
import me.kavishdevar.librepods.presentation.components.StyledList
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
|
||||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
||||||
import kotlin.experimental.and
|
import kotlin.experimental.and
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LongPress(viewModel: AppleViewModel, name: String, navigateToPurchase: () -> Unit) {
|
fun LongPress(
|
||||||
|
viewModel: AppleViewModel,
|
||||||
|
name: String,
|
||||||
|
navigateBack: (() -> Unit)?,
|
||||||
|
navigateToPurchase: () -> Unit
|
||||||
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
val state = uiState.state
|
val state = uiState.state
|
||||||
@@ -78,83 +74,144 @@ fun LongPress(viewModel: AppleViewModel, name: String, navigateToPurchase: () ->
|
|||||||
Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}")
|
Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}")
|
||||||
Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
|
Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
|
||||||
|
|
||||||
val longPressAction = if (name.lowercase() == "left") settings.leftLongPressAction else settings.rightLongPressAction
|
val longPressAction = if (name == stringResource(R.string.left)) settings.leftLongPressAction else settings.rightLongPressAction
|
||||||
|
|
||||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
|
||||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
|
||||||
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
|
||||||
|
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
Column (
|
StyledScaffold(
|
||||||
modifier = Modifier
|
title = name,
|
||||||
.fillMaxSize()
|
navigateBack = navigateBack
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
) { topPadding, bottomPadding ->
|
||||||
.verticalScroll(scrollState)
|
Column (
|
||||||
.padding(top = 8.dp)
|
modifier = Modifier
|
||||||
.padding(horizontal = 16.dp)
|
.fillMaxSize()
|
||||||
) {
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
Spacer(modifier = Modifier.height(topPadding))
|
.verticalScroll(scrollState)
|
||||||
|
.padding(top = 8.dp)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
StyledList {
|
StyledList {
|
||||||
StyledListItem(
|
StyledListItem(
|
||||||
contentText = stringResource(R.string.noise_control),
|
contentText = stringResource(R.string.noise_control),
|
||||||
selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES,
|
selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES,
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.setLongPressAction(
|
viewModel.setLongPressAction(
|
||||||
name,
|
name,
|
||||||
StemAction.CYCLE_NOISE_CONTROL_MODES
|
StemAction.CYCLE_NOISE_CONTROL_MODES
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
StyledListItem(
|
StyledListItem(
|
||||||
contentText = stringResource(R.string.digital_assistant),
|
contentText = stringResource(R.string.digital_assistant),
|
||||||
selected = longPressAction == StemAction.DIGITAL_ASSISTANT,
|
selected = longPressAction == StemAction.DIGITAL_ASSISTANT,
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.setLongPressAction(
|
viewModel.setLongPressAction(
|
||||||
name,
|
name,
|
||||||
StemAction.DIGITAL_ASSISTANT
|
StemAction.DIGITAL_ASSISTANT
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
enabled = uiState.isPremium
|
enabled = uiState.isPremium
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!uiState.isPremium) {
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
|
||||||
StyledButton(
|
|
||||||
onClick = navigateToPurchase,
|
|
||||||
backdrop = rememberLayerBackdrop(),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
maxScale = 0.05f,
|
|
||||||
surfaceColor = MaterialTheme.colorScheme.primary
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.unlock_advanced_features),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimary
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) {
|
if (!uiState.isPremium) {
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
StyledButton(
|
||||||
|
onClick = navigateToPurchase,
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
maxScale = 0.05f,
|
||||||
|
surfaceColor = MaterialTheme.colorScheme.primary
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.unlock_advanced_features),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
val currentByte = state.controlStates[ControlCommandIdentifier.LISTENING_MODE_CONFIGS]?.get(0)?.toInt() ?: 0
|
if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) {
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
val currentByte = state.controlStates[ControlCommandIdentifier.LISTENING_MODE_CONFIGS]?.get(0)?.toInt() ?: 0
|
||||||
|
|
||||||
|
StyledList(
|
||||||
|
title = stringResource(R.string.noise_control),
|
||||||
|
description = stringResource(R.string.press_and_hold_noise_control_description)
|
||||||
|
) {
|
||||||
|
if (state.controlStates[ControlCommandIdentifier.ALLOW_OFF_OPTION]?.get(0) == 1.toByte()) {
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.off),
|
||||||
|
supportingText = stringResource(R.string.listening_mode_off_description),
|
||||||
|
selected = (currentByte and 0x01) != 0,
|
||||||
|
onClick = {
|
||||||
|
viewModel.toggleListeningMode(0x01)
|
||||||
|
},
|
||||||
|
orientation = StyledListItemOrientation.Vertical,
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_noise_cancellation),
|
||||||
|
contentDescription = "Icon",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier
|
||||||
|
.height(42.dp)
|
||||||
|
.wrapContentWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
StyledList(
|
|
||||||
title = stringResource(R.string.noise_control),
|
|
||||||
description = stringResource(R.string.press_and_hold_noise_control_description)
|
|
||||||
) {
|
|
||||||
if (state.controlStates[ControlCommandIdentifier.ALLOW_OFF_OPTION]?.get(0) == 1.toByte()) {
|
|
||||||
StyledListItem(
|
StyledListItem(
|
||||||
contentText = stringResource(R.string.off),
|
contentText = stringResource(R.string.transparency),
|
||||||
supportingText = stringResource(R.string.listening_mode_off_description),
|
supportingText = stringResource(R.string.listening_mode_transparency_description),
|
||||||
selected = (currentByte and 0x01) != 0,
|
selected = (currentByte and 0x04) != 0,
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.toggleListeningMode(0x01)
|
viewModel.toggleListeningMode(0x04)
|
||||||
|
},
|
||||||
|
orientation = StyledListItemOrientation.Vertical,
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_transparency),
|
||||||
|
contentDescription = "Icon",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier
|
||||||
|
.height(42.dp)
|
||||||
|
.wrapContentWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.adaptive),
|
||||||
|
supportingText = stringResource(R.string.listening_mode_adaptive_description),
|
||||||
|
selected = (currentByte and 0x08) != 0,
|
||||||
|
onClick = {
|
||||||
|
viewModel.toggleListeningMode(0x08)
|
||||||
|
},
|
||||||
|
orientation = StyledListItemOrientation.Vertical,
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_adaptive),
|
||||||
|
contentDescription = "Icon",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier
|
||||||
|
.height(42.dp)
|
||||||
|
.wrapContentWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.noise_cancellation),
|
||||||
|
supportingText = stringResource(R.string.listening_mode_noise_cancellation_description),
|
||||||
|
selected = (currentByte and 0x02) != 0,
|
||||||
|
onClick = {
|
||||||
|
viewModel.toggleListeningMode(0x02)
|
||||||
},
|
},
|
||||||
orientation = StyledListItemOrientation.Vertical,
|
orientation = StyledListItemOrientation.Vertical,
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
@@ -169,68 +226,8 @@ fun LongPress(viewModel: AppleViewModel, name: String, navigateToPurchase: () ->
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
StyledListItem(
|
|
||||||
contentText = stringResource(R.string.transparency),
|
|
||||||
supportingText = stringResource(R.string.listening_mode_transparency_description),
|
|
||||||
selected = (currentByte and 0x04) != 0,
|
|
||||||
onClick = {
|
|
||||||
viewModel.toggleListeningMode(0x04)
|
|
||||||
},
|
|
||||||
orientation = StyledListItemOrientation.Vertical,
|
|
||||||
leadingContent = {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(R.drawable.ic_transparency),
|
|
||||||
contentDescription = "Icon",
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier
|
|
||||||
.height(42.dp)
|
|
||||||
.wrapContentWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledListItem(
|
|
||||||
contentText = stringResource(R.string.adaptive),
|
|
||||||
supportingText = stringResource(R.string.listening_mode_adaptive_description),
|
|
||||||
selected = (currentByte and 0x08) != 0,
|
|
||||||
onClick = {
|
|
||||||
viewModel.toggleListeningMode(0x08)
|
|
||||||
},
|
|
||||||
orientation = StyledListItemOrientation.Vertical,
|
|
||||||
leadingContent = {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(R.drawable.ic_adaptive),
|
|
||||||
contentDescription = "Icon",
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier
|
|
||||||
.height(42.dp)
|
|
||||||
.wrapContentWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledListItem(
|
|
||||||
contentText = stringResource(R.string.noise_cancellation),
|
|
||||||
supportingText = stringResource(R.string.listening_mode_noise_cancellation_description),
|
|
||||||
selected = (currentByte and 0x02) != 0,
|
|
||||||
onClick = {
|
|
||||||
viewModel.toggleListeningMode(0x02)
|
|
||||||
},
|
|
||||||
orientation = StyledListItemOrientation.Vertical,
|
|
||||||
leadingContent = {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(R.drawable.ic_noise_cancellation),
|
|
||||||
contentDescription = "Icon",
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier
|
|
||||||
.height(42.dp)
|
|
||||||
.wrapContentWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(bottomPadding))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+143
-154
@@ -8,14 +8,10 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBars
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
@@ -45,12 +41,11 @@ import androidx.core.content.FileProvider
|
|||||||
import me.kavishdevar.librepods.BuildConfig
|
import me.kavishdevar.librepods.BuildConfig
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.data.recording.Recording
|
import me.kavishdevar.librepods.data.recording.Recording
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
|
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledList
|
import me.kavishdevar.librepods.presentation.components.StyledList
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
|
||||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState
|
import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -60,27 +55,17 @@ import java.time.format.DateTimeFormatter
|
|||||||
@Composable
|
@Composable
|
||||||
fun RecordingScreenRoute(
|
fun RecordingScreenRoute(
|
||||||
viewModel: AppleViewModel,
|
viewModel: AppleViewModel,
|
||||||
|
navigateBack: (() -> Unit)?
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
RecordingScreen(
|
||||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
uiState = uiState,
|
||||||
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
navigateBack = navigateBack,
|
||||||
|
recordings = viewModel.recordings(),
|
||||||
Box (
|
startRecording = viewModel::startRecording,
|
||||||
modifier = Modifier
|
stopRecording = viewModel::stopRecording,
|
||||||
.fillMaxSize()
|
)
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
|
||||||
) {
|
|
||||||
RecordingScreen(
|
|
||||||
uiState = uiState,
|
|
||||||
recordings = viewModel.recordings(),
|
|
||||||
startRecording = viewModel::startRecording,
|
|
||||||
stopRecording = viewModel::stopRecording,
|
|
||||||
topPadding = topPadding,
|
|
||||||
bottomPadding = bottomPadding
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose {
|
onDispose {
|
||||||
@@ -92,11 +77,10 @@ fun RecordingScreenRoute(
|
|||||||
@Composable
|
@Composable
|
||||||
fun RecordingScreen(
|
fun RecordingScreen(
|
||||||
uiState: AppleUiState,
|
uiState: AppleUiState,
|
||||||
|
navigateBack: (() -> Unit)?,
|
||||||
recordings: List<Recording>,
|
recordings: List<Recording>,
|
||||||
startRecording: () -> Unit,
|
startRecording: () -> Unit,
|
||||||
stopRecording: () -> Unit,
|
stopRecording: () -> Unit,
|
||||||
topPadding: Dp = 16.dp,
|
|
||||||
bottomPadding: Dp = 16.dp
|
|
||||||
) {
|
) {
|
||||||
val state = uiState.state
|
val state = uiState.state
|
||||||
|
|
||||||
@@ -139,150 +123,155 @@ fun RecordingScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
StyledScaffold(
|
||||||
modifier = Modifier
|
title = stringResource(R.string.recorder),
|
||||||
.fillMaxWidth()
|
navigateBack = navigateBack
|
||||||
.padding(horizontal = 16.dp)
|
) { topPadding, bottomPadding ->
|
||||||
) {
|
Column(
|
||||||
Spacer(modifier = Modifier.padding(top = topPadding))
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.padding(top = topPadding))
|
||||||
|
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = state.recordingState.isRecording,
|
targetState = state.recordingState.isRecording,
|
||||||
label = "recording",
|
label = "recording",
|
||||||
modifier = Modifier.padding(vertical = 24.dp).weight(1f)
|
modifier = Modifier.padding(vertical = 24.dp).weight(1f)
|
||||||
) { recording ->
|
) { recording ->
|
||||||
if (recording) {
|
if (recording) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
MaterialTheme.colorScheme.surface,
|
|
||||||
RoundedCornerShape(28.dp)
|
|
||||||
)
|
|
||||||
.padding(24.dp)
|
|
||||||
) {
|
|
||||||
// AI-generated
|
|
||||||
Canvas(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxWidth()
|
||||||
) {
|
.background(
|
||||||
if (history.isEmpty()) return@Canvas
|
MaterialTheme.colorScheme.surface,
|
||||||
|
RoundedCornerShape(28.dp)
|
||||||
val spacing = 2.dp.toPx()
|
|
||||||
val width = 3.dp.toPx()
|
|
||||||
|
|
||||||
val visible = ((size.width + spacing) / (width + spacing)).toInt()
|
|
||||||
|
|
||||||
val start = (history.size - visible).coerceAtLeast(0)
|
|
||||||
|
|
||||||
val centerY = size.height / 2f
|
|
||||||
|
|
||||||
var x = size.width - width
|
|
||||||
|
|
||||||
for (i in history.lastIndex downTo start) {
|
|
||||||
val h = history[i]
|
|
||||||
.coerceIn(0f, 1f)
|
|
||||||
.let { 6.dp.toPx() + it * (size.height * 0.45f) }
|
|
||||||
|
|
||||||
drawRoundRect(
|
|
||||||
color = Color(0xFFFF4D4D),
|
|
||||||
topLeft = Offset(
|
|
||||||
x,
|
|
||||||
centerY - h
|
|
||||||
),
|
|
||||||
size = Size(
|
|
||||||
width,
|
|
||||||
h * 2
|
|
||||||
),
|
|
||||||
cornerRadius = CornerRadius(
|
|
||||||
width / 2,
|
|
||||||
width / 2
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
.padding(24.dp)
|
||||||
|
) {
|
||||||
|
// AI-generated
|
||||||
|
Canvas(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
) {
|
||||||
|
if (history.isEmpty()) return@Canvas
|
||||||
|
|
||||||
x -= width + spacing
|
val spacing = 2.dp.toPx()
|
||||||
|
val width = 3.dp.toPx()
|
||||||
|
|
||||||
if (x < 0f)
|
val visible = ((size.width + spacing) / (width + spacing)).toInt()
|
||||||
break
|
|
||||||
|
val start = (history.size - visible).coerceAtLeast(0)
|
||||||
|
|
||||||
|
val centerY = size.height / 2f
|
||||||
|
|
||||||
|
var x = size.width - width
|
||||||
|
|
||||||
|
for (i in history.lastIndex downTo start) {
|
||||||
|
val h = history[i]
|
||||||
|
.coerceIn(0f, 1f)
|
||||||
|
.let { 6.dp.toPx() + it * (size.height * 0.45f) }
|
||||||
|
|
||||||
|
drawRoundRect(
|
||||||
|
color = Color(0xFFFF4D4D),
|
||||||
|
topLeft = Offset(
|
||||||
|
x,
|
||||||
|
centerY - h
|
||||||
|
),
|
||||||
|
size = Size(
|
||||||
|
width,
|
||||||
|
h * 2
|
||||||
|
),
|
||||||
|
cornerRadius = CornerRadius(
|
||||||
|
width / 2,
|
||||||
|
width / 2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
x -= width + spacing
|
||||||
|
|
||||||
|
if (x < 0f)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = buildString {
|
||||||
|
val total = state.microphoneState.durationMs
|
||||||
|
|
||||||
|
append((total / 60000).toString().padStart(2, '0'))
|
||||||
|
append(':')
|
||||||
|
|
||||||
|
append(((total / 1000) % 60).toString().padStart(2, '0'))
|
||||||
|
append('.')
|
||||||
|
|
||||||
|
append(((total % 1000) / 10).toString().padStart(2, '0'))
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = MaterialTheme.typography.displaySmall
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.verticalScroll(scrollState)
|
||||||
|
) {
|
||||||
|
if (recordings.isNotEmpty()) {
|
||||||
|
StyledList(title = stringResource(R.string.recordings)) {
|
||||||
|
recordings.forEach {
|
||||||
|
val text = formatter.format(
|
||||||
|
Instant.ofEpochMilli(it.createdAt.toEpochMilliseconds())
|
||||||
|
.atZone(ZoneId.systemDefault())
|
||||||
|
)
|
||||||
|
StyledListItem(
|
||||||
|
contentText = text,
|
||||||
|
supportingText = it.uuid.toString(),
|
||||||
|
orientation = StyledListItemOrientation.Vertical,
|
||||||
|
onClick = {
|
||||||
|
val uri = FileProvider.getUriForFile(
|
||||||
|
context,
|
||||||
|
"${BuildConfig.APPLICATION_ID}.provider",
|
||||||
|
it.file
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
setDataAndType(uri, "audio/wav")
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
|
||||||
Text(
|
context.startActivity(
|
||||||
text = buildString {
|
Intent.createChooser(intent, null)
|
||||||
val total = state.microphoneState.durationMs
|
)
|
||||||
|
|
||||||
append((total / 60000).toString().padStart(2, '0'))
|
|
||||||
append(':')
|
|
||||||
|
|
||||||
append(((total / 1000) % 60).toString().padStart(2, '0'))
|
|
||||||
append('.')
|
|
||||||
|
|
||||||
append(((total % 1000) / 10).toString().padStart(2, '0'))
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
style = MaterialTheme.typography.displaySmall
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val scrollState = rememberScrollState()
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.verticalScroll(scrollState)
|
|
||||||
) {
|
|
||||||
if (recordings.isNotEmpty()) {
|
|
||||||
StyledList(title = stringResource(R.string.recordings)) {
|
|
||||||
recordings.forEach {
|
|
||||||
val text = formatter.format(
|
|
||||||
Instant.ofEpochMilli(it.createdAt.toEpochMilliseconds())
|
|
||||||
.atZone(ZoneId.systemDefault())
|
|
||||||
)
|
|
||||||
StyledListItem(
|
|
||||||
contentText = text,
|
|
||||||
supportingText = it.uuid.toString(),
|
|
||||||
orientation = StyledListItemOrientation.Vertical,
|
|
||||||
onClick = {
|
|
||||||
val uri = FileProvider.getUriForFile(
|
|
||||||
context,
|
|
||||||
"${BuildConfig.APPLICATION_ID}.provider",
|
|
||||||
it.file
|
|
||||||
)
|
|
||||||
|
|
||||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
|
||||||
setDataAndType(uri, "audio/wav")
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
context.startActivity(
|
}
|
||||||
Intent.createChooser(intent, null)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
StyledButton(
|
StyledButton(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
onClick = if (state.recordingState.isRecording) {
|
onClick = if (state.recordingState.isRecording) {
|
||||||
stopRecording
|
stopRecording
|
||||||
} else {
|
} else {
|
||||||
startRecording
|
startRecording
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (state.recordingState.isRecording) {
|
||||||
|
"Stop Recording"
|
||||||
|
} else "Start Recording",
|
||||||
|
style = MaterialTheme.typography.labelMedium
|
||||||
|
)
|
||||||
}
|
}
|
||||||
) {
|
Spacer(modifier = Modifier.padding(bottom = bottomPadding))
|
||||||
Text(
|
|
||||||
text = if (state.recordingState.isRecording) {
|
|
||||||
"Stop Recording"
|
|
||||||
} else "Start Recording",
|
|
||||||
style = MaterialTheme.typography.labelMedium
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.padding(bottom = bottomPadding))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-35
@@ -20,20 +20,13 @@
|
|||||||
|
|
||||||
package me.kavishdevar.librepods.presentation.screens.apple
|
package me.kavishdevar.librepods.presentation.screens.apple
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBars
|
|
||||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -41,21 +34,22 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.edit
|
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledInputField
|
import me.kavishdevar.librepods.presentation.components.StyledInputField
|
||||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun RenameScreen(viewModel: AppleViewModel) {
|
fun RenameScreen(
|
||||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
viewModel: AppleViewModel,
|
||||||
|
navigateBack: (() -> Unit)?
|
||||||
|
) {
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
val keyboardController = LocalSoftwareKeyboardController.current
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
|
||||||
@@ -64,33 +58,33 @@ fun RenameScreen(viewModel: AppleViewModel) {
|
|||||||
keyboardController?.show()
|
keyboardController?.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
|
||||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
|
||||||
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
|
||||||
|
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
val metadata = uiState.metadata
|
val metadata = uiState.metadata
|
||||||
|
|
||||||
Column(
|
StyledScaffold(
|
||||||
modifier = Modifier
|
title = stringResource(R.string.name),
|
||||||
.fillMaxSize()
|
navigateBack = navigateBack
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
) { topPadding, bottomPadding ->
|
||||||
.padding(horizontal = 16.dp)
|
Column(
|
||||||
) {
|
modifier = Modifier
|
||||||
Spacer(modifier = Modifier.height(topPadding))
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
val textFieldState = rememberTextFieldState(initialText = metadata.name)
|
val textFieldState = rememberTextFieldState(initialText = metadata.name)
|
||||||
|
|
||||||
|
LaunchedEffect(textFieldState.text) {
|
||||||
|
viewModel.renameDevice(textFieldState.text.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledInputField(
|
||||||
|
textFieldState,
|
||||||
|
focusRequester
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
|
|
||||||
LaunchedEffect(textFieldState.text) {
|
|
||||||
viewModel.renameDevice(textFieldState.text.toString())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
StyledInputField(
|
|
||||||
textFieldState,
|
|
||||||
focusRequester
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(bottomPadding))
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+226
-230
@@ -26,20 +26,15 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.statusBars
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
import androidx.compose.material3.SliderDefaults
|
import androidx.compose.material3.SliderDefaults
|
||||||
@@ -61,11 +56,11 @@ import androidx.compose.ui.graphics.luminance
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.bluetooth.att.types.TransparencySettings
|
import me.kavishdevar.librepods.bluetooth.att.types.TransparencySettings
|
||||||
import me.kavishdevar.librepods.bluetooth.att.types.parseTransparencySettingsResponse
|
import me.kavishdevar.librepods.bluetooth.att.types.parseTransparencySettingsResponse
|
||||||
import me.kavishdevar.librepods.bluetooth.att.types.sendTransparencySettings
|
import me.kavishdevar.librepods.bluetooth.att.types.sendTransparencySettings
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
import me.kavishdevar.librepods.presentation.icons.LocalIcons
|
import me.kavishdevar.librepods.presentation.icons.LocalIcons
|
||||||
@@ -73,15 +68,15 @@ import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
|||||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||||
import me.kavishdevar.librepods.presentation.theme.sectionHeader
|
import me.kavishdevar.librepods.presentation.theme.sectionHeader
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
private const val TAG = "TransparencySettings"
|
private const val TAG = "TransparencySettings"
|
||||||
|
|
||||||
@SuppressLint("DefaultLocale")
|
@SuppressLint("DefaultLocale")
|
||||||
@ExperimentalHazeMaterialsApi
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TransparencySettingsScreen(viewModel: AppleViewModel) {
|
fun TransparencySettingsScreen(
|
||||||
|
viewModel: AppleViewModel,
|
||||||
|
navigateBack: (() -> Unit)?,
|
||||||
|
) {
|
||||||
val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f
|
val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val verticalScrollState = rememberScrollState()
|
val verticalScrollState = rememberScrollState()
|
||||||
@@ -94,33 +89,33 @@ fun TransparencySettingsScreen(viewModel: AppleViewModel) {
|
|||||||
|
|
||||||
val state = uiState.state
|
val state = uiState.state
|
||||||
|
|
||||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
StyledScaffold(
|
||||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
title = stringResource(R.string.customize_transparency_mode),
|
||||||
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
navigateBack = navigateBack
|
||||||
|
) { topPadding, bottomPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
.verticalScroll(verticalScrollState)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
val backgroundColor = MaterialTheme.colorScheme.surfaceContainer
|
||||||
|
|
||||||
Column(
|
val enabled = rememberSaveable { mutableStateOf(false) }
|
||||||
modifier = Modifier
|
val amplificationSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
.fillMaxSize()
|
val balanceSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
val toneSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
.verticalScroll(verticalScrollState)
|
val ambientNoiseReductionSliderValue = rememberSaveable { mutableFloatStateOf(0.0f) }
|
||||||
.padding(horizontal = 16.dp),
|
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
val eq = rememberSaveable(
|
||||||
) {
|
saver = Saver(
|
||||||
Spacer(modifier = Modifier.height(topPadding))
|
save = { it.value.toList() },
|
||||||
val backgroundColor = MaterialTheme.colorScheme.surfaceContainer
|
restore = { mutableStateOf(it.toFloatArray()) }
|
||||||
|
)
|
||||||
val enabled = rememberSaveable { mutableStateOf(false) }
|
) { mutableStateOf(FloatArray(8)) }
|
||||||
val amplificationSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
|
||||||
val balanceSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
|
||||||
val toneSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
|
||||||
val ambientNoiseReductionSliderValue = rememberSaveable { mutableFloatStateOf(0.0f) }
|
|
||||||
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
|
|
||||||
val eq = rememberSaveable(
|
|
||||||
saver = Saver(
|
|
||||||
save = { it.value.toList() },
|
|
||||||
restore = { mutableStateOf(it.toFloatArray()) }
|
|
||||||
)
|
|
||||||
) { mutableStateOf(FloatArray(8)) }
|
|
||||||
|
|
||||||
// val phoneMediaEQ = rememberSaveable(
|
// val phoneMediaEQ = rememberSaveable(
|
||||||
// saver = Saver(
|
// saver = Saver(
|
||||||
@@ -129,16 +124,44 @@ fun TransparencySettingsScreen(viewModel: AppleViewModel) {
|
|||||||
// )
|
// )
|
||||||
// ) { mutableStateOf(FloatArray(8) { 0.5f }) }
|
// ) { mutableStateOf(FloatArray(8) { 0.5f }) }
|
||||||
|
|
||||||
val initialized = rememberSaveable { mutableStateOf(false) }
|
val initialized = rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
val transparencySettings = remember {
|
val transparencySettings = remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
TransparencySettings(
|
TransparencySettings(
|
||||||
|
enabled = enabled.value,
|
||||||
|
leftEQ = eq.value,
|
||||||
|
rightEQ = eq.value,
|
||||||
|
leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2,
|
||||||
|
rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2,
|
||||||
|
leftTone = toneSliderValue.floatValue,
|
||||||
|
rightTone = toneSliderValue.floatValue,
|
||||||
|
leftConversationBoost = conversationBoostEnabled.value,
|
||||||
|
rightConversationBoost = conversationBoostEnabled.value,
|
||||||
|
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
||||||
|
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
||||||
|
netAmplification = amplificationSliderValue.floatValue,
|
||||||
|
balance = balanceSliderValue.floatValue
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(
|
||||||
|
enabled.value,
|
||||||
|
amplificationSliderValue.floatValue,
|
||||||
|
balanceSliderValue.floatValue,
|
||||||
|
toneSliderValue.floatValue,
|
||||||
|
conversationBoostEnabled.value,
|
||||||
|
ambientNoiseReductionSliderValue.floatValue,
|
||||||
|
eq.value
|
||||||
|
) {
|
||||||
|
if (!initialized.value) return@LaunchedEffect
|
||||||
|
transparencySettings.value = TransparencySettings(
|
||||||
enabled = enabled.value,
|
enabled = enabled.value,
|
||||||
leftEQ = eq.value,
|
leftEQ = eq.value,
|
||||||
rightEQ = eq.value,
|
rightEQ = eq.value,
|
||||||
leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2,
|
leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
|
||||||
rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2,
|
rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f,
|
||||||
leftTone = toneSliderValue.floatValue,
|
leftTone = toneSliderValue.floatValue,
|
||||||
rightTone = toneSliderValue.floatValue,
|
rightTone = toneSliderValue.floatValue,
|
||||||
leftConversationBoost = conversationBoostEnabled.value,
|
leftConversationBoost = conversationBoostEnabled.value,
|
||||||
@@ -148,214 +171,187 @@ fun TransparencySettingsScreen(viewModel: AppleViewModel) {
|
|||||||
netAmplification = amplificationSliderValue.floatValue,
|
netAmplification = amplificationSliderValue.floatValue,
|
||||||
balance = balanceSliderValue.floatValue
|
balance = balanceSliderValue.floatValue
|
||||||
)
|
)
|
||||||
)
|
Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}")
|
||||||
}
|
sendTransparencySettings(viewModel::writeATTCharacteristic, transparencySettings.value)
|
||||||
|
|
||||||
LaunchedEffect(
|
|
||||||
enabled.value,
|
|
||||||
amplificationSliderValue.floatValue,
|
|
||||||
balanceSliderValue.floatValue,
|
|
||||||
toneSliderValue.floatValue,
|
|
||||||
conversationBoostEnabled.value,
|
|
||||||
ambientNoiseReductionSliderValue.floatValue,
|
|
||||||
eq.value
|
|
||||||
) {
|
|
||||||
if (!initialized.value) return@LaunchedEffect
|
|
||||||
transparencySettings.value = TransparencySettings(
|
|
||||||
enabled = enabled.value,
|
|
||||||
leftEQ = eq.value,
|
|
||||||
rightEQ = eq.value,
|
|
||||||
leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
|
|
||||||
rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f,
|
|
||||||
leftTone = toneSliderValue.floatValue,
|
|
||||||
rightTone = toneSliderValue.floatValue,
|
|
||||||
leftConversationBoost = conversationBoostEnabled.value,
|
|
||||||
rightConversationBoost = conversationBoostEnabled.value,
|
|
||||||
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
|
||||||
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
|
||||||
netAmplification = amplificationSliderValue.floatValue,
|
|
||||||
balance = balanceSliderValue.floatValue
|
|
||||||
)
|
|
||||||
Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}")
|
|
||||||
sendTransparencySettings(viewModel::writeATTCharacteristic, transparencySettings.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(state.transparencyData) {
|
|
||||||
val parsedSettings = parseTransparencySettingsResponse(data = state.transparencyData) ?: return@LaunchedEffect
|
|
||||||
Log.d(TAG, "Initial transparency settings: $parsedSettings")
|
|
||||||
enabled.value = parsedSettings.enabled
|
|
||||||
amplificationSliderValue.floatValue = parsedSettings.netAmplification
|
|
||||||
balanceSliderValue.floatValue = parsedSettings.balance
|
|
||||||
toneSliderValue.floatValue = parsedSettings.leftTone
|
|
||||||
ambientNoiseReductionSliderValue.floatValue =
|
|
||||||
parsedSettings.leftAmbientNoiseReduction
|
|
||||||
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
|
||||||
if (!eq.value.contentEquals(parsedSettings.leftEQ)) {
|
|
||||||
eq.value = parsedSettings.leftEQ.copyOf()
|
|
||||||
}
|
}
|
||||||
initialized.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uiState.vendorIdHook) {
|
LaunchedEffect(state.transparencyData) {
|
||||||
StyledToggle(
|
val parsedSettings = parseTransparencySettingsResponse(data = state.transparencyData) ?: return@LaunchedEffect
|
||||||
label = stringResource(R.string.transparency_mode),
|
Log.d(TAG, "Initial transparency settings: $parsedSettings")
|
||||||
checked = enabled.value,
|
enabled.value = parsedSettings.enabled
|
||||||
description = stringResource(R.string.customize_transparency_mode_description),
|
amplificationSliderValue.floatValue = parsedSettings.netAmplification
|
||||||
onCheckedChange = { enabled.value = it }
|
balanceSliderValue.floatValue = parsedSettings.balance
|
||||||
)
|
toneSliderValue.floatValue = parsedSettings.leftTone
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
ambientNoiseReductionSliderValue.floatValue =
|
||||||
StyledSlider(
|
parsedSettings.leftAmbientNoiseReduction
|
||||||
label = stringResource(R.string.amplification),
|
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
||||||
valueRange = -1f..1f,
|
if (!eq.value.contentEquals(parsedSettings.leftEQ)) {
|
||||||
value = amplificationSliderValue.floatValue,
|
eq.value = parsedSettings.leftEQ.copyOf()
|
||||||
onValueChange = {
|
}
|
||||||
amplificationSliderValue.floatValue = it
|
initialized.value = true
|
||||||
},
|
}
|
||||||
startImageVector = LocalIcons.current.SpeakerMin,
|
|
||||||
endImageVector = LocalIcons.current.SpeakerMax,
|
|
||||||
independent = true
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledSlider(
|
if (uiState.vendorIdHook) {
|
||||||
label = stringResource(R.string.balance),
|
StyledToggle(
|
||||||
valueRange = -1f..1f,
|
label = stringResource(R.string.transparency_mode),
|
||||||
value = balanceSliderValue.floatValue,
|
checked = enabled.value,
|
||||||
onValueChange = {
|
description = stringResource(R.string.customize_transparency_mode_description),
|
||||||
balanceSliderValue.floatValue = it
|
onCheckedChange = { enabled.value = it }
|
||||||
},
|
)
|
||||||
snapPoints = listOf(-1f, 0f, 1f),
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
startLabel = stringResource(R.string.left),
|
StyledSlider(
|
||||||
endLabel = stringResource(R.string.right),
|
label = stringResource(R.string.amplification),
|
||||||
independent = true,
|
valueRange = -1f..1f,
|
||||||
)
|
value = amplificationSliderValue.floatValue,
|
||||||
|
onValueChange = {
|
||||||
|
amplificationSliderValue.floatValue = it
|
||||||
|
},
|
||||||
|
startImageVector = LocalIcons.current.SpeakerMin,
|
||||||
|
endImageVector = LocalIcons.current.SpeakerMax,
|
||||||
|
independent = true
|
||||||
|
)
|
||||||
|
|
||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.tone),
|
label = stringResource(R.string.balance),
|
||||||
valueRange = -1f..1f,
|
valueRange = -1f..1f,
|
||||||
value = toneSliderValue.floatValue,
|
value = balanceSliderValue.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
toneSliderValue.floatValue = it
|
balanceSliderValue.floatValue = it
|
||||||
},
|
},
|
||||||
startLabel = stringResource(R.string.darker),
|
snapPoints = listOf(-1f, 0f, 1f),
|
||||||
endLabel = stringResource(R.string.brighter),
|
startLabel = stringResource(R.string.left),
|
||||||
independent = true,
|
endLabel = stringResource(R.string.right),
|
||||||
)
|
independent = true,
|
||||||
|
)
|
||||||
|
|
||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.ambient_noise_reduction),
|
label = stringResource(R.string.tone),
|
||||||
valueRange = 0f..1f,
|
valueRange = -1f..1f,
|
||||||
value = ambientNoiseReductionSliderValue.floatValue,
|
value = toneSliderValue.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
ambientNoiseReductionSliderValue.floatValue = it
|
toneSliderValue.floatValue = it
|
||||||
},
|
},
|
||||||
startLabel = stringResource(R.string.less),
|
startLabel = stringResource(R.string.darker),
|
||||||
endLabel = stringResource(R.string.more),
|
endLabel = stringResource(R.string.brighter),
|
||||||
independent = true,
|
independent = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
StyledToggle(
|
StyledSlider(
|
||||||
label = stringResource(R.string.conversation_boost),
|
label = stringResource(R.string.ambient_noise_reduction),
|
||||||
checked = conversationBoostEnabled.value,
|
valueRange = 0f..1f,
|
||||||
description = stringResource(R.string.conversation_boost_description),
|
value = ambientNoiseReductionSliderValue.floatValue,
|
||||||
onCheckedChange = { conversationBoostEnabled.value = it }
|
onValueChange = {
|
||||||
)
|
ambientNoiseReductionSliderValue.floatValue = it
|
||||||
|
},
|
||||||
|
startLabel = stringResource(R.string.less),
|
||||||
|
endLabel = stringResource(R.string.more),
|
||||||
|
independent = true,
|
||||||
|
)
|
||||||
|
|
||||||
Text(
|
StyledToggle(
|
||||||
text = stringResource(R.string.equalizer),
|
label = stringResource(R.string.conversation_boost),
|
||||||
style = MaterialTheme.typography.labelSmallEmphasized,
|
checked = conversationBoostEnabled.value,
|
||||||
color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader,
|
description = stringResource(R.string.conversation_boost_description),
|
||||||
modifier = Modifier.padding(16.dp, bottom = 4.dp)
|
onCheckedChange = { conversationBoostEnabled.value = it }
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(
|
Text(
|
||||||
modifier = Modifier
|
text = stringResource(R.string.equalizer),
|
||||||
.fillMaxWidth()
|
style = MaterialTheme.typography.labelSmallEmphasized,
|
||||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
color = if (LocalDesignSystem.current == DesignSystem.Material) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader,
|
||||||
.padding(16.dp),
|
modifier = Modifier.padding(16.dp, bottom = 4.dp)
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
)
|
||||||
verticalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
for (i in 0 until 8) {
|
|
||||||
val eqValue = remember(eq.value[i]) { mutableFloatStateOf(eq.value[i]) }
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(38.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = String.format("%.2f", eqValue.floatValue),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = textColor,
|
|
||||||
modifier = Modifier.padding(bottom = 4.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Slider(
|
Column(
|
||||||
value = eqValue.floatValue,
|
modifier = Modifier
|
||||||
onValueChange = { newVal ->
|
.fillMaxWidth()
|
||||||
eqValue.floatValue = newVal
|
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||||
val newEQ = eq.value.copyOf()
|
.padding(16.dp),
|
||||||
newEQ[i] = eqValue.floatValue
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
eq.value = newEQ
|
verticalArrangement = Arrangement.SpaceBetween
|
||||||
},
|
) {
|
||||||
valueRange = 0f..100f,
|
for (i in 0 until 8) {
|
||||||
|
val eqValue = remember(eq.value[i]) { mutableFloatStateOf(eq.value[i]) }
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(0.9f)
|
.fillMaxWidth()
|
||||||
.height(36.dp),
|
.height(38.dp)
|
||||||
colors = SliderDefaults.colors(
|
) {
|
||||||
thumbColor = thumbColor,
|
Text(
|
||||||
activeTrackColor = activeTrackColor,
|
text = String.format("%.2f", eqValue.floatValue),
|
||||||
inactiveTrackColor = trackColor
|
fontSize = 12.sp,
|
||||||
),
|
color = textColor,
|
||||||
thumb = {
|
modifier = Modifier.padding(bottom = 4.dp)
|
||||||
Box(
|
)
|
||||||
modifier = Modifier
|
|
||||||
.size(24.dp)
|
Slider(
|
||||||
.shadow(4.dp, CircleShape)
|
value = eqValue.floatValue,
|
||||||
.background(thumbColor, CircleShape)
|
onValueChange = { newVal ->
|
||||||
)
|
eqValue.floatValue = newVal
|
||||||
},
|
val newEQ = eq.value.copyOf()
|
||||||
track = {
|
newEQ[i] = eqValue.floatValue
|
||||||
Box(
|
eq.value = newEQ
|
||||||
modifier = Modifier
|
},
|
||||||
.fillMaxWidth()
|
valueRange = 0f..100f,
|
||||||
.height(12.dp),
|
modifier = Modifier
|
||||||
contentAlignment = Alignment.CenterStart
|
.fillMaxWidth(0.9f)
|
||||||
)
|
.height(36.dp),
|
||||||
{
|
colors = SliderDefaults.colors(
|
||||||
|
thumbColor = thumbColor,
|
||||||
|
activeTrackColor = activeTrackColor,
|
||||||
|
inactiveTrackColor = trackColor
|
||||||
|
),
|
||||||
|
thumb = {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.shadow(4.dp, CircleShape)
|
||||||
|
.background(thumbColor, CircleShape)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
track = {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(4.dp)
|
.height(12.dp),
|
||||||
.background(trackColor, RoundedCornerShape(4.dp))
|
contentAlignment = Alignment.CenterStart
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth(eqValue.floatValue / 100f)
|
|
||||||
.height(4.dp)
|
|
||||||
.background(
|
|
||||||
activeTrackColor,
|
|
||||||
RoundedCornerShape(4.dp)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
{
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(4.dp)
|
||||||
|
.background(trackColor, RoundedCornerShape(4.dp))
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(eqValue.floatValue / 100f)
|
||||||
|
.height(4.dp)
|
||||||
|
.background(
|
||||||
|
activeTrackColor,
|
||||||
|
RoundedCornerShape(4.dp)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.band_label, i + 1),
|
text = stringResource(R.string.band_label, i + 1),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = textColor,
|
color = textColor,
|
||||||
modifier = Modifier.padding(top = 4.dp)
|
modifier = Modifier.padding(top = 4.dp)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(bottomPadding))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+164
-172
@@ -25,14 +25,9 @@ import androidx.compose.foundation.layout.Box
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBars
|
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
@@ -56,7 +51,6 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
@@ -64,90 +58,132 @@ import me.kavishdevar.librepods.bluetooth.att.ATTHandle
|
|||||||
import me.kavishdevar.librepods.bluetooth.att.types.HearingAidSettings
|
import me.kavishdevar.librepods.bluetooth.att.types.HearingAidSettings
|
||||||
import me.kavishdevar.librepods.bluetooth.att.types.parseHearingAidSettingsResponse
|
import me.kavishdevar.librepods.bluetooth.att.types.parseHearingAidSettingsResponse
|
||||||
import me.kavishdevar.librepods.bluetooth.att.types.sendHearingAidSettings
|
import me.kavishdevar.librepods.bluetooth.att.types.sendHearingAidSettings
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||||
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState
|
import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
||||||
|
|
||||||
private const val TAG = "UpdateHearingTestScreen"
|
private const val TAG = "UpdateHearingTestScreen"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UpdateHearingTestRoute(viewModel: AppleViewModel) {
|
fun UpdateHearingTestRoute(
|
||||||
|
viewModel: AppleViewModel,
|
||||||
|
navigateBack: (() -> Unit)?
|
||||||
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
UpdateHearingTestScreen(
|
||||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
uiState = uiState,
|
||||||
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
navigateBack = navigateBack,
|
||||||
|
setATTCharacteristicValue = viewModel::writeATTCharacteristic
|
||||||
Box(
|
)
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
|
||||||
) {
|
|
||||||
UpdateHearingTestScreen(
|
|
||||||
uiState = uiState,
|
|
||||||
topPadding = topPadding,
|
|
||||||
bottomPadding = bottomPadding,
|
|
||||||
setATTCharacteristicValue = viewModel::writeATTCharacteristic
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UpdateHearingTestScreen(
|
fun UpdateHearingTestScreen(
|
||||||
uiState: AppleUiState,
|
uiState: AppleUiState,
|
||||||
topPadding: Dp = 16.dp,
|
navigateBack: (() -> Unit)?,
|
||||||
bottomPadding: Dp = 16.dp,
|
|
||||||
setATTCharacteristicValue: (ATTHandle, ByteArray) -> Unit
|
setATTCharacteristicValue: (ATTHandle, ByteArray) -> Unit
|
||||||
) {
|
) {
|
||||||
val state = uiState.state
|
val state = uiState.state
|
||||||
|
|
||||||
val verticalScrollState = rememberScrollState()
|
val verticalScrollState = rememberScrollState()
|
||||||
|
|
||||||
Column(
|
StyledScaffold(
|
||||||
modifier = Modifier
|
title = stringResource(R.string.update_hearing_test),
|
||||||
.verticalScroll(verticalScrollState)
|
navigateBack = navigateBack
|
||||||
.padding(horizontal = 16.dp),
|
) { topPadding, bottomPadding ->
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
modifier = Modifier
|
||||||
) {
|
.verticalScroll(verticalScrollState)
|
||||||
Spacer(modifier = Modifier.height(topPadding))
|
.padding(horizontal = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
Text(
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
text = stringResource(R.string.hearing_test_value_instruction),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
val tone = rememberSaveable { mutableFloatStateOf(0.5f) }
|
|
||||||
val ambientNoiseReduction = rememberSaveable { mutableFloatStateOf(0.0f) }
|
|
||||||
val ownVoiceAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
|
|
||||||
val leftAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
|
|
||||||
val rightAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
|
|
||||||
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
|
|
||||||
val leftEQ = rememberSaveable(
|
|
||||||
saver = Saver(
|
|
||||||
save = { it.value.toList() },
|
|
||||||
restore = { mutableStateOf(it.toFloatArray()) }
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
mutableStateOf(FloatArray(8))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
}
|
|
||||||
val rightEQ = rememberSaveable(
|
Text(
|
||||||
saver = Saver(
|
text = stringResource(R.string.hearing_test_value_instruction),
|
||||||
save = { it.value.toList() },
|
style = MaterialTheme.typography.labelMedium,
|
||||||
restore = { mutableStateOf(it.toFloatArray()) }
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
) {
|
val tone = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
mutableStateOf(FloatArray(8))
|
val ambientNoiseReduction = rememberSaveable { mutableFloatStateOf(0.0f) }
|
||||||
}
|
val ownVoiceAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
|
val leftAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
|
val rightAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
|
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
|
||||||
|
val leftEQ = rememberSaveable(
|
||||||
|
saver = Saver(
|
||||||
|
save = { it.value.toList() },
|
||||||
|
restore = { mutableStateOf(it.toFloatArray()) }
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
mutableStateOf(FloatArray(8))
|
||||||
|
}
|
||||||
|
val rightEQ = rememberSaveable(
|
||||||
|
saver = Saver(
|
||||||
|
save = { it.value.toList() },
|
||||||
|
restore = { mutableStateOf(it.toFloatArray()) }
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
mutableStateOf(FloatArray(8))
|
||||||
|
}
|
||||||
|
|
||||||
val debounceJob = remember { mutableStateOf<Job?>(null) }
|
val debounceJob = remember { mutableStateOf<Job?>(null) }
|
||||||
val initialized = rememberSaveable { mutableStateOf(false) }
|
val initialized = rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
val hearingAidSettings = remember {
|
val hearingAidSettings = remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
HearingAidSettings(
|
HearingAidSettings(
|
||||||
|
leftEQ = leftEQ.value,
|
||||||
|
rightEQ = rightEQ.value,
|
||||||
|
leftAmplification = leftAmplification.floatValue,
|
||||||
|
rightAmplification = rightAmplification.floatValue,
|
||||||
|
leftTone = tone.floatValue,
|
||||||
|
rightTone = tone.floatValue,
|
||||||
|
leftConversationBoost = conversationBoostEnabled.value,
|
||||||
|
rightConversationBoost = conversationBoostEnabled.value,
|
||||||
|
leftAmbientNoiseReduction = ambientNoiseReduction.floatValue,
|
||||||
|
rightAmbientNoiseReduction = ambientNoiseReduction.floatValue,
|
||||||
|
netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2,
|
||||||
|
balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
|
||||||
|
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(state.hearingAidData) {
|
||||||
|
val parsed = parseHearingAidSettingsResponse(state.hearingAidData)
|
||||||
|
if (parsed != null) {
|
||||||
|
leftEQ.value = parsed.leftEQ.copyOf()
|
||||||
|
rightEQ.value = parsed.rightEQ.copyOf()
|
||||||
|
conversationBoostEnabled.value = parsed.leftConversationBoost
|
||||||
|
tone.floatValue = parsed.leftTone
|
||||||
|
ambientNoiseReduction.floatValue = parsed.leftAmbientNoiseReduction
|
||||||
|
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
|
||||||
|
leftAmplification.floatValue = parsed.leftAmplification
|
||||||
|
rightAmplification.floatValue = parsed.rightAmplification
|
||||||
|
initialized.value = true
|
||||||
|
Log.d(TAG, "Updated hearing aid settings from notification")
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Failed to parse hearing aid settings from notification")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(
|
||||||
|
leftEQ.value,
|
||||||
|
rightEQ.value,
|
||||||
|
conversationBoostEnabled.value,
|
||||||
|
leftAmplification.floatValue,
|
||||||
|
rightAmplification.floatValue,
|
||||||
|
tone.floatValue,
|
||||||
|
ambientNoiseReduction.floatValue,
|
||||||
|
ownVoiceAmplification.floatValue
|
||||||
|
) {
|
||||||
|
if (!initialized.value) return@LaunchedEffect
|
||||||
|
hearingAidSettings.value = HearingAidSettings(
|
||||||
leftEQ = leftEQ.value,
|
leftEQ = leftEQ.value,
|
||||||
rightEQ = rightEQ.value,
|
rightEQ = rightEQ.value,
|
||||||
leftAmplification = leftAmplification.floatValue,
|
leftAmplification = leftAmplification.floatValue,
|
||||||
@@ -162,125 +198,79 @@ fun UpdateHearingTestScreen(
|
|||||||
balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
|
balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
|
||||||
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
||||||
)
|
)
|
||||||
)
|
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
||||||
}
|
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, setATTCharacteristicValue)
|
||||||
|
|
||||||
LaunchedEffect(state.hearingAidData) {
|
|
||||||
val parsed = parseHearingAidSettingsResponse(state.hearingAidData)
|
|
||||||
if (parsed != null) {
|
|
||||||
leftEQ.value = parsed.leftEQ.copyOf()
|
|
||||||
rightEQ.value = parsed.rightEQ.copyOf()
|
|
||||||
conversationBoostEnabled.value = parsed.leftConversationBoost
|
|
||||||
tone.floatValue = parsed.leftTone
|
|
||||||
ambientNoiseReduction.floatValue = parsed.leftAmbientNoiseReduction
|
|
||||||
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
|
|
||||||
leftAmplification.floatValue = parsed.leftAmplification
|
|
||||||
rightAmplification.floatValue = parsed.rightAmplification
|
|
||||||
initialized.value = true
|
|
||||||
Log.d(TAG, "Updated hearing aid settings from notification")
|
|
||||||
} else {
|
|
||||||
Log.w(TAG, "Failed to parse hearing aid settings from notification")
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(
|
val frequencies =
|
||||||
leftEQ.value,
|
listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz")
|
||||||
rightEQ.value,
|
|
||||||
conversationBoostEnabled.value,
|
|
||||||
leftAmplification.floatValue,
|
|
||||||
rightAmplification.floatValue,
|
|
||||||
tone.floatValue,
|
|
||||||
ambientNoiseReduction.floatValue,
|
|
||||||
ownVoiceAmplification.floatValue
|
|
||||||
) {
|
|
||||||
if (!initialized.value) return@LaunchedEffect
|
|
||||||
hearingAidSettings.value = HearingAidSettings(
|
|
||||||
leftEQ = leftEQ.value,
|
|
||||||
rightEQ = rightEQ.value,
|
|
||||||
leftAmplification = leftAmplification.floatValue,
|
|
||||||
rightAmplification = rightAmplification.floatValue,
|
|
||||||
leftTone = tone.floatValue,
|
|
||||||
rightTone = tone.floatValue,
|
|
||||||
leftConversationBoost = conversationBoostEnabled.value,
|
|
||||||
rightConversationBoost = conversationBoostEnabled.value,
|
|
||||||
leftAmbientNoiseReduction = ambientNoiseReduction.floatValue,
|
|
||||||
rightAmbientNoiseReduction = ambientNoiseReduction.floatValue,
|
|
||||||
netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2,
|
|
||||||
balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
|
|
||||||
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
|
||||||
)
|
|
||||||
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
|
||||||
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, setATTCharacteristicValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
val frequencies =
|
|
||||||
listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz")
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.width(60.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.left),
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
style = MaterialTheme.typography.labelMediumEmphasized
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.right),
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
style = MaterialTheme.typography.labelMediumEmphasized
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
frequencies.forEachIndexed { index, freq ->
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
|
Spacer(modifier = Modifier.width(60.dp))
|
||||||
Text(
|
Text(
|
||||||
text = freq,
|
text = stringResource(R.string.left),
|
||||||
modifier = Modifier
|
modifier = Modifier.weight(1f),
|
||||||
.width(60.dp)
|
textAlign = TextAlign.Center,
|
||||||
.align(Alignment.CenterVertically),
|
style = MaterialTheme.typography.labelMediumEmphasized
|
||||||
textAlign = TextAlign.End,
|
|
||||||
style = MaterialTheme.typography.labelSmall
|
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
Text(
|
||||||
value = leftEQ.value[index].toString(),
|
text = stringResource(R.string.right),
|
||||||
onValueChange = { newValue ->
|
modifier = Modifier.weight(1f),
|
||||||
val parsed = newValue.toFloatOrNull()
|
textAlign = TextAlign.Center,
|
||||||
if (parsed != null) {
|
style = MaterialTheme.typography.labelMediumEmphasized
|
||||||
val newArray = leftEQ.value.copyOf()
|
|
||||||
newArray[index] = parsed
|
|
||||||
leftEQ.value = newArray
|
|
||||||
Log.d(TAG, "Left EQ updated at index $index to $parsed")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
|
||||||
textStyle = MaterialTheme.typography.labelSmall,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = rightEQ.value[index].toString(),
|
|
||||||
onValueChange = { newValue ->
|
|
||||||
val parsed = newValue.toFloatOrNull()
|
|
||||||
if (parsed != null) {
|
|
||||||
val newArray = rightEQ.value.copyOf()
|
|
||||||
newArray[index] = parsed
|
|
||||||
rightEQ.value = newArray
|
|
||||||
Log.d(TAG, "Right EQ updated at index $index to $parsed")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
|
||||||
textStyle = MaterialTheme.typography.labelSmall,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
frequencies.forEachIndexed { index, freq ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = freq,
|
||||||
|
modifier = Modifier
|
||||||
|
.width(60.dp)
|
||||||
|
.align(Alignment.CenterVertically),
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
style = MaterialTheme.typography.labelSmall
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = leftEQ.value[index].toString(),
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
val parsed = newValue.toFloatOrNull()
|
||||||
|
if (parsed != null) {
|
||||||
|
val newArray = leftEQ.value.copyOf()
|
||||||
|
newArray[index] = parsed
|
||||||
|
leftEQ.value = newArray
|
||||||
|
Log.d(TAG, "Left EQ updated at index $index to $parsed")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
textStyle = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = rightEQ.value[index].toString(),
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
val parsed = newValue.toFloatOrNull()
|
||||||
|
if (parsed != null) {
|
||||||
|
val newArray = rightEQ.value.copyOf()
|
||||||
|
newArray[index] = parsed
|
||||||
|
rightEQ.value = newArray
|
||||||
|
Log.d(TAG, "Right EQ updated at index $index to $parsed")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||||
|
textStyle = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(bottomPadding))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,6 +285,7 @@ fun UpdateHearingTestScreenPreviewApple() {
|
|||||||
) {
|
) {
|
||||||
UpdateHearingTestScreen(
|
UpdateHearingTestScreen(
|
||||||
uiState = AppleUiState(),
|
uiState = AppleUiState(),
|
||||||
|
navigateBack = null,
|
||||||
setATTCharacteristicValue = { _, _ -> }
|
setATTCharacteristicValue = { _, _ -> }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -314,6 +305,7 @@ fun UpdateHearingTestScreenPreviewMaterial() {
|
|||||||
) {
|
) {
|
||||||
UpdateHearingTestScreen(
|
UpdateHearingTestScreen(
|
||||||
uiState = AppleUiState(),
|
uiState = AppleUiState(),
|
||||||
|
navigateBack = null,
|
||||||
setATTCharacteristicValue = { _, _ -> }
|
setATTCharacteristicValue = { _, _ -> }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-37
@@ -18,17 +18,11 @@
|
|||||||
|
|
||||||
package me.kavishdevar.librepods.presentation.screens.apple
|
package me.kavishdevar.librepods.presentation.screens.apple
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.asPaddingValues
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBars
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -38,46 +32,48 @@ import androidx.compose.ui.unit.dp
|
|||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledList
|
import me.kavishdevar.librepods.presentation.components.StyledList
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
||||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VersionScreen(viewModel: AppleViewModel) {
|
fun VersionScreen(
|
||||||
|
viewModel: AppleViewModel,
|
||||||
|
navigateBack: (() -> Unit)?
|
||||||
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
val metadata = uiState.metadata
|
val metadata = uiState.metadata
|
||||||
|
|
||||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
StyledScaffold(
|
||||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
title = stringResource(R.string.version),
|
||||||
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
navigateBack = navigateBack
|
||||||
|
) { topPadding, bottomPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
StyledList(title = stringResource(R.string.version)) {
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.version) + " 1",
|
||||||
|
supportingText = metadata.version1,
|
||||||
|
enabled = false
|
||||||
|
)
|
||||||
|
|
||||||
Column(
|
StyledListItem(
|
||||||
modifier = Modifier
|
contentText = stringResource(R.string.version) + " 2",
|
||||||
.fillMaxSize()
|
supportingText = metadata.version2,
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
enabled = false
|
||||||
.padding(horizontal = 16.dp)
|
)
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(topPadding))
|
|
||||||
StyledList(title = stringResource(R.string.version)) {
|
|
||||||
StyledListItem(
|
|
||||||
contentText = stringResource(R.string.version) + " 1",
|
|
||||||
supportingText = metadata.version1,
|
|
||||||
enabled = false
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledListItem(
|
StyledListItem(
|
||||||
contentText = stringResource(R.string.version) + " 2",
|
contentText = stringResource(R.string.version) + " 3",
|
||||||
supportingText = metadata.version2,
|
supportingText = metadata.version3,
|
||||||
enabled = false
|
enabled = false
|
||||||
)
|
)
|
||||||
|
}
|
||||||
StyledListItem(
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
contentText = stringResource(R.string.version) + " 3",
|
|
||||||
supportingText = metadata.version3,
|
|
||||||
enabled = false
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(bottomPadding))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -93,6 +93,7 @@ fun OnboardingScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
HorizontalUncontainedCarousel(
|
HorizontalUncontainedCarousel(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|||||||
+74
-26
@@ -37,8 +37,11 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import androidx.health.connect.client.permission.HealthPermission
|
||||||
|
import androidx.health.connect.client.records.HeartRateRecord
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
@@ -46,9 +49,10 @@ import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
|||||||
import com.google.accompanist.permissions.isGranted
|
import com.google.accompanist.permissions.isGranted
|
||||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||||
import com.google.accompanist.permissions.rememberPermissionState
|
import com.google.accompanist.permissions.rememberPermissionState
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledList
|
import me.kavishdevar.librepods.presentation.components.StyledList
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
|
||||||
import me.kavishdevar.librepods.presentation.icons.MaterialIcons
|
import me.kavishdevar.librepods.presentation.icons.MaterialIcons
|
||||||
|
|
||||||
@OptIn(ExperimentalPermissionsApi::class)
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
@@ -63,11 +67,8 @@ fun PermissionsPage(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val canDrawOverlays = remember { mutableStateOf(Settings.canDrawOverlays(context)) }
|
val canDrawOverlays = remember { mutableStateOf(Settings.canDrawOverlays(context)) }
|
||||||
|
|
||||||
val phonePermissionState = rememberMultiplePermissionsState(
|
val healthPermissions = rememberPermissionState(
|
||||||
listOf(
|
HealthPermission.getWritePermission(HeartRateRecord::class)
|
||||||
"android.permission.READ_PHONE_STATE",
|
|
||||||
"android.permission.ANSWER_PHONE_CALLS"
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
if (grantingAll) {
|
if (grantingAll) {
|
||||||
if (!canDrawOverlays.value) {
|
if (!canDrawOverlays.value) {
|
||||||
@@ -80,10 +81,23 @@ fun PermissionsPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val phonePermissionState = rememberMultiplePermissionsState(
|
||||||
|
listOf(
|
||||||
|
"android.permission.READ_PHONE_STATE",
|
||||||
|
"android.permission.ANSWER_PHONE_CALLS"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (grantingAll) {
|
||||||
|
if (!healthPermissions.status.isGranted) healthPermissions.launchPermissionRequest()
|
||||||
|
else if (!canDrawOverlays.value) canDrawOverlays.value = Settings.canDrawOverlays(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
val notificationPermissionState = rememberPermissionState("android.permission.POST_NOTIFICATIONS") {
|
val notificationPermissionState = rememberPermissionState("android.permission.POST_NOTIFICATIONS") {
|
||||||
if (grantingAll) {
|
if (grantingAll) {
|
||||||
if (!phonePermissionState.allPermissionsGranted) phonePermissionState.launchMultiplePermissionRequest()
|
if (!phonePermissionState.allPermissionsGranted) phonePermissionState.launchMultiplePermissionRequest()
|
||||||
|
else if (!healthPermissions.status.isGranted) healthPermissions.launchPermissionRequest()
|
||||||
else if (!canDrawOverlays.value) canDrawOverlays.value = Settings.canDrawOverlays(context)
|
else if (!canDrawOverlays.value) canDrawOverlays.value = Settings.canDrawOverlays(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -100,6 +114,7 @@ fun PermissionsPage(
|
|||||||
if (grantingAll) {
|
if (grantingAll) {
|
||||||
if (!notificationPermissionState.status.isGranted) notificationPermissionState.launchPermissionRequest()
|
if (!notificationPermissionState.status.isGranted) notificationPermissionState.launchPermissionRequest()
|
||||||
else if (!phonePermissionState.allPermissionsGranted) phonePermissionState.launchMultiplePermissionRequest()
|
else if (!phonePermissionState.allPermissionsGranted) phonePermissionState.launchMultiplePermissionRequest()
|
||||||
|
else if (!healthPermissions.status.isGranted) healthPermissions.launchPermissionRequest()
|
||||||
else if (!canDrawOverlays.value) canDrawOverlays.value = Settings.canDrawOverlays(context)
|
else if (!canDrawOverlays.value) canDrawOverlays.value = Settings.canDrawOverlays(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,21 +150,21 @@ fun PermissionsPage(
|
|||||||
.verticalScroll(scrollState),
|
.verticalScroll(scrollState),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
StyledList(title = "Required Permissions") {
|
StyledList(title = stringResource(R.string.required_permissions)) {
|
||||||
val animatedBluetoothIconColor by animateColorAsState(if (bluetoothPermissionsState.allPermissionsGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface)
|
val animatedBluetoothIconColor by animateColorAsState(if (bluetoothPermissionsState.allPermissionsGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
val animatedBluetoothContainerColor by animateColorAsState(
|
val animatedBluetoothContainerColor by animateColorAsState(
|
||||||
if (bluetoothPermissionsState.allPermissionsGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest
|
if (bluetoothPermissionsState.allPermissionsGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
|
||||||
)
|
)
|
||||||
|
|
||||||
StyledListItem(
|
StyledListItem(
|
||||||
contentText = "Bluetooth",
|
contentText = stringResource(R.string.bluetooth),
|
||||||
onClick = if (!bluetoothPermissionsState.allPermissionsGranted) {
|
onClick = if (!bluetoothPermissionsState.allPermissionsGranted) {
|
||||||
{
|
{
|
||||||
grantingAll = false
|
grantingAll = false
|
||||||
bluetoothPermissionsState.launchMultiplePermissionRequest()
|
bluetoothPermissionsState.launchMultiplePermissionRequest()
|
||||||
}
|
}
|
||||||
} else null,
|
} else null,
|
||||||
supportingText = "Required to communicate with AirPods",
|
supportingText = stringResource(R.string.permission_description_bluetooth),
|
||||||
orientation = StyledListItemOrientation.Vertical,
|
orientation = StyledListItemOrientation.Vertical,
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Box(
|
Box(
|
||||||
@@ -172,27 +187,27 @@ fun PermissionsPage(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
StyledList(title = "Optional Permissions") {
|
StyledList(title = stringResource(R.string.optional_permissions)) {
|
||||||
val animatedNotificationsIconColor by animateColorAsState(
|
val animatedNotificationsIconColor by animateColorAsState(
|
||||||
if (notificationPermissionState.status.isGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
|
if (notificationPermissionState.status.isGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
val animatedNotificationsContainerColor by animateColorAsState(
|
val animatedNotificationsContainerColor by animateColorAsState(
|
||||||
if (notificationPermissionState.status.isGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest
|
if (notificationPermissionState.status.isGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
|
||||||
)
|
)
|
||||||
val animatedPhoneIconColor by animateColorAsState(if (phonePermissionState.allPermissionsGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface)
|
val animatedPhoneIconColor by animateColorAsState(if (phonePermissionState.allPermissionsGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
val animatedPhoneContainerColor by animateColorAsState(
|
val animatedPhoneContainerColor by animateColorAsState(
|
||||||
if (phonePermissionState.allPermissionsGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest
|
if (phonePermissionState.allPermissionsGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
|
||||||
)
|
)
|
||||||
|
|
||||||
StyledListItem(
|
StyledListItem(
|
||||||
contentText = "Notifications",
|
contentText = stringResource(R.string.notifications),
|
||||||
onClick = if (!notificationPermissionState.status.isGranted) {
|
onClick = if (!notificationPermissionState.status.isGranted) {
|
||||||
{
|
{
|
||||||
grantingAll = false
|
grantingAll = false
|
||||||
notificationPermissionState.launchPermissionRequest()
|
notificationPermissionState.launchPermissionRequest()
|
||||||
}
|
}
|
||||||
} else null,
|
} else null,
|
||||||
supportingText = "Show battery status",
|
supportingText = stringResource(R.string.permission_description_notification),
|
||||||
orientation = StyledListItemOrientation.Vertical,
|
orientation = StyledListItemOrientation.Vertical,
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Box(
|
Box(
|
||||||
@@ -215,14 +230,14 @@ fun PermissionsPage(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
StyledListItem(
|
StyledListItem(
|
||||||
contentText = "Phone",
|
contentText = stringResource(R.string.phone),
|
||||||
onClick = if (!phonePermissionState.allPermissionsGranted) {
|
onClick = if (!phonePermissionState.allPermissionsGranted) {
|
||||||
{
|
{
|
||||||
grantingAll = false
|
grantingAll = false
|
||||||
phonePermissionState.launchMultiplePermissionRequest()
|
phonePermissionState.launchMultiplePermissionRequest()
|
||||||
}
|
}
|
||||||
} else null,
|
} else null,
|
||||||
supportingText = "Respond to phone calls with head gestures",
|
supportingText = stringResource(R.string.permission_description_phone),
|
||||||
orientation = StyledListItemOrientation.Vertical,
|
orientation = StyledListItemOrientation.Vertical,
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Box(
|
Box(
|
||||||
@@ -237,7 +252,7 @@ fun PermissionsPage(
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = MaterialIcons.Call,
|
imageVector = MaterialIcons.Call,
|
||||||
contentDescription = "bluetooth",
|
contentDescription = "call",
|
||||||
modifier = Modifier.size(24.dp),
|
modifier = Modifier.size(24.dp),
|
||||||
tint = animatedPhoneIconColor
|
tint = animatedPhoneIconColor
|
||||||
)
|
)
|
||||||
@@ -246,11 +261,44 @@ fun PermissionsPage(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val animatedOverlayIconColor by animateColorAsState(if (canDrawOverlays.value) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface)
|
val animatedHealthConnectIconColor by animateColorAsState(if (healthPermissions.status.isGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
val animatedOverlayContainerColor by animateColorAsState(if (canDrawOverlays.value) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest)
|
val animatedHealthConnectContainerColor by animateColorAsState(if (healthPermissions.status.isGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
|
||||||
StyledListItem(
|
StyledListItem(
|
||||||
contentText = "Display over other apps",
|
contentText = stringResource(R.string.permission_healthconnect),
|
||||||
|
onClick = if (!healthPermissions.status.isGranted) {
|
||||||
|
{
|
||||||
|
grantingAll = false
|
||||||
|
healthPermissions.launchPermissionRequest()
|
||||||
|
}
|
||||||
|
} else null,
|
||||||
|
supportingText = stringResource(R.string.permission_description_healthconnect),
|
||||||
|
leadingContent = {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.background(
|
||||||
|
animatedHealthConnectContainerColor,
|
||||||
|
MaterialShapes.SoftBurst.normalized()
|
||||||
|
.toShape()
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = MaterialIcons.VitalSigns,
|
||||||
|
contentDescription = "vital signs",
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = animatedHealthConnectIconColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
val animatedOverlayIconColor by animateColorAsState(if (canDrawOverlays.value) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
val animatedOverlayContainerColor by animateColorAsState(if (canDrawOverlays.value) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
|
||||||
|
StyledListItem(
|
||||||
|
contentText = stringResource(R.string.permission_overlay),
|
||||||
onClick = if (!canDrawOverlays.value) {
|
onClick = if (!canDrawOverlays.value) {
|
||||||
{
|
{
|
||||||
grantingAll = false
|
grantingAll = false
|
||||||
@@ -261,7 +309,7 @@ fun PermissionsPage(
|
|||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
}
|
}
|
||||||
} else null,
|
} else null,
|
||||||
supportingText = "Show popups when AirPods are nearby or audio switches to them.",
|
supportingText = stringResource(R.string.permission_description_overlay),
|
||||||
orientation = StyledListItemOrientation.Vertical,
|
orientation = StyledListItemOrientation.Vertical,
|
||||||
leadingContent = {
|
leadingContent = {
|
||||||
Box(
|
Box(
|
||||||
@@ -276,7 +324,7 @@ fun PermissionsPage(
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = MaterialIcons.Overlay,
|
imageVector = MaterialIcons.Overlay,
|
||||||
contentDescription = "bluetooth",
|
contentDescription = "overlay",
|
||||||
modifier = Modifier.size(24.dp),
|
modifier = Modifier.size(24.dp),
|
||||||
tint = animatedOverlayIconColor
|
tint = animatedOverlayIconColor
|
||||||
)
|
)
|
||||||
|
|||||||
+57
-27
@@ -48,42 +48,50 @@ fun PrivacyPolicyPage(
|
|||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Overview",
|
text = "Overview",
|
||||||
style = MaterialTheme.typography.titleLarge
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "LibrePods does not collect, store, sell, or share personal information for advertising, analytics, tracking, or profiling purposes. The app does not include analytics, crash reporting, telemetry, advertising SDKs, or tracking services.",
|
text = "LibrePods does not collect, store, sell, or share personal information for advertising, analytics, tracking, or profiling purposes. The app does not include analytics, crash reporting, telemetry, advertising SDKs, or tracking services.",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "All information remains on your device unless you explicitly choose to contact me, create a GitHub issue from the app, or make a purchase or sponsorship through a third-party platform.",
|
text = "All information remains on your device unless you explicitly choose to contact me, create a GitHub issue from the app, or make a purchase or sponsorship through a third-party platform.",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Third Party Services",
|
text = "Third Party Services",
|
||||||
style = MaterialTheme.typography.titleLarge
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "LibrePods provides several ways to contact me, including email, Discord, and GitHub Issues.",
|
text = "LibrePods provides several ways to contact me, including email, Discord, and GitHub Issues.",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Email",
|
text = "Email",
|
||||||
style = MaterialTheme.typography.titleMedium
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "If you contact me by email, I receive your email address and any information you choose to include in your message. When using the contact form within LibrePods, your email client will open with a pre-filled email address, the subject line and body that you fill out. The body will also include LibrePods version information and device information to help with troubleshooting.",
|
text = "If you contact me by email, I receive your email address and any information you choose to include in your message. When using the contact form within LibrePods, your email client will open with a pre-filled email address, the subject line and body that you fill out. The body will also include LibrePods version information and device information to help with troubleshooting.",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "You can edit or remove any of this information before sending the email.",
|
text = "You can edit or remove any of this information before sending the email.",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
@@ -93,22 +101,26 @@ fun PrivacyPolicyPage(
|
|||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "The app provides a link to the LibrePods Discord server. If you choose to join the Discord server, you will be subject to Discord's privacy policy.",
|
text = "The app provides a link to the LibrePods Discord server. If you choose to join the Discord server, you will be subject to Discord's privacy policy.",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "I do not receive any information about you from Discord other than what is publicly visible in the Discord server, such as your username, joining date, common servers, and any messages or content you post in the server, unless you choose to share it with me in the Discord server.",
|
text = "I do not receive any information about you from Discord other than what is publicly visible in the Discord server, such as your username, joining date, common servers, and any messages or content you post in the server, unless you choose to share it with me in the Discord server.",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "GitHub Issues",
|
text = "GitHub Issues",
|
||||||
style = MaterialTheme.typography.titleMedium
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "When creating a GitHub issue through LibrePods, the app will pre-fill the issue form with:",
|
text = "When creating a GitHub issue through LibrePods, the app will pre-fill the issue form with:",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
@@ -117,68 +129,86 @@ fun PrivacyPolicyPage(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
"• LibrePods version name and version code",
|
"• LibrePods version name and version code",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "• Device manufacturer and model",
|
text = "• Device manufacturer and model",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "• Android build information",
|
text = "• Android build information",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "• Installation source (Google Play or GitHub)",
|
text = "• Installation source (Google Play or GitHub)",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "This information helps diagnose bugs and provide support. No information is sent automatically. The information is only submitted if you choose to create the GitHub issue.",
|
text = "This information helps diagnose bugs and provide support. No information is sent automatically. The information is only submitted if you choose to create the GitHub issue.",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Payments", style = MaterialTheme.typography.titleLarge
|
text = "Payments",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
||||||
if (BuildConfig.PLAY_BUILD) {
|
if (BuildConfig.PLAY_BUILD) {
|
||||||
Text(
|
Text(
|
||||||
text = "Google Play", style = MaterialTheme.typography.titleMedium
|
text = "Google Play",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "When using the version available on Google Play, purchases are processed by Google Play.",
|
text = "When using the version available on Google Play, purchases are processed by Google Play.",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "LibrePods verifies the purchase with Google Play on-device, not with a remote server that I control. I do not receive any information about you or your purchase from Google Play. Payment processing is handled entirely by Google Play, and I do not have access to any of your payment information.",
|
text = "LibrePods verifies the purchase with Google Play on-device, not with a remote server that I control. I do not receive any information about you or your purchase from Google Play. Payment processing is handled entirely by Google Play, and I do not have access to any of your payment information.",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = "GitHub Sponsors", style = MaterialTheme.typography.titleMedium
|
text = "GitHub Sponsors",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "When using the FOSS version available on GitHub, the upgrade button links to GitHub Sponsors. If you choose to sponsor LibrePods, your sponsorship is processed by GitHub.",
|
text = "When using the FOSS version available on GitHub, the upgrade button links to GitHub Sponsors. If you choose to sponsor LibrePods, your sponsorship is processed by GitHub.",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Your username and country/region are shared with me when you sponsor LibrePods. Depending on your GitHub Sponsors privacy settings, I may also receive your email address.",
|
text = "Your username and country/region are shared with me when you sponsor LibrePods. Depending on your GitHub Sponsors privacy settings, I may also receive your email address.",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Contact", style = MaterialTheme.typography.titleLarge
|
text = "Contact",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "If you have questions about this privacy policy, please contact me via email at privacy@kavish.xyz.",
|
text = "If you have questions about this privacy policy, please contact me via email at privacy@kavish.xyz.",
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
)
|
)
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
@@ -187,7 +217,7 @@ fun PrivacyPolicyPage(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.i_agree),
|
text = stringResource(R.string.i_agree),
|
||||||
style = MaterialTheme.typography.labelMediumEmphasized
|
style = MaterialTheme.typography.labelMediumEmphasized,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+68
-5
@@ -21,35 +21,54 @@ package me.kavishdevar.librepods.presentation.viewmodel
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.transformLatest
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.billing.BillingManager
|
import me.kavishdevar.librepods.billing.BillingManager
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
|
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
|
||||||
import me.kavishdevar.librepods.bluetooth.att.ATTHandle
|
import me.kavishdevar.librepods.bluetooth.att.ATTHandle
|
||||||
import me.kavishdevar.librepods.data.StemAction
|
import me.kavishdevar.librepods.data.StemAction
|
||||||
|
import me.kavishdevar.librepods.data.heartrate.HeartRateSample
|
||||||
import me.kavishdevar.librepods.data.recording.Recording
|
import me.kavishdevar.librepods.data.recording.Recording
|
||||||
import me.kavishdevar.librepods.data.xposed.XposedRemotePrefProvider
|
import me.kavishdevar.librepods.data.xposed.XposedRemotePrefProvider
|
||||||
|
import me.kavishdevar.librepods.database.app.AppSettingsEntity
|
||||||
|
import me.kavishdevar.librepods.database.app.AppStateEntity
|
||||||
import me.kavishdevar.librepods.devices.AppleDevice
|
import me.kavishdevar.librepods.devices.AppleDevice
|
||||||
import me.kavishdevar.librepods.devices.AppleMetadata
|
import me.kavishdevar.librepods.devices.AppleMetadata
|
||||||
import me.kavishdevar.librepods.devices.AppleSettings
|
import me.kavishdevar.librepods.devices.AppleSettings
|
||||||
import me.kavishdevar.librepods.devices.AppleState
|
import me.kavishdevar.librepods.devices.AppleState
|
||||||
|
import me.kavishdevar.librepods.repository.AppDataRepository
|
||||||
|
import me.kavishdevar.librepods.repository.HeartRateRepository
|
||||||
import me.kavishdevar.librepods.repository.RecordingRepository
|
import me.kavishdevar.librepods.repository.RecordingRepository
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
data class AppleUiState(
|
data class AppleUiState(
|
||||||
val state: AppleState = AppleState(),
|
val state: AppleState = AppleState(),
|
||||||
val settings: AppleSettings = AppleSettings(),
|
val settings: AppleSettings = AppleSettings(),
|
||||||
val metadata: AppleMetadata = AppleMetadata(),
|
val metadata: AppleMetadata = AppleMetadata(),
|
||||||
|
|
||||||
|
val appState: AppStateEntity = AppStateEntity(),
|
||||||
|
val appSettings: AppSettingsEntity = AppSettingsEntity(),
|
||||||
|
|
||||||
val isPremium: Boolean = false,
|
val isPremium: Boolean = false,
|
||||||
val vendorIdHook: Boolean = false,
|
val vendorIdHook: Boolean = false,
|
||||||
val recordings: List<Recording> = emptyList(),
|
val recordings: List<Recording> = emptyList(),
|
||||||
|
|
||||||
|
val heartRateSamples: List<HeartRateSample> = emptyList(),
|
||||||
|
|
||||||
|
val heartRateRange: ClosedRange<Long>? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
class AppleViewModel(
|
class AppleViewModel(
|
||||||
private val device: AppleDevice,
|
private val device: AppleDevice,
|
||||||
|
private val appDataRepository: AppDataRepository,
|
||||||
private val recordingRepository: RecordingRepository,
|
private val recordingRepository: RecordingRepository,
|
||||||
|
private val heartRateRepository: HeartRateRepository
|
||||||
) : ViewModel(), DeviceViewModel {
|
) : ViewModel(), DeviceViewModel {
|
||||||
val billingManager = BillingManager
|
val billingManager = BillingManager
|
||||||
|
|
||||||
@@ -57,12 +76,42 @@ class AppleViewModel(
|
|||||||
|
|
||||||
private var attObserveJob: Job? = null
|
private var attObserveJob: Job? = null
|
||||||
|
|
||||||
val uiState = combine(
|
private val _heartRateRange = MutableStateFlow<ClosedRange<Long>?>(null)
|
||||||
|
|
||||||
|
private val heartRateSamples: Flow<List<HeartRateSample>> = _heartRateRange
|
||||||
|
.transformLatest { range ->
|
||||||
|
if (range == null) {
|
||||||
|
emit(emptyList())
|
||||||
|
} else {
|
||||||
|
val startInstant = Instant.fromEpochMilliseconds(range.start)
|
||||||
|
val endInstant = Instant.fromEpochMilliseconds(range.endInclusive)
|
||||||
|
val samples = heartRateRepository.get(startInstant, endInstant)
|
||||||
|
emit(samples)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val deviceDetails = combine(
|
||||||
device.state,
|
device.state,
|
||||||
device.settings,
|
device.settings,
|
||||||
device.metadata,
|
device.metadata
|
||||||
|
) { state, settings, metadata ->
|
||||||
|
Triple(state, settings, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val appData = combine(
|
||||||
|
appDataRepository.state,
|
||||||
|
appDataRepository.settings
|
||||||
|
) { appState, appSettings ->
|
||||||
|
Pair(appState, appSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
val uiState = combine(
|
||||||
|
deviceDetails,
|
||||||
|
appData,
|
||||||
billingManager.provider.isPremium,
|
billingManager.provider.isPremium,
|
||||||
) { state, settings, metadata, isPremium ->
|
_heartRateRange,
|
||||||
|
heartRateSamples
|
||||||
|
) { (state, settings, metadata), (appState, appSettings), isPremium, heartRateRange, heartRateSamples ->
|
||||||
AppleUiState(
|
AppleUiState(
|
||||||
state = state,
|
state = state,
|
||||||
settings = settings,
|
settings = settings,
|
||||||
@@ -71,7 +120,11 @@ class AppleViewModel(
|
|||||||
vendorIdHook = XposedRemotePrefProvider.create().getBoolean(
|
vendorIdHook = XposedRemotePrefProvider.create().getBoolean(
|
||||||
"vendor_id_hook",
|
"vendor_id_hook",
|
||||||
false
|
false
|
||||||
) // TODO: make this a Flow, even if it means polling every few seconds
|
), // TODO: make this a Flow (even if it means polling every few seconds?)
|
||||||
|
heartRateRange = heartRateRange,
|
||||||
|
heartRateSamples = heartRateSamples,
|
||||||
|
appState = appState,
|
||||||
|
appSettings = appSettings
|
||||||
)
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
viewModelScope,
|
viewModelScope,
|
||||||
@@ -133,7 +186,17 @@ class AppleViewModel(
|
|||||||
fun setCustomEqEnabled(enabled: Boolean) = device.setCustomEqEnabled(enabled)
|
fun setCustomEqEnabled(enabled: Boolean) = device.setCustomEqEnabled(enabled)
|
||||||
fun setCustomEq(low: Int, mid: Int, high: Int) = device.setCustomEq(low, mid, high)
|
fun setCustomEq(low: Int, mid: Int, high: Int) = device.setCustomEq(low, mid, high)
|
||||||
|
|
||||||
fun testHeadGestures() = device.testHeadGestures()
|
fun detectHeadGestures(callback: (Boolean) -> Unit) = device.detectHeadGestures(callback)
|
||||||
|
|
||||||
|
fun setHeadGesturesVerticalOffset(offset: Int) = device.setHeadGesturesVerticalOffset(offset)
|
||||||
|
fun setHeadGesturesHorizontalOffset(offset: Int) = device.setHeadGesturesHorizontalOffset(offset)
|
||||||
|
fun setHeadTrackingInterval(interval: Duration) = device.setHeadTrackingInterval(interval)
|
||||||
|
|
||||||
|
fun startHr() = device.startHr()
|
||||||
|
fun stopHr() = device.stopHr()
|
||||||
|
fun setHrRange(range: ClosedRange<Long>) {
|
||||||
|
_heartRateRange.value = range
|
||||||
|
}
|
||||||
|
|
||||||
fun sendRawPacket(data: ByteArray): Boolean = device.sendRawPacket(data)
|
fun sendRawPacket(data: ByteArray): Boolean = device.sendRawPacket(data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class AppleRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun saveCacheFromState(macAddress: MacAddress, state: AppleState) {
|
suspend fun saveCacheFromState(macAddress: MacAddress, state: AppleState) {
|
||||||
Log.d(TAG, "Saving AppleCache from AppleState for ${macAddress.toRedactedString()}: $state")
|
Log.d(TAG, "Saving AppleCache from AppleState for ${macAddress.toRedactedString()}")
|
||||||
val cache = try {
|
val cache = try {
|
||||||
AppleCache(
|
AppleCache(
|
||||||
capabilities = state.capabilities,
|
capabilities = state.capabilities,
|
||||||
@@ -48,7 +48,7 @@ class AppleRepository(
|
|||||||
customEq = state.customEq
|
customEq = state.customEq
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to create AppleCache from AppleState for ${macAddress.toRedactedString()}: $state", e)
|
Log.e(TAG, "Failed to create AppleCache from AppleState for ${macAddress.toRedactedString()}", e)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
package me.kavishdevar.librepods.repository
|
||||||
|
|
||||||
|
import me.kavishdevar.librepods.data.heartrate.HeartRateDao
|
||||||
|
import me.kavishdevar.librepods.data.heartrate.HeartRateSample
|
||||||
|
import me.kavishdevar.librepods.database.heartrate.HeartRateSampleEntity
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
class HeartRateRepository(
|
||||||
|
private val dao: HeartRateDao,
|
||||||
|
) {
|
||||||
|
suspend fun insert(sample: HeartRateSample) {
|
||||||
|
dao.insert(sample.toEntity())
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun get(
|
||||||
|
start: Instant,
|
||||||
|
end: Instant,
|
||||||
|
): List<HeartRateSample> = dao.get(start, end).map { it.toSample() }
|
||||||
|
|
||||||
|
private fun HeartRateSample.toEntity() = HeartRateSampleEntity(
|
||||||
|
timestamp = timestamp,
|
||||||
|
bpm = bpm
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun HeartRateSampleEntity.toSample() = HeartRateSample(
|
||||||
|
bpm = bpm,
|
||||||
|
timestamp = timestamp
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,16 +18,21 @@ import android.content.ComponentName
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.media.AudioManager
|
import android.media.AudioManager
|
||||||
import android.os.BatteryManager
|
import android.os.BatteryManager
|
||||||
import android.os.Binder
|
import android.os.Binder
|
||||||
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.os.ParcelUuid
|
import android.os.ParcelUuid
|
||||||
|
import android.os.ext.SdkExtensions
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.RemoteViews
|
import android.widget.RemoteViews
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.health.connect.client.permission.HealthPermission
|
||||||
|
import androidx.health.connect.client.records.HeartRateRecord
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -42,6 +47,7 @@ import me.kavishdevar.librepods.bluetooth.MacAddress
|
|||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
|
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
|
||||||
import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType
|
import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType
|
||||||
import me.kavishdevar.librepods.bluetooth.verifyRPA
|
import me.kavishdevar.librepods.bluetooth.verifyRPA
|
||||||
|
import me.kavishdevar.librepods.data.heartrate.HeartRateSample
|
||||||
import me.kavishdevar.librepods.database.app.AppSettingsEntity
|
import me.kavishdevar.librepods.database.app.AppSettingsEntity
|
||||||
import me.kavishdevar.librepods.devices.AppleDevice
|
import me.kavishdevar.librepods.devices.AppleDevice
|
||||||
import me.kavishdevar.librepods.devices.AppleSettings
|
import me.kavishdevar.librepods.devices.AppleSettings
|
||||||
@@ -58,7 +64,10 @@ import me.kavishdevar.librepods.presentation.overlays.IslandWindow
|
|||||||
import me.kavishdevar.librepods.presentation.widgets.BatteryWidget
|
import me.kavishdevar.librepods.presentation.widgets.BatteryWidget
|
||||||
import me.kavishdevar.librepods.utils.MediaController
|
import me.kavishdevar.librepods.utils.MediaController
|
||||||
import me.kavishdevar.librepods.utils.redactMac
|
import me.kavishdevar.librepods.utils.redactMac
|
||||||
|
import java.time.ZoneOffset
|
||||||
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
import kotlin.time.toJavaInstant
|
||||||
|
|
||||||
private const val TAG = "LibrePodsService"
|
private const val TAG = "LibrePodsService"
|
||||||
|
|
||||||
@@ -94,6 +103,14 @@ class LibrePodsService : Service() {
|
|||||||
(application as LibrePodsApplication).widgetConfigRepository
|
(application as LibrePodsApplication).widgetConfigRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val heartRateRepository by lazy {
|
||||||
|
(application as LibrePodsApplication).heartRateRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
private val healthConnectClient by lazy {
|
||||||
|
(application as LibrePodsApplication).healthConnectClient
|
||||||
|
}
|
||||||
|
|
||||||
private val hasConnectedToAACP by lazy {
|
private val hasConnectedToAACP by lazy {
|
||||||
appDataRepository.state.value.hasConnectedToAACP
|
appDataRepository.state.value.hasConnectedToAACP
|
||||||
}
|
}
|
||||||
@@ -684,6 +701,24 @@ class LibrePodsService : Service() {
|
|||||||
6, 8, 9 -> MediaController.stopSpeaking()
|
6, 8, 9 -> MediaController.stopSpeaking()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.currentHeartRate?.timestamp != previousState.currentHeartRate?.timestamp -> {
|
||||||
|
state.currentHeartRate?.let { heartRateSample ->
|
||||||
|
Log.i(
|
||||||
|
TAG,
|
||||||
|
"current heart rate changed from device ${device.macAddress.toRedactedString()}"
|
||||||
|
)
|
||||||
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"current heart rate: ${heartRateSample.bpm} bpm, timestamp: ${heartRateSample.timestamp.toJavaInstant()}. processing."
|
||||||
|
)
|
||||||
|
|
||||||
|
processHeartRateSample(
|
||||||
|
heartRateSample = heartRateSample,
|
||||||
|
interval = state.heartRateInterval
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
previousState = state
|
previousState = state
|
||||||
}
|
}
|
||||||
@@ -1066,6 +1101,13 @@ class LibrePodsService : Service() {
|
|||||||
device.disableAudio(this)
|
device.disableAudio(this)
|
||||||
device.disconnectAudio(this)
|
device.disconnectAudio(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// not the right place because the stream doesn't switch to the remaining bud when the active one is removed
|
||||||
|
if (device is AppleDevice && device.state.value.hrmActive) {
|
||||||
|
device.updateState {
|
||||||
|
it.copy(hrmActive = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1089,6 +1131,44 @@ class LibrePodsService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun processHeartRateSample(heartRateSample: HeartRateSample, interval: Duration) {
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
Log.d(TAG, "inserting to local db")
|
||||||
|
heartRateRepository.insert(heartRateSample)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SdkExtensions.getExtensionVersion(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) >= 7) {
|
||||||
|
if (checkSelfPermission(HealthPermission.getWritePermission(HeartRateRecord::class)) != PackageManager.PERMISSION_GRANTED) return
|
||||||
|
val healthConnectHeartRateSample = HeartRateRecord.Sample(
|
||||||
|
time = heartRateSample.timestamp.toJavaInstant(),
|
||||||
|
beatsPerMinute = heartRateSample.bpm.toLong()
|
||||||
|
)
|
||||||
|
|
||||||
|
val zoneOffset = ZoneOffset.systemDefault().rules.getOffset(heartRateSample.timestamp.toJavaInstant())
|
||||||
|
|
||||||
|
val heartRateRecord = HeartRateRecord(
|
||||||
|
startTime = (heartRateSample.timestamp - interval).toJavaInstant(),
|
||||||
|
endTime = heartRateSample.timestamp.toJavaInstant(),
|
||||||
|
startZoneOffset = zoneOffset,
|
||||||
|
endZoneOffset = zoneOffset,
|
||||||
|
samples = listOf(healthConnectHeartRateSample),
|
||||||
|
metadata = androidx.health.connect.client.records.metadata.Metadata.autoRecorded(
|
||||||
|
device = androidx.health.connect.client.records.metadata.Device(type = androidx.health.connect.client.records.metadata.Device.TYPE_HEARABLE)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (healthConnectClient != null) {
|
||||||
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
healthConnectClient!!.insertRecords(listOf(heartRateRecord))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Health Connect client not available")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "U SDK Extension <7")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Shizuku
|
// TODO: Shizuku
|
||||||
private fun setAppleBluetoothMetadata(
|
private fun setAppleBluetoothMetadata(
|
||||||
@Suppress("unused") device: AppleDevice
|
@Suppress("unused") device: AppleDevice
|
||||||
|
|||||||
@@ -16,20 +16,16 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@file:OptIn(ExperimentalEncodingApi::class)
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.utils
|
package me.kavishdevar.librepods.utils
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import me.kavishdevar.librepods.services.LibrePodsService
|
|
||||||
import java.util.Collections
|
import java.util.Collections
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
@@ -37,10 +33,10 @@ import kotlin.math.abs
|
|||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
import kotlin.math.pow
|
import kotlin.math.pow
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
class GestureDetector(
|
// TODO: rewrite
|
||||||
private val librepodsService: LibrePodsService
|
class GestureDetector {
|
||||||
) {
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "GestureDetector"
|
private const val TAG = "GestureDetector"
|
||||||
|
|
||||||
@@ -54,8 +50,6 @@ class GestureDetector(
|
|||||||
private const val MAX_VALID_ORIENTATION_VALUE = 6000
|
private const val MAX_VALID_ORIENTATION_VALUE = 6000
|
||||||
}
|
}
|
||||||
|
|
||||||
// val audio = GestureFeedback(ServiceManager.getService()?.baseContext!!)
|
|
||||||
|
|
||||||
private val horizontalBuffer = Collections.synchronizedList(ArrayList<Double>())
|
private val horizontalBuffer = Collections.synchronizedList(ArrayList<Double>())
|
||||||
private val verticalBuffer = Collections.synchronizedList(ArrayList<Double>())
|
private val verticalBuffer = Collections.synchronizedList(ArrayList<Double>())
|
||||||
|
|
||||||
@@ -86,7 +80,7 @@ class GestureDetector(
|
|||||||
|
|
||||||
private var isRunning = false
|
private var isRunning = false
|
||||||
private var detectionJob: Job? = null
|
private var detectionJob: Job? = null
|
||||||
private var gestureDetectedCallback: ((Boolean) -> Unit)? = null
|
private var processingJob: Job? = null
|
||||||
|
|
||||||
private var significantMotion = false
|
private var significantMotion = false
|
||||||
private var lastSignificantMotionTime = 0L
|
private var lastSignificantMotionTime = 0L
|
||||||
@@ -96,49 +90,52 @@ class GestureDetector(
|
|||||||
while (verticalAvgBuffer.size < 3) verticalAvgBuffer.add(0.0)
|
while (verticalAvgBuffer.size < 3) verticalAvgBuffer.add(0.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> Unit) {
|
fun startDetection(acceleration: StateFlow<Acceleration>, callback: (Boolean) -> Unit) {
|
||||||
if (isRunning) return
|
if (isRunning) return
|
||||||
|
|
||||||
Log.d(TAG, "Starting gesture detection...")
|
Log.d(TAG, "Starting gesture detection...")
|
||||||
isRunning = true
|
isRunning = true
|
||||||
gestureDetectedCallback = onGestureDetected
|
|
||||||
|
|
||||||
// Log.d(TAG, "started: ${airPodsService.startHeadTracking()}")
|
|
||||||
|
|
||||||
clearData()
|
clearData()
|
||||||
|
|
||||||
prevHorizontal = 0.0
|
prevHorizontal = 0.0
|
||||||
prevVertical = 0.0
|
prevVertical = 0.0
|
||||||
|
|
||||||
|
processingJob = CoroutineScope(Dispatchers.Default).launch {
|
||||||
|
acceleration.collect {
|
||||||
|
processHeadOrientation(it.horizontal.toInt(), it.vertical.toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
detectionJob = CoroutineScope(Dispatchers.Default).launch {
|
detectionJob = CoroutineScope(Dispatchers.Default).launch {
|
||||||
while (isRunning) {
|
while (isRunning) {
|
||||||
delay(50)
|
delay(50.milliseconds)
|
||||||
|
|
||||||
val gesture = detectGestures()
|
val gesture = detectGestures()
|
||||||
if (gesture != null) {
|
if (gesture != null) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
// audio.playConfirmation(gesture)
|
GestureFeedback.playConfirmation(gesture)
|
||||||
|
|
||||||
gestureDetectedCallback?.invoke(gesture)
|
callback.invoke(gesture)
|
||||||
stopDetection(doNotStop)
|
stopDetection()
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun stopDetection(doNotStop: Boolean = false) {
|
fun stopDetection() {
|
||||||
if (!isRunning) return
|
if (!isRunning) return
|
||||||
|
|
||||||
Log.d(TAG, "Stopping gesture detection")
|
Log.d(TAG, "Stopping gesture detection")
|
||||||
isRunning = false
|
isRunning = false
|
||||||
|
|
||||||
// if (!doNotStop) airPodsService.stopHeadTracking()
|
|
||||||
|
|
||||||
detectionJob?.cancel()
|
detectionJob?.cancel()
|
||||||
detectionJob = null
|
detectionJob = null
|
||||||
gestureDetectedCallback = null
|
|
||||||
}
|
processingJob?.cancel()
|
||||||
|
processingJob = null
|
||||||
|
}
|
||||||
|
|
||||||
fun processHeadOrientation(horizontal: Int, vertical: Int) {
|
fun processHeadOrientation(horizontal: Int, vertical: Int) {
|
||||||
if (!isRunning) return
|
if (!isRunning) return
|
||||||
@@ -156,7 +153,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U
|
|||||||
|
|
||||||
if (significantHorizontal && (!significantVertical || abs(horizontalDelta) > abs(verticalDelta))) {
|
if (significantHorizontal && (!significantVertical || abs(horizontalDelta) > abs(verticalDelta))) {
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
CoroutineScope(Dispatchers.Main).launch {
|
||||||
// audio.playDirectional(isVertical = false, value = horizontalDelta)
|
GestureFeedback.playDirectional(isVertical = false, value = horizontalDelta)
|
||||||
}
|
}
|
||||||
significantMotion = true
|
significantMotion = true
|
||||||
lastSignificantMotionTime = System.currentTimeMillis()
|
lastSignificantMotionTime = System.currentTimeMillis()
|
||||||
@@ -164,7 +161,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U
|
|||||||
}
|
}
|
||||||
else if (significantVertical) {
|
else if (significantVertical) {
|
||||||
CoroutineScope(Dispatchers.Main).launch {
|
CoroutineScope(Dispatchers.Main).launch {
|
||||||
// audio.playDirectional(isVertical = true, value = verticalDelta)
|
GestureFeedback.playDirectional(isVertical = true, value = verticalDelta)
|
||||||
}
|
}
|
||||||
significantMotion = true
|
significantMotion = true
|
||||||
lastSignificantMotionTime = System.currentTimeMillis()
|
lastSignificantMotionTime = System.currentTimeMillis()
|
||||||
@@ -363,7 +360,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U
|
|||||||
|
|
||||||
private fun detectGestures(): Boolean? {
|
private fun detectGestures(): Boolean? {
|
||||||
val requiredExtremes = getRequiredExtremes()
|
val requiredExtremes = getRequiredExtremes()
|
||||||
Log.d(TAG, "Current required extremes: $requiredExtremes")
|
// Log.d(TAG, "Current required extremes: $requiredExtremes")
|
||||||
|
|
||||||
if (verticalPeaks.size + verticalTroughs.size >= requiredExtremes) {
|
if (verticalPeaks.size + verticalTroughs.size >= requiredExtremes) {
|
||||||
val allExtremes = (verticalPeaks + verticalTroughs).sortedBy { it.first }
|
val allExtremes = (verticalPeaks + verticalTroughs).sortedBy { it.first }
|
||||||
|
|||||||
@@ -23,14 +23,12 @@ package me.kavishdevar.librepods.utils
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.media.AudioAttributes
|
import android.media.AudioAttributes
|
||||||
import android.media.SoundPool
|
import android.media.SoundPool
|
||||||
import android.os.Build
|
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
class GestureFeedback(context: Context) {
|
object GestureFeedback {
|
||||||
|
|
||||||
private val TAG = "GestureFeedback"
|
private val TAG = "GestureFeedback"
|
||||||
|
|
||||||
@@ -71,7 +69,7 @@ class GestureFeedback(context: Context) {
|
|||||||
private val RIGHT_VOLUME = Pair(0.0f, 1.0f)
|
private val RIGHT_VOLUME = Pair(0.0f, 1.0f)
|
||||||
private val VERTICAL_VOLUME = Pair(1.0f, 1.0f)
|
private val VERTICAL_VOLUME = Pair(1.0f, 1.0f)
|
||||||
|
|
||||||
init {
|
fun init (context: Context) {
|
||||||
soundId = soundPool.load(context, R.raw.blip_no, 1)
|
soundId = soundPool.load(context, R.raw.blip_no, 1)
|
||||||
confirmYesId = soundPool.load(context, R.raw.confirm_yes, 1)
|
confirmYesId = soundPool.load(context, R.raw.confirm_yes, 1)
|
||||||
confirmNoId = soundPool.load(context, R.raw.confirm_no, 1)
|
confirmNoId = soundPool.load(context, R.raw.confirm_no, 1)
|
||||||
@@ -84,7 +82,6 @@ class GestureFeedback(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.R)
|
|
||||||
fun playDirectional(isVertical: Boolean, value: Double) {
|
fun playDirectional(isVertical: Boolean, value: Double) {
|
||||||
if (!soundsLoaded.get()) {
|
if (!soundsLoaded.get()) {
|
||||||
Log.d(TAG, "Sounds not yet loaded, skipping playback")
|
Log.d(TAG, "Sounds not yet loaded, skipping playback")
|
||||||
|
|||||||
@@ -20,82 +20,19 @@ package me.kavishdevar.librepods.utils
|
|||||||
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
data class Orientation(val pitch: Float = 0f, val yaw: Float = 0f)
|
|
||||||
data class Acceleration(val vertical: Float = 0f, val horizontal: Float = 0f)
|
data class Acceleration(val vertical: Float = 0f, val horizontal: Float = 0f)
|
||||||
|
|
||||||
|
// TODO: remove
|
||||||
object HeadTracking {
|
object HeadTracking {
|
||||||
private val _orientation = MutableStateFlow(Orientation())
|
|
||||||
val orientation = _orientation.asStateFlow()
|
|
||||||
|
|
||||||
private val _acceleration = MutableStateFlow(Acceleration())
|
private val _acceleration = MutableStateFlow(Acceleration())
|
||||||
val acceleration = _acceleration.asStateFlow()
|
val acceleration = _acceleration.asStateFlow()
|
||||||
|
|
||||||
private val calibrationSamples = mutableListOf<Triple<Int, Int, Int>>()
|
fun addAccel(vert: Float, horiz: Float) {
|
||||||
private var isCalibrated = false
|
_acceleration.value = Acceleration(vert, horiz)
|
||||||
private var o1Neutral = 19000
|
|
||||||
private var o2Neutral = 0
|
|
||||||
private var o3Neutral = 0
|
|
||||||
|
|
||||||
private const val CALIBRATION_SAMPLE_COUNT = 10
|
|
||||||
private const val ORIENTATION_OFFSET = 5500
|
|
||||||
|
|
||||||
fun processPacket(packet: ByteArray) {
|
|
||||||
val o1 = bytesToInt(packet[43], packet[44])
|
|
||||||
val o2 = bytesToInt(packet[45], packet[46])
|
|
||||||
val o3 = bytesToInt(packet[47], packet[48])
|
|
||||||
|
|
||||||
val horizontalAccel = bytesToInt(packet[51], packet[52]).toFloat()
|
|
||||||
val verticalAccel = bytesToInt(packet[53], packet[54]).toFloat()
|
|
||||||
|
|
||||||
if (!isCalibrated) {
|
|
||||||
calibrationSamples.add(Triple(o1, o2, o3))
|
|
||||||
if (calibrationSamples.size >= CALIBRATION_SAMPLE_COUNT) {
|
|
||||||
calibrate()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val orientation = calculateOrientation(o1, o2, o3)
|
|
||||||
_orientation.value = orientation
|
|
||||||
|
|
||||||
_acceleration.value = Acceleration(verticalAccel, horizontalAccel)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun calibrate() {
|
|
||||||
if (calibrationSamples.size < 3) return
|
|
||||||
|
|
||||||
// Add offset during calibration
|
|
||||||
o1Neutral = calibrationSamples.map { it.first + ORIENTATION_OFFSET }.average().roundToInt()
|
|
||||||
o2Neutral = calibrationSamples.map { it.second + ORIENTATION_OFFSET }.average().roundToInt()
|
|
||||||
o3Neutral = calibrationSamples.map { it.third + ORIENTATION_OFFSET }.average().roundToInt()
|
|
||||||
|
|
||||||
isCalibrated = true
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("UnusedVariable")
|
|
||||||
private fun calculateOrientation(o1: Int, o2: Int, o3: Int): Orientation {
|
|
||||||
if (!isCalibrated) return Orientation()
|
|
||||||
|
|
||||||
val o1Norm = (o1 + ORIENTATION_OFFSET) - o1Neutral
|
|
||||||
val o2Norm = (o2 + ORIENTATION_OFFSET) - o2Neutral
|
|
||||||
val o3Norm = (o3 + ORIENTATION_OFFSET) - o3Neutral
|
|
||||||
|
|
||||||
val pitch = (o2Norm + o3Norm) / 2f / 32000f * 180f
|
|
||||||
val yaw = (o2Norm - o3Norm) / 2f / 32000f * 180f
|
|
||||||
|
|
||||||
return Orientation(pitch, yaw)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bytesToInt(b1: Byte, b2: Byte): Int {
|
|
||||||
return (b2.toInt() shl 8) or (b1.toInt() and 0xFF)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reset() {
|
fun reset() {
|
||||||
calibrationSamples.clear()
|
|
||||||
isCalibrated = false
|
|
||||||
_orientation.value = Orientation()
|
|
||||||
_acceleration.value = Acceleration()
|
_acceleration.value = Acceleration()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import android.util.Log
|
|||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
// TODO: fragile play/pause, don't refactor unless needed. userPlayedMedia and iPaused etc. probably should not be here
|
||||||
object MediaController {
|
object MediaController {
|
||||||
private var initialVolume: Int? = null
|
private var initialVolume: Int? = null
|
||||||
private lateinit var audioManager: AudioManager
|
private lateinit var audioManager: AudioManager
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package rtbuddy;
|
||||||
|
|
||||||
|
option java_package = "me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto";
|
||||||
|
option java_multiple_files = true;
|
||||||
|
|
||||||
|
enum SensorServiceType {
|
||||||
|
SENSOR_SERVICE_UNKNOWN = 0;
|
||||||
|
DRVCOMM = 1;
|
||||||
|
TTR = 2;
|
||||||
|
ANALYTICS = 3;
|
||||||
|
ACTIN = 4;
|
||||||
|
KADABRA = 5;
|
||||||
|
MANDO = 6;
|
||||||
|
IED = 7;
|
||||||
|
NEO = 8;
|
||||||
|
NEORELAY = 9;
|
||||||
|
COMM = 10;
|
||||||
|
ACCEL = 11;
|
||||||
|
GYRO = 12;
|
||||||
|
PDR = 13;
|
||||||
|
ACTIVITY = 14;
|
||||||
|
CMA = 15;
|
||||||
|
DEVMOTION6 = 16;
|
||||||
|
SPL0 = 17;
|
||||||
|
HOSTLIBHID = 18;
|
||||||
|
HEARTRATE = 19;
|
||||||
|
HEARTRATEv2 = 20;
|
||||||
|
|
||||||
|
SENSOR_SERVICE_UNKNOWN_82 = 82;
|
||||||
|
HEARTRATE_COMMAND = 84;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SensorDataWX {
|
||||||
|
int32 seq = 1;
|
||||||
|
int32 log_type = 2;
|
||||||
|
bytes another_sensor_stream = 3;
|
||||||
|
RequestAllDescriptors request_all_descriptors = 4;
|
||||||
|
SensorDescriptor sensor_descriptor = 5;
|
||||||
|
|
||||||
|
SensorCommand command = 7;
|
||||||
|
SensorServiceSetting service_settings = 8;
|
||||||
|
SensorTypeAck start_ack = 9;
|
||||||
|
|
||||||
|
SensorTypeAck command_ack = 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
message RequestAllDescriptors {
|
||||||
|
}
|
||||||
|
|
||||||
|
message SensorCommand {
|
||||||
|
SensorServiceType service = 1;
|
||||||
|
bytes payload = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SensorDescriptor {
|
||||||
|
SensorServiceType service = 1;
|
||||||
|
bytes sensor_descriptor = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SensorTypeAck {
|
||||||
|
SensorServiceType service = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SensorServiceSetting {
|
||||||
|
SensorServiceType service = 1;
|
||||||
|
int32 setting = 2;
|
||||||
|
bytes configuration = 3;
|
||||||
|
}
|
||||||
@@ -320,4 +320,24 @@
|
|||||||
<string name="enable_debug_mode">Enable debug mode</string>
|
<string name="enable_debug_mode">Enable debug mode</string>
|
||||||
<string name="ble_report_delay_description">Any value greater than 0 enables batching.</string>
|
<string name="ble_report_delay_description">Any value greater than 0 enables batching.</string>
|
||||||
<string name="ble_report_delay">Report delay (in ms)</string>
|
<string name="ble_report_delay">Report delay (in ms)</string>
|
||||||
|
<string name="interval">Interval</string>
|
||||||
|
<string name="heart_rate_interval_description">Interval between heart rate measurements</string>
|
||||||
|
<string name="no_heart_rate_data">No heart rate data</string>
|
||||||
|
<string name="one_second">1 second</string>
|
||||||
|
<string name="one_minute">1 minute</string>
|
||||||
|
<string name="permission_healthconnect">Write heart rate to Health Connect</string>
|
||||||
|
<string name="permission_description_healthconnect">Allows the app to write heart rate data to Android\'s Health Connect platform.</string>
|
||||||
|
<string name="required_permissions">Required Permissions</string>
|
||||||
|
<string name="bluetooth">Bluetooth</string>
|
||||||
|
<string name="permission_description_bluetooth">Required to communicate with AirPods.</string>
|
||||||
|
<string name="optional_permissions">Optional Permissions</string>
|
||||||
|
<string name="notifications">Notifications</string>
|
||||||
|
<string name="permission_description_notification">Show battery status when connected or available nearby.</string>
|
||||||
|
<string name="permission_description_phone">Respond to phone calls with head gestures.</string>
|
||||||
|
<string name="permission_overlay">Display over other apps</string>
|
||||||
|
<string name="permission_description_overlay">Show popups when AirPods are nearby or audio switches to them.</string>
|
||||||
|
<string name="swipe_anywhere_to_go_back">Swipe anywhere to go back</string>
|
||||||
|
<string name="use_highest_refresh_rate">Use highest refresh rate available</string>
|
||||||
|
<string name="debug_mode_description">Shows extra information and settings.</string>
|
||||||
|
<string name="interaction">Interaction</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -6,5 +6,6 @@ plugins {
|
|||||||
alias(libs.plugins.ksp) apply false
|
alias(libs.plugins.ksp) apply false
|
||||||
alias(libs.plugins.room3) apply false
|
alias(libs.plugins.room3) apply false
|
||||||
alias(libs.plugins.android.library) apply false
|
alias(libs.plugins.android.library) apply false
|
||||||
|
alias (libs.plugins.protobuf) apply false
|
||||||
// alias(libs.plugins.hilt) apply false
|
// alias(libs.plugins.hilt) apply false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,12 +26,10 @@ navevent = "1.1.2"
|
|||||||
m3 = "1.5.0-alpha23"
|
m3 = "1.5.0-alpha23"
|
||||||
ksp = "2.3.10"
|
ksp = "2.3.10"
|
||||||
room = "3.0.0"
|
room = "3.0.0"
|
||||||
ktxSerialization = "1.11.0"
|
protobufPlugin = "0.10.0"
|
||||||
junit = "4.13.2"
|
protobuf = "4.35.1"
|
||||||
junitVersion = "1.1.5"
|
healthConnect = "1.2.0-alpha05"
|
||||||
espressoCore = "3.5.1"
|
foundationLayout = "1.12.0"
|
||||||
appcompat = "1.6.1"
|
|
||||||
material = "1.10.0"
|
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
|
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
|
||||||
@@ -71,11 +69,10 @@ androidx-navigationevent = { module = "androidx.navigationevent:navigationevent"
|
|||||||
androidx-room3-runtime = { group = "androidx.room3", name = "room3-runtime", version.ref = "room" }
|
androidx-room3-runtime = { group = "androidx.room3", name = "room3-runtime", version.ref = "room" }
|
||||||
androidx-room3-compiler = { group = "androidx.room3", name = "room3-compiler", version.ref = "room" }
|
androidx-room3-compiler = { group = "androidx.room3", name = "room3-compiler", version.ref = "room" }
|
||||||
kotlinx-serialization-cbor = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-cbor", version = "ktxSerialization" }
|
kotlinx-serialization-cbor = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-cbor", version = "ktxSerialization" }
|
||||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
protobuf-kotlin = { group = "com.google.protobuf", name = "protobuf-kotlin", version.ref = "protobuf" }
|
||||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" }
|
||||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
androidx-healthconnect-client = { group = "androidx.health.connect", name = "connect-client", version.ref = "healthConnect" }
|
||||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
androidx-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" }
|
||||||
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
@@ -86,3 +83,4 @@ ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
|||||||
room3 = { id = "androidx.room3", version.ref = "room" }
|
room3 = { id = "androidx.room3", version.ref = "room" }
|
||||||
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||||
android-library = { id = "com.android.library", version.ref = "agp" }
|
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||||
|
protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" }
|
||||||
|
|||||||
Reference in New Issue
Block a user