diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 2fd88956..14814371 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.ksp) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.room3) + alias(libs.plugins.protobuf) // alias(libs.plugins.hilt) id("kotlin-parcelize") } @@ -31,13 +32,30 @@ room3 { schemaDirectory("$projectDir/schemas") } +protobuf { + protoc { + artifact = libs.protobuf.protoc.get().toString() + } + + generateProtoTasks { + all().forEach { task -> + task.builtins { + create("java") + create("kotlin") + } + } + } +} + kotlin { compilerOptions { optIn.addAll( "androidx.compose.material3.ExperimentalMaterial3ExpressiveApi", "kotlin.uuid.ExperimentalUuidApi", "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(libs.accompanist.permissions) implementation(libs.androidx.core.ktx) + implementation(libs.androidx.foundation.layout) implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) @@ -169,8 +188,9 @@ dependencies { implementation(libs.androidx.room3.runtime) ksp(libs.androidx.room3.compiler) implementation(libs.kotlinx.serialization.cbor) - + implementation(libs.protobuf.kotlin) // compileOnly(files("../../../framework-classes.jar")) + implementation(libs.androidx.healthconnect.client) } aboutLibraries { diff --git a/android/app/schemas/me.kavishdevar.librepods.database.LibrePodsDatabase/1.json b/android/app/schemas/me.kavishdevar.librepods.database.LibrePodsDatabase/1.json index 674a5c26..e22a1ab7 100644 --- a/android/app/schemas/me.kavishdevar.librepods.database.LibrePodsDatabase/1.json +++ b/android/app/schemas/me.kavishdevar.librepods.database.LibrePodsDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "ae2e348e0198b8d871e9e3ed6959ab30", + "identityHash": "7a502eac34ab57e1c12ba8f849e172cf", "entities": [ { "tableName": "AppleEntity", @@ -42,7 +42,7 @@ }, { "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": [ { "fieldPath": "id", @@ -62,6 +62,12 @@ "affinity": "TEXT", "notNull": true }, + { + "fieldPath": "useHighestRefreshRate", + "columnName": "useHighestRefreshRate", + "affinity": "INTEGER", + "notNull": true + }, { "fieldPath": "debugMode", "columnName": "debugMode", @@ -79,6 +85,12 @@ "columnName": "bleReportDelay", "affinity": "INTEGER", "notNull": true + }, + { + "fieldPath": "swipeAnywhereForBack", + "columnName": "swipeAnywhereForBack", + "affinity": "INTEGER", + "notNull": true } ], "primaryKey": { @@ -163,11 +175,41 @@ "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": [ "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')" ] } } \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 526a6ed6..cd55e20a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -27,7 +27,8 @@ android:name="android.permission.INTERACT_ACROSS_USERS" tools:ignore="ProtectedPermissions" /> + android:name="android.permission.MODIFY_AUDIO_ROUTING" + tools:ignore="ProtectedPermissions" /> @@ -48,6 +49,8 @@ + + - + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/LibrePodsApplication.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/LibrePodsApplication.kt index 3317d188..b5000707 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/LibrePodsApplication.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/LibrePodsApplication.kt @@ -1,6 +1,7 @@ package me.kavishdevar.librepods import android.app.Application +import androidx.health.connect.client.HealthConnectClient import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner @@ -12,7 +13,10 @@ import me.kavishdevar.librepods.billing.BillingProviderFactory import me.kavishdevar.librepods.database.LibrePodsDatabase import me.kavishdevar.librepods.repository.AppDataRepository 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.utils.GestureFeedback import me.kavishdevar.librepods.utils.XposedServiceHolder 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 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() { System.loadLibrary("hiddenapi") @@ -37,6 +53,8 @@ class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener BillingManager.provider = BillingProviderFactory.create(this) ProcessLifecycleOwner.get().lifecycle.addObserver(this) + GestureFeedback.init(this) + super.onCreate() } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt index 62e36606..2d3322eb 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/AACPManager.kt @@ -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.InformationPacket 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.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.Capability 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.MagicKeyType 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.heartrate.HeartRateSample import me.kavishdevar.librepods.devices.AppleDevice import me.kavishdevar.librepods.devices.BatteryStatus +import me.kavishdevar.librepods.utils.HeadTracking import java.nio.ByteBuffer import java.nio.ByteOrder import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.time.Clock +import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds class AACPManager(private val device: AppleDevice) { @@ -62,6 +71,8 @@ class AACPManager(private val device: AppleDevice) { private var socket: BluetoothSocket? = null + private val rtBuddyManager = RTBuddyManager(::sendPacket) + fun connect(): Boolean { if (socket != null && socket!!.isConnected) { Log.i(TAG, "Already connected") @@ -192,7 +203,7 @@ class AACPManager(private val device: AppleDevice) { ) return } - Log.d(TAG, "received packet: ${packet.toHexString()}") +// Log.d(TAG, "received packet: ${packet.toHexString()}") val opcode = packet[4] when (MessageOpcode.fromByte(opcode)) { MessageOpcode.BUD_ROLE -> { @@ -314,7 +325,28 @@ class AACPManager(private val device: AppleDevice) { } 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 -> { @@ -625,55 +657,7 @@ class AACPManager(private val device: AppleDevice) { return sendPacket(packet) } - fun sendStartHeadTracking(): Boolean { - 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 setSensorServiceReportInterval(sensorServiceType: SensorServiceType, interval: Duration): Boolean = rtBuddyManager.setSensorServiceReportInterval(sensorServiceType, interval) fun sendRename(name: String): Boolean { val packet = RenamePacket.create(name) @@ -1045,6 +1029,8 @@ class AACPManager(private val device: AppleDevice) { connectedDevices = emptyList(), microphoneFrames = emptyList(), controlStates = emptyMap(), + aacpPackets = emptyList(), + currentHeartRate = null ) } } @@ -1174,4 +1160,93 @@ class AACPManager(private val device: AppleDevice) { 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()}" + ) + } + } + } + } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/RTBuddyManager.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/RTBuddyManager.kt new file mode 100644 index 00000000..3d08dd28 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/RTBuddyManager.kt @@ -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() + ) +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/RTBuddyPacket.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/RTBuddyPacket.kt new file mode 100644 index 00000000..3214d7c2 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/packet/RTBuddyPacket.kt @@ -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, + ) + } + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/RTBuddyDescriptor.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/RTBuddyDescriptor.kt new file mode 100644 index 00000000..9da77497 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/RTBuddyDescriptor.kt @@ -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 + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/RTBuddyPayload.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/RTBuddyPayload.kt new file mode 100644 index 00000000..b445af15 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/RTBuddyPayload.kt @@ -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 diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/SensorDataWxBuddyPayload.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/SensorDataWxBuddyPayload.kt new file mode 100644 index 00000000..81b560ca --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/bluetooth/aacp/types/SensorDataWxBuddyPayload.kt @@ -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 +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/data/heartrate/HeartRateDao.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/heartrate/HeartRateDao.kt new file mode 100644 index 00000000..bc7ebff0 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/heartrate/HeartRateDao.kt @@ -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 +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/data/heartrate/HeartRateSample.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/heartrate/HeartRateSample.kt new file mode 100644 index 00000000..eb96706d --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/heartrate/HeartRateSample.kt @@ -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) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/data/updates/Updates.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/updates/Updates.kt index bcdab1fe..0d6a728c 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/data/updates/Updates.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/data/updates/Updates.kt @@ -2,7 +2,7 @@ package me.kavishdevar.librepods.data.updates import androidx.compose.runtime.Composable 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.EqualizerScreenPreviewMaterial import me.kavishdevar.librepods.presentation.theme.DesignSystem @@ -13,7 +13,7 @@ val update1_0_0 = listOf( titleRes = R.string.material3e, descriptionRes = R.string.update_m3e_description, demoComposeable = @Composable { - AirPodsSettingsScreenPreviewMaterial() + AppleSettingsScreenPreviewMaterial() } ), UpdateItem( diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/database/Converters.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/Converters.kt index af8e1905..655e8609 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/database/Converters.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/Converters.kt @@ -8,6 +8,7 @@ import me.kavishdevar.librepods.bluetooth.MacAddress import me.kavishdevar.librepods.data.apple.AppleCache import me.kavishdevar.librepods.devices.AppleMetadata import me.kavishdevar.librepods.devices.AppleSettings +import kotlin.time.Instant object Converters { @ColumnTypeConverter @@ -39,4 +40,12 @@ object Converters { @ColumnTypeConverter fun bytesToAppleCache(bytes: ByteArray): AppleCache = Cbor.decodeFromByteArray(bytes) + + @ColumnTypeConverter + fun kotlinInstantToLong(instant: Instant): Long = + instant.toEpochMilliseconds() + + @ColumnTypeConverter + fun longToKotlinInstant(millis: Long): Instant = + Instant.fromEpochMilliseconds(millis) } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/database/LibrePodsDatabase.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/LibrePodsDatabase.kt index ef16a0c3..4b26d87f 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/database/LibrePodsDatabase.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/LibrePodsDatabase.kt @@ -3,12 +3,14 @@ package me.kavishdevar.librepods.database import androidx.room3.ColumnTypeConverters import androidx.room3.Database import androidx.room3.RoomDatabase +import me.kavishdevar.librepods.data.heartrate.HeartRateDao import me.kavishdevar.librepods.database.app.AppSettingsDao import me.kavishdevar.librepods.database.app.AppSettingsEntity import me.kavishdevar.librepods.database.app.AppStateDao import me.kavishdevar.librepods.database.app.AppStateEntity import me.kavishdevar.librepods.database.apple.AppleDao 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.WidgetConfigEntity @@ -19,6 +21,7 @@ import me.kavishdevar.librepods.database.widget.WidgetConfigEntity AppSettingsEntity::class, AppStateEntity::class, WidgetConfigEntity::class, + HeartRateSampleEntity::class ], version = 1, ) @@ -29,4 +32,6 @@ abstract class LibrePodsDatabase: RoomDatabase() { abstract fun appStateDao(): AppStateDao abstract fun widgetConfigDao(): WidgetConfigDao + + abstract fun heartRateDao(): HeartRateDao } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/database/app/AppSettingsEntity.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/app/AppSettingsEntity.kt index 5457ff20..4ccfcd86 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/database/app/AppSettingsEntity.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/app/AppSettingsEntity.kt @@ -13,12 +13,12 @@ data class AppSettingsEntity( val nightMode: NightTheme = NightTheme.System, val designSystem: DesignSystem = DesignSystem.Material, + val useHighestRefreshRate: Boolean = false, - /** - * Currently only shows the button for Debug screen. - */ val debugMode: Boolean = false, val bleScanMode: Int = ScanSettings.SCAN_MODE_BALANCED, val bleReportDelay: Long = 0, + + val swipeAnywhereForBack: Boolean = true, ) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/database/heartrate/HeartRateSampleEntity.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/heartrate/HeartRateSampleEntity.kt new file mode 100644 index 00000000..0b036852 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/database/heartrate/HeartRateSampleEntity.kt @@ -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, +) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleDevice.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleDevice.kt index 725c680c..5267dc63 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleDevice.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleDevice.kt @@ -18,12 +18,17 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import me.kavishdevar.librepods.bluetooth.MacAddress 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.ControlCommandIdentifier import me.kavishdevar.librepods.bluetooth.att.ATTHandle import me.kavishdevar.librepods.bluetooth.att.ATTManager 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.seconds private const val TAG = "AppleDevice" @@ -77,6 +82,12 @@ class AppleDevice( val aacp = AACPManager(this) val att = ATTManager(this) + + // ik, probably wrong place. TODO + private val gestureDetector by lazy { + GestureDetector() + } + init { updateMetadata { it.copy( @@ -205,25 +216,35 @@ class AppleDevice( } fun startHeadTracking() { - if (settings.value.alternateHeadTrackingPackets) { - aacp.sendStartAlternateHeadTracking() - } else { - aacp.sendStartHeadTracking() - } + aacp.setSensorServiceReportInterval( + sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 8) SensorServiceType.DEVMOTION6 else SensorServiceType.ACTIVITY, + interval = _settings.value.headTrackingInterval + ) _state.update { it.copy(headTrackingActive = true) } } fun stopHeadTracking() { - if (settings.value.alternateHeadTrackingPackets) { - aacp.sendStopAlternateHeadTracking() - } else { - aacp.sendStopHeadTracking() - } + aacp.setSensorServiceReportInterval( + sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 8) SensorServiceType.DEVMOTION6 else SensorServiceType.ACTIVITY, + interval = Duration.ZERO + ) _state.update { 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) { @@ -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) { 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)) } - fun testHeadGestures() { - if (settings.value.alternateHeadTrackingPackets) { - aacp.sendStartAlternateHeadTracking() - } else { - aacp.sendStartHeadTracking() - } - _state.update { + // AACPManager sets hrmActive true when a valid reading is received + fun startHr(): Boolean = aacp.setSensorServiceReportInterval( + sensorServiceType = if (metadata.value.version3.first().digitToInt() >= 8) SensorServiceType.HEARTRATE_COMMAND else SensorServiceType.HEARTRATE, + interval = 1.seconds + ) + + 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( - detectHeadGestures = true + hrmActive = false, + currentHeartRate = null ) } + return success } fun sendRawPacket(data: ByteArray): Boolean = aacp.sendRawPacket(data) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleSettings.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleSettings.kt index 6465f2e4..a1893466 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleSettings.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleSettings.kt @@ -2,6 +2,8 @@ package me.kavishdevar.librepods.devices import kotlinx.serialization.Serializable import me.kavishdevar.librepods.data.StemAction +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds @Serializable data class AppleSettings( @@ -10,6 +12,9 @@ data class AppleSettings( val cacheDisconnectedComponentBattery: Boolean = true, 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 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 showBottomSheetPopup: Boolean = true, // show_bottom_sheet_popup - val alternateHeadTrackingPackets: Boolean = true, // use_alternate_head_tracking_packets - val takeoverWhenDisconnected: Boolean = true, // takeover_when_disconnected val takeoverWhenIdle: Boolean = true, // takeover_when_idle val takeoverWhenMusic: Boolean = true, // takeover_when_music diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleState.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleState.kt index f06aafcf..4fa53cd2 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleState.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/devices/AppleState.kt @@ -11,7 +11,10 @@ import me.kavishdevar.librepods.bluetooth.aacp.types.CustomEq import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType import me.kavishdevar.librepods.data.audio.MicrophoneFrame import me.kavishdevar.librepods.data.audio.MicrophoneState +import me.kavishdevar.librepods.data.heartrate.HeartRateSample import me.kavishdevar.librepods.data.recording.RecordingState +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds data class AppleState( val isLocallyConnected: Boolean = false, @@ -51,5 +54,9 @@ data class AppleState( val headphoneAccomodationEnabledForMedia: Boolean = false, val headphoneAccomodationEnabledForPhone: Boolean = false, + val currentHeartRate: HeartRateSample? = null, + val hrmActive: Boolean = false, + val heartRateInterval: Duration = 1.seconds, + val aacpPackets: List = emptyList(), ): DeviceState diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/MainActivity.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/MainActivity.kt index b95dfcd6..9d5d8ac3 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/MainActivity.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/MainActivity.kt @@ -28,9 +28,11 @@ import android.content.Context import android.content.Context.MODE_PRIVATE import android.content.Intent import android.content.ServiceConnection +import android.hardware.display.DisplayManager import android.os.Bundle import android.os.IBinder import android.util.Log +import android.view.Display import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge @@ -58,8 +60,8 @@ import me.kavishdevar.librepods.services.LibrePodsService import me.kavishdevar.librepods.utils.XposedState import kotlin.io.encoding.ExperimentalEncodingApi -lateinit var serviceConnection: ServiceConnection -lateinit var connectionStatusReceiver: BroadcastReceiver +private lateinit var serviceConnection: ServiceConnection +private lateinit var connectionStatusReceiver: BroadcastReceiver //lateinit var testReviewReceiver: BroadcastReceiver class MainActivity : ComponentActivity() { @@ -95,6 +97,22 @@ class MainActivity : ComponentActivity() { 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( designSystem = settings.designSystem, darkTheme = darkTheme diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/NoiseControlWidgetConfigurationActivity.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/NoiseControlWidgetConfigurationActivity.kt index d9d7888b..d1af77a6 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/NoiseControlWidgetConfigurationActivity.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/NoiseControlWidgetConfigurationActivity.kt @@ -7,7 +7,6 @@ import android.content.Intent import android.content.ServiceConnection import android.os.Bundle import android.os.IBinder -import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent 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.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -34,9 +34,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import me.kavishdevar.librepods.LibrePodsApplication import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.bluetooth.MacAddress import me.kavishdevar.librepods.database.widget.WidgetConfigEntity 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.StyledList import me.kavishdevar.librepods.presentation.components.StyledListItem @@ -164,13 +164,15 @@ private fun WidgetDevicePickerContent( ) { StyledScaffold( title = stringResource(R.string.configure_widget), - ) { + navigateBack = null + ) { topPadding, bottomPadding -> Column( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp) ) { + Spacer(modifier = Modifier.size(topPadding)) if (devices.isEmpty()) { Text( 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)) } } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/PrivacyPolicyActivity.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/PrivacyPolicyActivity.kt new file mode 100644 index 00000000..7ec7b838 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/activities/PrivacyPolicyActivity.kt @@ -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)) + } + } + } + } + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledList.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledList.kt index 0e8498ee..d3433f49 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledList.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledList.kt @@ -122,12 +122,13 @@ fun StyledListDemo() { darkTheme = false ) { StyledScaffold( - title = "StyledListTest" - ) { + title = "StyledListTest", + navigateBack = null + ) { topPadding, bottomPadding -> Column ( modifier = Modifier.padding(horizontal = 12.dp) ) { - Spacer(modifier = Modifier.height(56.dp)) + Spacer(modifier = Modifier.height(topPadding)) StyledList( title = "hello" ) { @@ -145,6 +146,7 @@ fun StyledListDemo() { onCheckedChange = { checked.value = it }, ) } + Spacer(modifier = Modifier.height(bottomPadding)) } } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledListItem.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledListItem.kt index b1c39517..39b4ad65 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledListItem.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledListItem.kt @@ -668,7 +668,7 @@ fun StyledListScope.StyledListItem( Text( text = supportingText, 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, @@ -805,25 +805,27 @@ private fun StyledListItemContent( } ) .pointerInput(Unit) { - detectTapGestures( - onPress = { - if (enabled) { - backgroundColor = pressedColor - tryAwaitRelease() - backgroundColor = surfaceColor - } - }, - onTap = { - if (enabled) { - scope.launch { - haptics.performHapticFeedback( - HapticFeedbackType.ContextClick - ) + if (onClick != null) { + detectTapGestures( + onPress = { + if (enabled) { + backgroundColor = pressedColor + tryAwaitRelease() + backgroundColor = surfaceColor + } + }, + onTap = { + if (enabled) { + scope.launch { + haptics.performHapticFeedback( + HapticFeedbackType.ContextClick + ) + } + onClick.invoke() } - onClick?.invoke() } - } - ) + ) + } } .heightIn(min = height) .padding(horizontal = 16.dp) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledScaffold.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledScaffold.kt index 3849b84c..6d205f2e 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledScaffold.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledScaffold.kt @@ -18,6 +18,8 @@ package me.kavishdevar.librepods.presentation.components +import android.graphics.RenderEffect +import android.graphics.Shader import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween @@ -27,6 +29,7 @@ import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.shadow 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.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.LocalWindowInfo import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import com.kyant.backdrop.backdrops.LayerBackdrop @@ -75,6 +85,9 @@ import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState 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.LocalDesignSystem @@ -84,11 +97,10 @@ fun StyledScaffold( modifier: Modifier = Modifier, visible: Boolean = true, title: String, - showBackButton: Boolean = false, - onNavigateBack: () -> Unit = {}, + navigateBack: (() -> Unit)?, actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(), snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, - content: @Composable () -> Unit + content: @Composable (topPadding: Dp, bottomPadding: Dp) -> Unit ) { val hazeState = rememberHazeState(blurEnabled = true) @@ -105,14 +117,18 @@ fun StyledScaffold( ) { TopAppBar( navigationIcon = { - if (showBackButton) { + if (navigateBack != null) { Row { Spacer(modifier = Modifier.width(12.dp)) FilledTonalIconButton( - onClick = onNavigateBack, + onClick = navigateBack, modifier = Modifier .minimumInteractiveComponentSize() - .size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Narrow)), + .size( + IconButtonDefaults.mediumContainerSize( + IconButtonDefaults.IconButtonWidthOption.Narrow + ) + ), shape = IconButtonDefaults.mediumRoundShape ) { Icon( @@ -130,7 +146,7 @@ fun StyledScaffold( text = it, maxLines = 1, 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 ) } @@ -146,15 +162,20 @@ fun StyledScaffold( } }, ) { paddingValues -> - Column( + Box( 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() .hazeSource(hazeState) ) { - Spacer(modifier = Modifier.height(paddingValues.calculateTopPadding())) - content() - Spacer(modifier = Modifier.height(paddingValues.calculateBottomPadding())) + content(paddingValues.calculateTopPadding(), paddingValues.calculateBottomPadding()) } } } @@ -173,6 +194,7 @@ fun StyledScaffold( ) ) { paddingValues -> val topPadding = paddingValues.calculateTopPadding() + val bottomPadding = paddingValues.calculateBottomPadding() val startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current) val endPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current) @@ -184,31 +206,80 @@ fun StyledScaffold( ) { val backdrop = rememberLayerBackdrop() val bgColor = MaterialTheme.colorScheme.surfaceContainer - AnimatedVisibility( - visible = showBackButton, - enter = fadeIn() + scaleIn( - initialScale = 0f, - animationSpec = tween() - ), - exit = fadeOut() + scaleOut( - targetScale = 0.5f, - animationSpec = tween(100) - ), - modifier = Modifier - .zIndex(3f) - .padding(top = topPadding, start = 8.dp) - .align(Alignment.TopStart) - ) { - StyledIconButton( - onClick = onNavigateBack, - backdrop = backdrop - ) { - Icon( - imageVector = LocalIcons.current.ArrowBack, - contentDescription = "Back", - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onBackground - ) + + val density = LocalDensity.current + val screenWidthPx = with(density) { + LocalWindowInfo.current.containerDpSize.width.toPx() + } + val isCurrentEntry = LocalIsCurrentEntry.current + val transitionProgress = LocalTransitionProgress.current + val sharedTransitionScope = LocalSharedTransitionScope.current + + val showBackButton = if (transitionProgress == 0f) navigateBack != null else !isCurrentEntry + + if (showBackButton) { + with(sharedTransitionScope) { + Box( + modifier = Modifier + .zIndex(3f) + .padding(top = topPadding, start = 8.dp) + .align(Alignment.TopStart) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + awaitFirstDown(requireUnconsumed = false) + + 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) .padding(top = topPadding, end = 8.dp) .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{ actionButtons.forEach { actionButton -> @@ -283,7 +369,7 @@ fun StyledScaffold( .hazeSource(hazeState) .fillMaxSize() ) { - content() + content(topPadding + 64.dp, bottomPadding) } } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledSlider.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledSlider.kt index 37813b2b..6e7ddcbc 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledSlider.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/components/StyledSlider.kt @@ -759,12 +759,13 @@ fun StyledSliderPreview() { ) { StyledScaffold( title = "test", - ) { + navigateBack = null + ) { topPadding, bottomPadding -> Column( modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Spacer(modifier = Modifier.height(72.dp)) + Spacer(modifier = Modifier.height(topPadding)) StyledSlider( value = a.floatValue, onValueChange = { @@ -803,6 +804,7 @@ fun StyledSliderPreview() { independent = true, description = stringResource(R.string.adaptive_audio_description), ) + Spacer(modifier = Modifier.height(bottomPadding)) } } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/AppleIcons.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/AppleIcons.kt index 6822e910..bd35aedd 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/AppleIcons.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/AppleIcons.kt @@ -1248,4 +1248,54 @@ object AppleIcons: IconSet { override val CircleDotted: ImageVector 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 } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/IconSet.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/IconSet.kt index 0515c235..3d87c399 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/IconSet.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/IconSet.kt @@ -55,6 +55,8 @@ interface IconSet { val CircleDotted: ImageVector + val VitalSigns: ImageVector + /* * AirPods Icons */ @@ -155,6 +157,7 @@ interface IconSet { "BoltCircle" to BoltCircle, "Circle" to Circle, "CircleDotted" to CircleDotted, + "VitalSign" to VitalSigns, "AirPods1" to AirPods1, "AirPods1Case" to AirPods1Case, diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/MaterialIcons.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/MaterialIcons.kt index c8877b3d..dfc83155 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/MaterialIcons.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/icons/MaterialIcons.kt @@ -1658,4 +1658,69 @@ object MaterialIcons: IconSet { override val CircleDotted: ImageVector 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 } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt index 34338860..2e2f12d0 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt @@ -1,656 +1,112 @@ package me.kavishdevar.librepods.presentation.navigation 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.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.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.lifecycle.viewmodel.initializer -import androidx.lifecycle.viewmodel.viewModelFactory +import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavEntry 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.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.ConnectionState 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 fun AppNavGraph( + backStack: NavBackStack, + devicesState: State>>, showReleaseNotes: Boolean = false, updatesShown: () -> Unit = {}, onboardingComplete: () -> Unit = {}, - backStack: SnapshotStateList, - devicesState: State>> ) { + val backRequests = remember { Channel>(Channel.BUFFERED) } + val devices by devicesState - - val navigate: (Screen) -> Unit = { screen -> - backStack.add(screen) - } - - fun navigateToPurchase() { - navigate(Screen.Purchase) - } - val context = LocalContext.current 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<*, *, *>? = - devices[backStack.lastOrNull()?.let { (it as? DeviceScreen)?.macAddress }] + val currentDevice: Device<*, *, *>? = devices[backStack.lastOrNull()?.let { (it as? DeviceScreen)?.macAddress }] @SuppressLint("UnrememberedMutableState") - val currentConnectionState by (currentDevice as? AppleDevice)?.connectionState?.collectAsState() - ?: mutableStateOf(ConnectionState.DISCONNECTED) + val currentConnectionState by (currentDevice as? AppleDevice)?.connectionState?.collectAsState()?: mutableStateOf(ConnectionState.DISCONNECTED) - if (currentConnectionState == ConnectionState.DISCONNECTED) { - // 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 - while (backStack.isNotEmpty() && backStack.last() is DeviceScreen && (backStack.last() as DeviceScreen).macAddress == currentDevice?.macAddress) { - backStack.removeAt(backStack.lastIndex) + LaunchedEffect(currentConnectionState) { + if (currentConnectionState == ConnectionState.DISCONNECTED) { + while ( + backStack.size > 1 && + backStack.lastOrNull() is DeviceScreen + ) { + val completed = CompletableDeferred() + backRequests.send(completed) + completed.await() + } } } - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + val appSettings by appDataRepository.settings.collectAsState() - SharedTransitionLayout { - NavDisplay( - sharedTransitionScope = this, - backStack = backStack, - onBack = { + val sceneStrategy = remember(appSettings.swipeAnywhereForBack) { + SwipeBackSceneStrategy( + enabled = appSettings.swipeAnywhereForBack, + backRequests = backRequests, + onDismiss = { if (backStack.size > 1) { 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 } + ) + } + } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt index c30b6ee9..aa487d6c 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt @@ -1,35 +1,26 @@ 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.LaunchedEffect import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import com.kyant.backdrop.backdrops.LayerBackdrop -import me.kavishdevar.librepods.R +import androidx.compose.runtime.saveable.rememberSerializable +import androidx.navigation3.runtime.NavBackStack +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.serialization.serializer import me.kavishdevar.librepods.bluetooth.MacAddress import me.kavishdevar.librepods.devices.AppleDevice +import me.kavishdevar.librepods.devices.ConnectionState import me.kavishdevar.librepods.devices.Device -import me.kavishdevar.librepods.presentation.components.StyledIconButton -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 + +@Composable +fun rememberScreenNavBackStack(vararg elements: Screen): NavBackStack { + return rememberSerializable(serializer = serializer()) { + NavBackStack(*elements) + } +} @Composable fun NavigationRoot( @@ -41,133 +32,33 @@ fun NavigationRoot( ) { val devices by devicesState - val backStack = remember { - mutableStateListOf( - when { - showOnboarding -> Screen.Onboarding - showReleaseNotes -> Screen.ReleaseNotes - else -> Screen.DeviceList + val backStack = rememberScreenNavBackStack( + when { + showOnboarding -> Screen.Onboarding + showReleaseNotes -> Screen.ReleaseNotes + 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() - - 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 - ) - } - } + if (targetScreen !in backStack) { + backStack.add(targetScreen) } - ) - else -> listOf() + } } - StyledScaffold( - visible = currentScreen.showTopBar, - title = title, - showBackButton = backStack.size > 1, - onNavigateBack = { backStack.removeAt(backStack.lastIndex) }, - actionButtons = actionButtons - ) { - AppNavGraph( - showReleaseNotes = showReleaseNotes, - updatesShown = updatesShown, - onboardingComplete = onboardingComplete, - backStack = backStack, - devicesState = devicesState, - ) - } + AppNavGraph( + backStack = backStack, + devicesState = devicesState, + showReleaseNotes = showReleaseNotes, + updatesShown = updatesShown, + onboardingComplete = onboardingComplete, + ) } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/RenderScreenContent.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/RenderScreenContent.kt new file mode 100644 index 00000000..3cf9a16a --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/RenderScreenContent.kt @@ -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, + backRequests: Channel>, + devices: Map>, + 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() + 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 + ) + } + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/Screen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/Screen.kt index 81181177..321f9794 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/Screen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/Screen.kt @@ -30,9 +30,6 @@ sealed interface Screen: NavKey { @Serializable data object AppSettings: Screen - @Serializable - data object Troubleshooting: Screen - @Serializable data class HeadTracking( override val macAddress: MacAddress @@ -126,6 +123,11 @@ sealed interface Screen: NavKey { @Serializable data object BLESettings: Screen + + @Serializable + data class HeartRate( + override val macAddress: MacAddress + ): DeviceScreen } @Serializable diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/SwipeBack.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/SwipeBack.kt new file mode 100644 index 00000000..2417b2bd --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/SwipeBack.kt @@ -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( + private val enabled: Boolean, + private val backRequests: Channel>, + private val onDismiss: () -> Unit +) : SceneStrategy { + + private var previousEntries: List> = emptyList() + + override fun SceneStrategyScope.calculateScene( + entries: List> + ): Scene? { + 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 { +// override val key: Any +// get() = "${currentEntry.contentKey}_${currentEntry.hashCode()}" +// +// override val entries: List> +// get() = listOf(currentEntry) +// +// override val previousEntries: List> +// get() = emptyList() +// +// override val content: @Composable () -> Unit +// get() = { currentEntry.Content() } +// } +// } + + return object : Scene { + override val key: Any + get() = "${currentEntry.contentKey}_${currentEntry.hashCode()}" + + override val entries: List> + get() = listOfNotNull(previousEntry, currentEntry) + + override val previousEntries: List> + 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 SwipeBackSceneContent( + previousEntry: NavEntry?, + currentEntry: NavEntry, + direction: Direction, + swipeEnabled: Boolean, + backRequests: Channel>, + 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() + } + } + } + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/Transition.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/Transition.kt new file mode 100644 index 00000000..96051e89 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/navigation/Transition.kt @@ -0,0 +1,11 @@ +package me.kavishdevar.librepods.presentation.navigation + +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.runtime.compositionLocalOf + +val LocalSharedTransitionScope = compositionLocalOf { + error("LocalSharedTransitionScope not provided") +} + +val LocalTransitionProgress = compositionLocalOf { 0f } +val LocalIsCurrentEntry = compositionLocalOf { false } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt index 9f243334..56a9d698 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt @@ -29,14 +29,10 @@ 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.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape 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.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.icons.LocalIcons 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.viewmodel.AppSettingsViewModel import me.kavishdevar.librepods.utils.XposedState @@ -89,8 +85,8 @@ import java.util.concurrent.TimeUnit @Composable fun AppSettingsScreen( viewModel: AppSettingsViewModel = viewModel(), + navigateBack: (() -> Unit)?, navigateToPurchase: () -> Unit, - navigateToTroubleshooting: () -> Unit, navigateToOpenSourceLicenses: () -> Unit, navigateToReleaseNotesScreen: () -> Unit, navigateToBleSettingsScreen: () -> Unit @@ -107,237 +103,263 @@ fun AppSettingsScreen( val subjectFocusRequester = remember { FocusRequester() } val descriptionFocusRequester = remember { FocusRequester() } - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - val topPadding = if (m3eEnabled) 16.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp - val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + StyledScaffold( + title = stringResource(R.string.settings), + 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( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - .layerBackdrop(backdrop) - .verticalScroll(scrollState) - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) - - if (!state.isPremium && state.state.hasConnectedToAACP) { - 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 (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 + if (!state.isPremium && state.state.hasConnectedToAACP) { + 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 ) - })", - 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( - contentText = stringResource(R.string.ble_settings), - supportingText = stringResource(R.string.do_not_change), - orientation = StyledListItemOrientation.Vertical, - onClick = navigateToBleSettingsScreen - ) - - StyledToggle( - label = stringResource(R.string.enable_debug_mode), - checked = state.settings.debugMode, - onCheckedChange = { checked -> - viewModel.updateSettings { - it.copy(debugMode = checked) + 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 + ) + } + + 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)) - 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( - contentText = stringResource(R.string.troubleshooting), - onClick = navigateToTroubleshooting, + contentText = stringResource(R.string.ble_settings), + orientation = StyledListItemOrientation.Vertical, + onClick = navigateToBleSettingsScreen ) } - } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(16.dp)) - StyledList(title = stringResource(R.string.contact)) { - StyledListItem( - contentText = stringResource(R.string.email), - supportingText = stringResource(R.string.contact_email_supporting_text), - orientation = StyledListItemOrientation.Vertical, - onClick = { contactBottomSheet.value = true }, - ) + StyledList(title = stringResource(R.string.contact)) { + StyledListItem( + contentText = stringResource(R.string.email), + supportingText = stringResource(R.string.contact_email_supporting_text), + orientation = StyledListItemOrientation.Vertical, + 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( - 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() - } - }, + contentText = stringResource(R.string.open_source_licenses), + onClick = navigateToOpenSourceLicenses, ) - 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(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)) + Spacer(modifier = Modifier.height(bottomPadding)) // if (state.showCameraDialog) { // AlertDialog(onDismissRequest = { viewModel.setShowCameraDialog(false) }, title = { @@ -403,6 +425,7 @@ fun AppSettingsScreen( // } // }) // } + } } StyledBottomSheet( diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/BLESettingsScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/BLESettingsScreen.kt index 7d84d989..b9dcc228 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/BLESettingsScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/BLESettingsScreen.kt @@ -1,16 +1,9 @@ package me.kavishdevar.librepods.presentation.screens -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.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.statusBars import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme @@ -24,58 +17,45 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.debounce import me.kavishdevar.librepods.R import me.kavishdevar.librepods.presentation.components.StyledList 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.StyledSlider -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel import kotlin.time.Duration.Companion.seconds @Composable 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 settings = uiState.settings - Box ( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - ) { - BLESettingsScreen( - topPadding = topPadding, - bottomPadding = bottomPadding, - scanMode = settings.bleScanMode, - onScanModeChanged = { scanMode -> - viewModel.updateSettings { - it.copy(bleScanMode = scanMode) - } - }, - reportDelay = settings.bleReportDelay, - onReportDelayChanged = { reportDelay -> - viewModel.updateSettings { - it.copy(bleReportDelay = reportDelay) - } - }, - ) - } + BLESettingsScreen( + navigateBack = navigateBack, + scanMode = settings.bleScanMode, + onScanModeChanged = { scanMode -> + viewModel.updateSettings { + it.copy(bleScanMode = scanMode) + } + }, + reportDelay = settings.bleReportDelay, + onReportDelayChanged = { reportDelay -> + viewModel.updateSettings { + it.copy(bleReportDelay = reportDelay) + } + }, + ) } @Composable fun BLESettingsScreen( - topPadding: Dp = 16.dp, - bottomPadding: Dp = 16.dp, + navigateBack: (() -> Unit)? = null, scanMode: Int, onScanModeChanged: (Int) -> Unit, reportDelay: Long, @@ -83,62 +63,68 @@ fun BLESettingsScreen( ) { val scrollState = rememberScrollState() - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .verticalScroll(scrollState), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Spacer(modifier = Modifier.padding(top = topPadding)) + StyledScaffold( + title = stringResource(R.string.ble_settings), + navigateBack = navigateBack + ) { topPadding, bottomPadding -> + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.padding(top = topPadding)) - Text( - text = stringResource(R.string.do_not_change), - style = MaterialTheme.typography.bodyMedium, - 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 + Text( + text = stringResource(R.string.do_not_change), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error ) - 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) { - snapshotFlow { sliderValue.floatValue } - .debounce(1.seconds) - .collect { newValue -> - onReportDelayChanged(newValue.toLong()) - } - } + val sliderValue = remember { mutableFloatStateOf(reportDelay.toFloat()) } + + LaunchedEffect(sliderValue) { + snapshotFlow { sliderValue.floatValue } + .debounce(1.seconds) + .collect { newValue -> + onReportDelayChanged(newValue.toLong()) + } + } - StyledList(title = stringResource(R.string.ble_report_delay)) { StyledSlider( + label = stringResource(R.string.ble_report_delay), value = sliderValue.floatValue, onValueChange = { sliderValue.floatValue = it }, valueRange = 0f..1000f, 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)) } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/DeviceListScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/DeviceListScreen.kt index 5fb197bc..1ddaca81 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/DeviceListScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/DeviceListScreen.kt @@ -9,26 +9,25 @@ import androidx.compose.animation.core.Spring import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween -import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures 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.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.size -import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.rememberScrollState 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.IconButtonDefaults import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.contentColorFor +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.material3.toPath import androidx.compose.runtime.Composable 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.input.pointer.pointerInput import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.graphics.shapes.Morph 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.Device 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.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.theme.DesignSystem import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem @@ -79,388 +79,399 @@ import me.kavishdevar.librepods.presentation.utils.createAirPodsBatteryRichText import kotlin.math.min import kotlin.time.Duration.Companion.milliseconds -@Composable -fun DeviceListRoute( - devices: Map>, - 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 fun DeviceListScreen( devices: Map>, + navigateToAppSettings: () -> Unit, navigateToDevice: (MacAddress) -> Unit, - topPadding: Dp = 16.dp, - bottomPadding: Dp = 16.dp ) { val scrollState = rememberScrollState() - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .verticalScroll(scrollState), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Spacer(modifier = Modifier.padding(top = topPadding)) + StyledScaffold( + title = stringResource(R.string.app_name), + navigateBack = null, + actionButtons = listOf( + { scaffoldBackdrop -> + if (LocalDesignSystem.current == DesignSystem.Material) { + FilledTonalIconButton( + onClick = navigateToAppSettings, + modifier = Modifier + .minimumInteractiveComponentSize() + .size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)), - Log.d("DeviceListScreen", "Rendering device list with ${devices.size} devices") - - 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() - - fun ConnectionState.shape() = when (this) { - ConnectionState.DISCONNECTED -> MaterialShapes.Circle.normalized() - ConnectionState.CONNECTING -> MaterialShapes.SoftBurst.normalized() - ConnectionState.CONNECTED -> MaterialShapes.SoftBurst.normalized() - ConnectionState.DISCONNECTING -> MaterialShapes.Cookie4Sided.normalized() - ConnectionState.AVAILABLE -> MaterialShapes.Circle.normalized() - } - - 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())) + ) { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = "settings", + modifier = Modifier.size(IconButtonDefaults.mediumIconSize), + ) + } + } else { + StyledIconButton( + onClick = navigateToAppSettings, + backdrop = scaffoldBackdrop + ) { + Icon( + imageVector = LocalIcons.current.Settings, + contentDescription = "Settings", + tint = MaterialTheme.colorScheme.onBackground + ) } } + } + ) + ) { 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 { - Morph( - if (connectionState == ConnectionState.CONNECTED) MaterialShapes.SoftBurst.normalized() else MaterialShapes.Circle.normalized(), - MaterialShapes.Cookie4Sided.normalized() - ) - } + fun ConnectionState.shape() = when (this) { + ConnectionState.DISCONNECTED -> MaterialShapes.Circle.normalized() + ConnectionState.CONNECTING -> MaterialShapes.SoftBurst.normalized() + ConnectionState.CONNECTED -> MaterialShapes.SoftBurst.normalized() + ConnectionState.DISCONNECTING -> MaterialShapes.Cookie4Sided.normalized() + ConnectionState.AVAILABLE -> MaterialShapes.Circle.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 + 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(), ) - ) - } + } - var currentMorphIndex by remember { mutableIntStateOf(0) } - var morphRotationTarget by remember { mutableFloatStateOf(90f) } - - val morphProgress = remember { Animatable(0f) } - 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 - ) - ) + val connectingMorphs = remember { + buildList { + connectingShapes.zipWithNext { a, b -> + add(Morph(a, b)) } + add(Morph(connectingShapes.last(), connectingShapes.first())) } - } else { - globalRotation.stop() - morphProgress.stop() + } - morphProgress.snapTo(0f) + var previousState by remember { mutableStateOf(connectionState) } - morphProgress.animateTo( - 1f, - spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMedium + var pressed by remember { mutableStateOf(false) } + + val touchMorph = remember { + Morph( + 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( - connectionState, - previousState, - currentMorphIndex - ) { - if (connectionState == ConnectionState.CONNECTING) { - connectingMorphs[currentMorphIndex] - } else { - Morph(previousState.shape(), connectionState.shape()) + var currentMorphIndex by remember { mutableIntStateOf(0) } + var morphRotationTarget by remember { mutableFloatStateOf(90f) } + + val morphProgress = remember { Animatable(0f) } + 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 + ) + ) + } + } + } else { + globalRotation.stop() + morphProgress.stop() + + morphProgress.snapTo(0f) + + morphProgress.animateTo( + 1f, + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ) + ) + + previousState = connectionState + } } - } - val iconBackgroundColor by animateColorAsState( - targetValue = when (connectionState) { - ConnectionState.CONNECTING -> MaterialTheme.colorScheme.secondaryContainer - ConnectionState.CONNECTED -> MaterialTheme.colorScheme.primaryContainer - ConnectionState.DISCONNECTING -> MaterialTheme.colorScheme.surfaceContainer - ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.surfaceDim - ConnectionState.AVAILABLE -> MaterialTheme.colorScheme.surfaceBright - }, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessMedium - ), - label = "iconBackgroundColor" - ) + val morph = remember( + connectionState, + previousState, + currentMorphIndex + ) { + if (connectionState == ConnectionState.CONNECTING) { + connectingMorphs[currentMorphIndex] + } else { + Morph(previousState.shape(), connectionState.shape()) + } + } - val iconColor by animateColorAsState( - 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" - ) + val iconBackgroundColor by animateColorAsState( + targetValue = when (connectionState) { + ConnectionState.CONNECTING -> MaterialTheme.colorScheme.secondaryContainer + ConnectionState.CONNECTED -> MaterialTheme.colorScheme.primaryContainer + ConnectionState.DISCONNECTING -> MaterialTheme.colorScheme.surfaceContainer + ConnectionState.DISCONNECTED -> MaterialTheme.colorScheme.surfaceDim + ConnectionState.AVAILABLE -> MaterialTheme.colorScheme.surfaceBright + }, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ), + label = "iconBackgroundColor" + ) - val path = remember { Path() } - val matrix = remember { Matrix() } + val iconColor by animateColorAsState( + 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( - onClick = if (device.connectionState.collectAsState().value == ConnectionState.CONNECTED) { { navigateToDevice(macAddress) } } else null, - contentText = deviceMetadata.name, - leadingContent = { - Box( - modifier = Modifier - .size(56.dp) - .pointerInput(Unit) { - detectTapGestures( - onPress = { - pressed = true - tryAwaitRelease() - pressed = false - }, - onTap = { - CoroutineScope(Dispatchers.IO).launch { - when (connectionState) { - ConnectionState.CONNECTED -> device.disconnect() - ConnectionState.DISCONNECTED -> device.connect() - else -> {} + val path = remember { Path() } + val matrix = remember { Matrix() } + + StyledListItem( + onClick = if (device.connectionState.collectAsState().value == ConnectionState.CONNECTED) { { navigateToDevice(macAddress) } } else null, + contentText = deviceMetadata.name, + leadingContent = { + Box( + modifier = Modifier + .size(56.dp) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + pressed = true + tryAwaitRelease() + pressed = false + }, + onTap = { + CoroutineScope(Dispatchers.IO).launch { + 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 ) } - }, - 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 -> { + .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 + ) + } + }, + 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 + } + 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, color = MaterialTheme.colorScheme.onSurfaceVariant ) } - } - ConnectionState.DISCONNECTING, ConnectionState.DISCONNECTED -> { - Text( - text = macAddress.value, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + ConnectionState.CONNECTING -> { + Text( + text = stringResource(R.string.connecting), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } - ConnectionState.CONNECTING -> { - Text( - text = stringResource(R.string.connecting), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + ConnectionState.CONNECTED -> { + when (deviceState) { + is AppleState -> { + Column ( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + val deviceState = deviceState as AppleState + val deviceMetadata = deviceMetadata as AppleMetadata - ConnectionState.CONNECTED -> { - when (deviceState) { - is AppleState -> { - 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 + 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 + ) + } } } } } } - } - }, - orientation = StyledListItemOrientation.Vertical - ) + }, + orientation = StyledListItemOrientation.Vertical + ) + } } - } - Spacer(modifier = Modifier.padding(top = bottomPadding)) + Spacer(modifier = Modifier.padding(top = bottomPadding)) + } } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/LoadingScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/LoadingScreen.kt deleted file mode 100644 index 2702da10..00000000 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/LoadingScreen.kt +++ /dev/null @@ -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() - } -} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/OpenSourceLicensesScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/OpenSourceLicensesScreen.kt index c6230100..00abb461 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/OpenSourceLicensesScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/OpenSourceLicensesScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.mikepenz.aboutlibraries.ui.compose.LibrariesContainer 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.LibraryRow 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.LocalDesignSystem @Composable -fun OpenSourceLicensesScreen() { - 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 +fun OpenSourceLicensesScreen( + navigateBack: (() -> Unit)? +) { + 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( - 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 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( - libraries = libraries, - modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(top = 16.dp, bottom = bottomPadding), - contentPadding = PaddingValues(top = 16.dp, bottom = bottomPadding), + badges = LibraryBadges(version = true), - badges = LibraryBadges(version = true), + variant = LibrariesVariant.Refined, + detailMode = LibraryDetailMode.Inline, - variant = LibrariesVariant.Refined, - detailMode = LibraryDetailMode.Inline, + colors = LibraryDefaults.libraryColors( + libraryBackgroundColor = MaterialTheme.colorScheme.surface, + libraryContentColor = MaterialTheme.colorScheme.onBackground, + ), - colors = LibraryDefaults.libraryColors( - libraryBackgroundColor = MaterialTheme.colorScheme.surface, - libraryContentColor = MaterialTheme.colorScheme.onBackground, - ), + variantColors = LibraryDefaults.m3VariantColors( + rowBackground = MaterialTheme.colorScheme.surfaceContainer, + rowOnBackground = MaterialTheme.colorScheme.onSurface, + rowExpandedBackground = MaterialTheme.colorScheme.surfaceContainer + ), - variantColors = LibraryDefaults.m3VariantColors( - rowBackground = MaterialTheme.colorScheme.surfaceContainer, - rowOnBackground = MaterialTheme.colorScheme.onSurface, - rowExpandedBackground = MaterialTheme.colorScheme.surfaceContainer - ), + divider = { + Spacer(modifier = Modifier.height(2.dp)) + }, - divider = { - Spacer(modifier = Modifier.height(2.dp)) - }, - - 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) + libraryRow = { index, library, expanded, toggle, style -> + val transition = updateTransition( + targetState = expanded, + label = "library" ) - } - } - ) - 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)) + } } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/PurchaseScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/PurchaseScreen.kt index 0d1607fb..5b0a15c5 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/PurchaseScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/PurchaseScreen.kt @@ -36,7 +36,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext 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.rememberLayerBackdrop 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.StyledButton import me.kavishdevar.librepods.presentation.components.StyledList 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.LocalDesignSystem import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel @@ -59,7 +58,7 @@ import me.kavishdevar.librepods.utils.XposedState @Composable fun PurchaseScreen( viewModel: PurchaseViewModel = viewModel(), - backStack: SnapshotStateList + navigateBack: (() -> Unit)?, ) { val context = LocalContext.current val scrollState = rememberScrollState() @@ -68,150 +67,149 @@ fun PurchaseScreen( 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 + StyledScaffold( + title = stringResource(R.string.unlock_advanced_features), + 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( - modifier = Modifier - .layerBackdrop(backdrop) - .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) + LaunchedEffect(state.isPremium) { + if (state.isPremium) { + navigateBack?.invoke() } } - } - if (!state.isPremium) { - 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) { + if (!state.isPremium) { + StyledList(title = stringResource(R.string.free_features)) { StyledListItem( - contentText = "${stringResource(R.string.hearing_aid)} (${stringResource(R.string.requires_xposed)})", - supportingText = stringResource(R.string.hearing_aid_description) - .substringBefore("\n\n"), + 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( + 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, 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(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)) } - Spacer(modifier = Modifier.height(bottomPadding)) } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/TroubleshootingScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/TroubleshootingScreen.kt deleted file mode 100644 index db9239fb..00000000 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/TroubleshootingScreen.kt +++ /dev/null @@ -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 . -*/ - -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() } - - 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(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() - } - } -} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AccessibilitySettingsScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AccessibilitySettingsScreen.kt index ea43afc5..486802c0 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AccessibilitySettingsScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AccessibilitySettingsScreen.kt @@ -18,7 +18,6 @@ package me.kavishdevar.librepods.presentation.screens.apple -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column 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.StyledList 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.StyledToggle import me.kavishdevar.librepods.presentation.icons.LocalIcons @@ -67,7 +67,12 @@ import kotlin.time.Duration.Companion.milliseconds //private var phoneMediaDebounceJob: Job? = null @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 state = uiState.state @@ -82,89 +87,88 @@ fun AccessibilitySettingsScreen(viewModel: AppleViewModel, navigateToPurchase: ( )?.toInt() == 1 - 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 + StyledScaffold( + title = stringResource(R.string.accessibility), + 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( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) - - if (!uiState.isPremium) { - 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 - ) + if (!uiState.isPremium) { + 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)) } - Spacer(modifier = Modifier.height(16.dp)) - } // val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) } // val phoneEQEnabled = remember { mutableStateOf(false) } // val mediaEQEnabled = remember { mutableStateOf(false) } - val pressSpeedOptions = mapOf( - 0.toByte() to stringResource(R.string.default_option), - 1.toByte() to stringResource(R.string.slower), - 2.toByte() to stringResource(R.string.slowest) - ) + val pressSpeedOptions = mapOf( + 0.toByte() to stringResource(R.string.default_option), + 1.toByte() to stringResource(R.string.slower), + 2.toByte() to stringResource(R.string.slowest) + ) - val selectedPressSpeedValue = - state.controlStates[ControlCommandIdentifier.DOUBLE_CLICK_INTERVAL]?.getOrNull( - 0 - ) - var selectedPressSpeed by remember { - mutableStateOf( - pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0] - ) - } + val selectedPressSpeedValue = + state.controlStates[ControlCommandIdentifier.DOUBLE_CLICK_INTERVAL]?.getOrNull( + 0 + ) + var selectedPressSpeed by remember { + mutableStateOf( + pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0] + ) + } - val pressAndHoldDurationOptions = mapOf( - 0.toByte() to stringResource(R.string.default_option), - 1.toByte() to stringResource(R.string.slower), - 2.toByte() to stringResource(R.string.slowest) - ) + val pressAndHoldDurationOptions = mapOf( + 0.toByte() to stringResource(R.string.default_option), + 1.toByte() to stringResource(R.string.slower), + 2.toByte() to stringResource(R.string.slowest) + ) - val selectedPressAndHoldDurationValue = - state.controlStates[ControlCommandIdentifier.CLICK_HOLD_INTERVAL]?.getOrNull( - 0 - ) - var selectedPressAndHoldDuration by remember { - mutableStateOf( - pressAndHoldDurationOptions[selectedPressAndHoldDurationValue] - ?: pressAndHoldDurationOptions[0] - ) - } + val selectedPressAndHoldDurationValue = + state.controlStates[ControlCommandIdentifier.CLICK_HOLD_INTERVAL]?.getOrNull( + 0 + ) + var selectedPressAndHoldDuration by remember { + mutableStateOf( + pressAndHoldDurationOptions[selectedPressAndHoldDurationValue] + ?: pressAndHoldDurationOptions[0] + ) + } - val volumeSwipeSpeedOptions = mapOf( - 1.toByte() to stringResource(R.string.default_option), - 2.toByte() to stringResource(R.string.longer), - 3.toByte() to stringResource(R.string.longest) - ) - val selectedVolumeSwipeSpeedValue = - state.controlStates[ControlCommandIdentifier.VOLUME_SWIPE_INTERVAL]?.getOrNull( - 0 + val volumeSwipeSpeedOptions = mapOf( + 1.toByte() to stringResource(R.string.default_option), + 2.toByte() to stringResource(R.string.longer), + 3.toByte() to stringResource(R.string.longest) ) - var selectedVolumeSwipeSpeed by remember { - mutableStateOf( - volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue] - ?: volumeSwipeSpeedOptions[1] - ) - } + val selectedVolumeSwipeSpeedValue = + state.controlStates[ControlCommandIdentifier.VOLUME_SWIPE_INTERVAL]?.getOrNull( + 0 + ) + var selectedVolumeSwipeSpeed by remember { + mutableStateOf( + volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue] + ?: volumeSwipeSpeedOptions[1] + ) + } // val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) } // 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( - title = stringResource(R.string.volume_swipe_speed), - description = stringResource(R.string.volume_swipe_speed_description) + title = stringResource(R.string.press_speed), + description = stringResource(R.string.press_speed_description) ) { - volumeSwipeSpeedOptions.forEach { (value, label) -> + pressSpeedOptions.forEach { (value, label) -> StyledListItem( contentText = label, - selected = selectedVolumeSwipeSpeed == label, + selected = selectedPressSpeed == label, onClick = { - selectedVolumeSwipeSpeed = label + selectedPressSpeed = label viewModel.setControlCommand( - identifier = ControlCommandIdentifier.VOLUME_SWIPE_INTERVAL, + 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( + 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) { // Text( @@ -570,6 +574,7 @@ fun AccessibilitySettingsScreen(viewModel: AppleViewModel, navigateToPurchase: ( // } // } // } - Spacer(modifier = Modifier.height(bottomPadding)) + Spacer(modifier = Modifier.height(bottomPadding)) + } } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AdaptiveStrengthScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AdaptiveStrengthScreen.kt index 96014fb1..62d42e28 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AdaptiveStrengthScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AdaptiveStrengthScreen.kt @@ -18,7 +18,6 @@ package me.kavishdevar.librepods.presentation.screens.apple -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -48,6 +47,7 @@ import kotlinx.coroutines.flow.debounce import me.kavishdevar.librepods.R import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier 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.icons.LocalIcons import me.kavishdevar.librepods.presentation.theme.DesignSystem @@ -56,68 +56,71 @@ import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel import kotlin.time.Duration.Companion.milliseconds @Composable -fun AdaptiveStrengthScreen(viewModel: AppleViewModel, navigateToPurchase: () -> Unit) { +fun AdaptiveStrengthScreen( + viewModel: AppleViewModel, + navigateBack: (() -> Unit)?, + navigateToPurchase: () -> Unit +) { val uiState by viewModel.uiState.collectAsState() val state = uiState.state 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 - - Column( - modifier = Modifier - .fillMaxSize() - .layerBackdrop(backdrop) - .background(MaterialTheme.colorScheme.surfaceContainer) - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) - if (!uiState.isPremium) { - 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 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()) + StyledScaffold( + title = stringResource(R.string.customize_adaptive_audio), + navigateBack = navigateBack + ) { topPadding, bottomPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .layerBackdrop(backdrop) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.height(topPadding)) + if (!uiState.isPremium) { + 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 sliderValue = remember { + mutableFloatStateOf(100f - (state.controlStates[ControlCommandIdentifier.AUTO_ANC_STRENGTH]?.getOrNull(0)?.toFloat() ?: 50f)) + } - StyledSlider( - label = stringResource(R.string.customize_adaptive_audio), - 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)) + LaunchedEffect(sliderValue) { + snapshotFlow { sliderValue.floatValue } + .debounce(100.milliseconds) + .collect { value -> + viewModel.setControlCommand( + ControlCommandIdentifier.AUTO_ANC_STRENGTH, + byteArrayOf((100 - value).toInt().toByte()) + ) + } + } + + StyledSlider( + 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)) + } } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AppleSettingsScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AppleSettingsScreen.kt index a867456c..c621e881 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AppleSettingsScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/AppleSettingsScreen.kt @@ -16,25 +16,17 @@ along with this program. If not, see . */ -@file:OptIn(ExperimentalEncodingApi::class) - package me.kavishdevar.librepods.presentation.screens.apple import android.annotation.SuppressLint import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box 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.heightIn -import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -44,10 +36,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import me.kavishdevar.librepods.R import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier 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.ConnectionSettings 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.PressAndHoldSettings import me.kavishdevar.librepods.presentation.components.StyledButton 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.theme.DesignSystem 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.AppleViewModel -import kotlin.io.encoding.ExperimentalEncodingApi @Composable -fun AirPodsSettingsRoute( +fun AppleSettingsRoute( viewModel: AppleViewModel, + navigateBack: (() -> Unit)?, navigateToRename: () -> Unit, navigateToHearingProtection: () -> Unit, navigateToHearingAid: () -> Unit, @@ -89,66 +79,50 @@ fun AirPodsSettingsRoute( navigateToCallControlScreen: (action: String) -> Unit, navigateToMicrophoneSettings: () -> Unit, navigateToRecordingScreen: () -> Unit, - navigateToDebugScreen: () -> Unit + navigateToDebugScreen: () -> Unit, + navigateToHeartRateScreen: () -> Unit ) { val uiState by viewModel.uiState.collectAsState() - val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - val topPadding = WindowInsets.statusBars.asPaddingValues() - .calculateTopPadding() + if (m3eEnabled) 0.dp else 84.dp - val bottomPadding = - WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + AppleSettingsScreen( + uiState = uiState, - Box( - modifier = Modifier - .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) }, + setControlCommandInt = { id, value -> viewModel.setControlCommand(id, value) }, + setControlCommandBoolean = { id, value -> viewModel.setControlCommand(id, value) }, // setControlCommandByte = { id, value -> viewModel.setControlCommand(id, value) }, // setControlCommandValue = { id, value -> viewModel.setControlCommand(id, value) }, - writeATTCharacteristic = viewModel::writeATTCharacteristic, + writeATTCharacteristic = viewModel::writeATTCharacteristic, // onAutomaticEarDetectionChanged = viewModel::setAutomaticEarDetectionEnabled, // onAutomaticConnectionChanged = viewModel::setAutomaticConnectionEnabled, - disconnect = viewModel::disconnect, + disconnect = viewModel::disconnect, - navigateToRename = navigateToRename, - navigateToHearingProtection = navigateToHearingProtection, - navigateToHearingAid = navigateToHearingAid, - navigateToLeftLongPress = navigateToLeftLongPress, - navigateToRightLongPress = navigateToRightLongPress, - navigateToPurchase = navigateToPurchase, - navigateToAdaptiveStrength = navigateToAdaptiveStrength, - navigateToEqualizer = navigateToEqualizer, - navigateToHeadTracking = navigateToHeadTracking, - navigateToAccessibility = navigateToAccessibility, - navigateToVersion = navigateToVersion, - navigateToCallControlScreen = navigateToCallControlScreen, - navigateToMicrophoneSettings = navigateToMicrophoneSettings, - navigateToRecordingScreen = navigateToRecordingScreen, - navigateToDebugScreen = navigateToDebugScreen - ) - } + navigateBack = navigateBack, + navigateToRename = navigateToRename, + navigateToHearingProtection = navigateToHearingProtection, + navigateToHearingAid = navigateToHearingAid, + navigateToLeftLongPress = navigateToLeftLongPress, + navigateToRightLongPress = navigateToRightLongPress, + navigateToPurchase = navigateToPurchase, + navigateToAdaptiveStrength = navigateToAdaptiveStrength, + navigateToEqualizer = navigateToEqualizer, + navigateToHeadTracking = navigateToHeadTracking, + navigateToAccessibility = navigateToAccessibility, + navigateToVersion = navigateToVersion, + navigateToCallControlScreen = navigateToCallControlScreen, + navigateToMicrophoneSettings = navigateToMicrophoneSettings, + navigateToRecordingScreen = navigateToRecordingScreen, + navigateToHeartRateScreen = navigateToHeartRateScreen, + navigateToDebugScreen = navigateToDebugScreen + ) } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") @Composable -fun AirPodsSettingsScreen( +fun AppleSettingsScreen( uiState: AppleUiState, - topPadding: Dp = 16.dp, - bottomPadding: Dp = 16.dp, - setControlCommandInt: (ControlCommandIdentifier, Int) -> Unit, setControlCommandBoolean: (ControlCommandIdentifier, Boolean) -> Unit, // setControlCommandByte: (ControlCommandIdentifier, Byte) -> Unit, @@ -161,6 +135,7 @@ fun AirPodsSettingsScreen( disconnect: () -> Unit, + navigateBack: (() -> Unit)?, navigateToRename: () -> Unit, navigateToHearingProtection: () -> Unit, navigateToHearingAid: () -> Unit, @@ -175,6 +150,7 @@ fun AirPodsSettingsScreen( navigateToCallControlScreen: (action: String) -> Unit, navigateToMicrophoneSettings: () -> Unit, navigateToRecordingScreen: () -> Unit, + navigateToHeartRateScreen: () -> Unit, navigateToDebugScreen: () -> Unit ) { val state = uiState.state @@ -185,113 +161,128 @@ fun AirPodsSettingsScreen( val baseCapabilities = spec.baseCapabilities - LazyColumn( - modifier = Modifier - .background(MaterialTheme.colorScheme.surfaceContainer) - .padding(horizontal = 16.dp), - ) { - item(key = "top_padding") { Spacer(modifier = Modifier.height(topPadding)) } + StyledScaffold( + title = uiState.metadata.name, + navigateBack = navigateBack + ) { topPadding, bottomPadding -> + LazyColumn( + modifier = Modifier.padding(horizontal = 16.dp), + ) { + item(key = "top_padding") { Spacer(modifier = Modifier.height(topPadding)) } - item(key = "battery") { - BatteryView( - batteryList = state.battery, - primaryImageRes = spec.primaryImageRes, - caseImageRes = spec.caseImageRes ?: R.drawable.img_airpods_pro_2_case // TODO - ) - } - item(key = "spacer_battery") { - Spacer(modifier = Modifier.height(32.dp)) - } + item(key = "battery") { + BatteryView( + batteryList = state.battery, + primaryImageRes = spec.primaryImageRes, + caseImageRes = spec.caseImageRes ?: R.drawable.img_airpods_pro_2_case // TODO + ) + } + item(key = "spacer_battery") { + Spacer(modifier = Modifier.height(32.dp)) + } - item(key = "name") { - StyledListItem( - contentText = stringResource(R.string.name), - supportingText = metadata.name, - onClick = navigateToRename, - ) - } + item(key = "name") { + StyledListItem( + contentText = stringResource(R.string.name), + supportingText = metadata.name, + onClick = navigateToRename, + ) + } - val hasHearingAidCapability = baseCapabilities.contains(BaseCapability.HEARING_AID) - val hasPPECapability = baseCapabilities.contains(BaseCapability.PPE) + val hasHearingAidCapability = baseCapabilities.contains(BaseCapability.HEARING_AID) + val hasPPECapability = baseCapabilities.contains(BaseCapability.PPE) - if (hasHearingAidCapability || hasPPECapability) { - if (hasPPECapability || uiState.vendorIdHook) { - item(key = "spacer_hearing_health") { - Spacer(modifier = Modifier.height(24.dp)) + if (hasHearingAidCapability || hasPPECapability) { + if (hasPPECapability || uiState.vendorIdHook) { + item(key = "spacer_hearing_health") { + 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")) { - item(key = "spacer_recording") { + if (metadata.version3.startsWith("8") || metadata.version3.startsWith("9")) { + 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)) } - item(key = "recording") { - StyledListItem( - contentText = stringResource(R.string.recorder), - supportingText = stringResource(R.string.recorder_description), - onClick = navigateToRecordingScreen, - orientation = StyledListItemOrientation.Vertical + 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.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) { // item(key = "spacer_camera") { Spacer(modifier = Modifier.height(16.dp)) } @@ -306,208 +297,212 @@ fun AirPodsSettingsScreen( // } // } - item(key = "upgrade_button") { - if (!uiState.isPremium) { - Spacer(modifier = Modifier.height(28.dp)) + item(key = "upgrade_button") { + if (!uiState.isPremium) { + 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( - onClick = navigateToPurchase, + onClick = disconnect, backdrop = rememberLayerBackdrop(), - modifier = Modifier.fillMaxWidth(), - maxScale = 0.05f, - surfaceColor = MaterialTheme.colorScheme.primary + isInteractive = false, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 56.dp) ) { Text( - stringResource(R.string.unlock_advanced_features), + text = stringResource(R.string.disconnect), 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 = "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) + item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) } - 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 + if (uiState.appSettings.debugMode) { + item(key = "debug") { + StyledListItem( + contentText = "debug", + onClick = navigateToDebugScreen ) - }, - 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 = "bottom_padding") { Spacer(modifier = Modifier.height(bottomPadding)) } } - - 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") @Composable -fun AirPodsSettingsScreenPreviewApple() { +fun AppleSettingsScreenPreviewApple() { LibrePodsTheme( designSystem = DesignSystem.Apple ) { @@ -515,7 +510,7 @@ fun AirPodsSettingsScreenPreviewApple() { modifier = Modifier .background(MaterialTheme.colorScheme.surfaceContainer) ) { - AirPodsSettingsScreen( + AppleSettingsScreen( uiState = AppleUiState(), setControlCommandInt = { _, _ -> }, @@ -524,6 +519,7 @@ fun AirPodsSettingsScreenPreviewApple() { disconnect = {}, + navigateBack = null, navigateToRename = {}, navigateToHearingProtection = {}, navigateToHearingAid = {}, @@ -538,6 +534,7 @@ fun AirPodsSettingsScreenPreviewApple() { navigateToCallControlScreen = {}, navigateToMicrophoneSettings = {}, navigateToRecordingScreen = {}, + navigateToHeartRateScreen = {}, navigateToDebugScreen = {} ) } @@ -547,7 +544,7 @@ fun AirPodsSettingsScreenPreviewApple() { @Preview(name = "Material") @Composable -fun AirPodsSettingsScreenPreviewMaterial() { +fun AppleSettingsScreenPreviewMaterial() { LibrePodsTheme( designSystem = DesignSystem.Material ) { @@ -555,7 +552,7 @@ fun AirPodsSettingsScreenPreviewMaterial() { modifier = Modifier .background(MaterialTheme.colorScheme.surfaceContainer) ) { - AirPodsSettingsScreen( + AppleSettingsScreen( uiState = AppleUiState(), setControlCommandInt = { _, _ -> }, @@ -564,6 +561,7 @@ fun AirPodsSettingsScreenPreviewMaterial() { disconnect = {}, + navigateBack = null, navigateToRename = {}, navigateToHearingProtection = {}, navigateToHearingAid = {}, @@ -578,6 +576,7 @@ fun AirPodsSettingsScreenPreviewMaterial() { navigateToCallControlScreen = {}, navigateToMicrophoneSettings = {}, navigateToRecordingScreen = {}, + navigateToHeartRateScreen = {}, navigateToDebugScreen = {} ) } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/CallControlScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/CallControlScreen.kt index a94771f6..7a0241b9 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/CallControlScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/CallControlScreen.kt @@ -1,19 +1,12 @@ package me.kavishdevar.librepods.presentation.screens.apple -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column 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.height -import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState 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.presentation.components.StyledList import me.kavishdevar.librepods.presentation.components.StyledListItem -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.components.StyledScaffold import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel -@OptIn(ExperimentalMaterial3Api::class) @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 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 bytes = - state.controlStates[ControlCommandIdentifier.CALL_MANAGEMENT_CONFIG]?.take( - 2 - )?.toByteArray() ?: byteArrayOf(0x00, 0x00) + val bytes = state.controlStates[ControlCommandIdentifier.CALL_MANAGEMENT_CONFIG]?.take(2)?.toByteArray() ?: byteArrayOf(0x00, 0x00) val flipped = try { bytes[1] == 0x02.toByte() } catch (e: Exception) { @@ -64,36 +53,40 @@ fun CallControlScreen(viewModel: AppleViewModel, action: String, onCallControlVa val pressOnceIsAction by remember { derivedStateOf { singlePressAction == pressOnceText } } val flippedValue = action != muteUnmuteText - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - .verticalScroll(scrollState) - .padding(top = 8.dp) - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) + StyledScaffold( + title = action, + navigateBack = navigateBack + ) { topPadding, bottomPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(top = 8.dp) + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(topPadding)) - StyledList { - StyledListItem( - contentText = pressOnceText, - selected = pressOnceIsAction, - onClick = { - singlePressAction = pressOnceText - onCallControlValueChanged(flippedValue) - } - ) + StyledList { + StyledListItem( + contentText = pressOnceText, + selected = pressOnceIsAction, + onClick = { + singlePressAction = pressOnceText + onCallControlValueChanged(flippedValue) + } + ) - StyledListItem( - contentText = pressTwiceText, - selected = !pressOnceIsAction, - onClick = { - singlePressAction = pressTwiceText - onCallControlValueChanged(!flippedValue) - } - ) + StyledListItem( + contentText = pressTwiceText, + selected = !pressOnceIsAction, + onClick = { + singlePressAction = pressTwiceText + onCallControlValueChanged(!flippedValue) + } + ) + } + + Spacer(modifier = Modifier.height(bottomPadding)) } - - Spacer(modifier = Modifier.height(bottomPadding)) } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/DebugScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/DebugScreen.kt index 122c9058..e6e13c02 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/DebugScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/DebugScreen.kt @@ -1,18 +1,12 @@ package me.kavishdevar.librepods.presentation.screens.apple import android.util.Log -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.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBars 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.delete import androidx.compose.material3.MaterialTheme @@ -26,247 +20,302 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.text.style.TextAlign -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.BatteryInfoPacket import me.kavishdevar.librepods.bluetooth.aacp.packet.ControlCommandPacket import me.kavishdevar.librepods.bluetooth.aacp.packet.EarDetectionResponsePacket 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.rtbuddy.proto.SensorServiceType 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.PacketDestination -import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation import me.kavishdevar.librepods.presentation.components.StyledButton import me.kavishdevar.librepods.presentation.components.StyledInputField import me.kavishdevar.librepods.presentation.components.StyledList 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.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel import me.kavishdevar.librepods.utils.nonScaledSp @Composable -fun DebugRoute(viewModel: AppleViewModel) { +fun DebugRoute( + viewModel: AppleViewModel, + navigateBack: (() -> Unit)? +) { val uiState by viewModel.uiState.collectAsState() - 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) - ) { - DebugScreen( - uiState = uiState, - topPadding = topPadding, - bottomPadding = bottomPadding, - sendPacket = viewModel::sendRawPacket - ) - } + DebugScreen( + uiState = uiState, + navigateBack = navigateBack, + sendPacket = viewModel::sendRawPacket, + ) } @Composable fun DebugScreen( uiState: AppleUiState, - topPadding: Dp = 16.dp, - bottomPadding: Dp = 16.dp, - sendPacket: (ByteArray) -> Boolean = { false } + navigateBack: (() -> Unit)?, + sendPacket: (ByteArray) -> Boolean, ) { val state = uiState.state - Log.d("DebugScreen", "Screen ${state.aacpPackets.size}") +// Log.d("DebugScreen", "Screen ${state.aacpPackets.size}") - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .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 - 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( + StyledScaffold( + title = "debug", + navigateBack = navigateBack + ) { topPadding, bottomPadding -> + Column( modifier = Modifier - .fillMaxWidth(), - onClick = { + .padding(horizontal = 16.dp) + .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 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 - ) - } - - 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()) - } + val hexRegex = Regex("^[0-9A-Fa-f ]+$") + if (!hexRegex.matches(inputText)) { + inputState.edit { + delete(inputText.length - 1, inputText.length) } - } 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)) + } } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/EqualizerScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/EqualizerScreen.kt index a08dd327..ebefb033 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/EqualizerScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/EqualizerScreen.kt @@ -30,20 +30,15 @@ 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.fillMaxHeight 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.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.visible import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape 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.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp 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.StyledList 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.LibrePodsTheme -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel import kotlin.math.abs @@ -96,32 +90,23 @@ import kotlin.math.roundToInt import kotlin.time.Duration.Companion.milliseconds @Composable -fun EqualizerRoute(viewModel: AppleViewModel) { +fun EqualizerRoute( + viewModel: AppleViewModel, + navigateBack: (() -> Unit)? +) { val uiState by viewModel.uiState.collectAsState() - 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) - ) { - EqualizerScreen( - uiState = uiState, - topPadding = topPadding, - bottomPadding = bottomPadding, - setCustomEqEnabled = viewModel::setCustomEqEnabled, - setCustomEq = viewModel::setCustomEq - ) - } + EqualizerScreen( + uiState = uiState, + navigateBack = navigateBack, + setCustomEqEnabled = viewModel::setCustomEqEnabled, + setCustomEq = viewModel::setCustomEq + ) } @Composable fun EqualizerScreen( uiState: AppleUiState, - topPadding: Dp = 16.dp, - bottomPadding: Dp = 16.dp, + navigateBack: (() -> Unit)?, setCustomEqEnabled: (Boolean) -> Unit, setCustomEq: (Int, Int, Int) -> Unit ) { @@ -131,102 +116,107 @@ fun EqualizerScreen( val scrollState = rememberScrollState() - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .verticalScroll(scrollState), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - val height = 200.dp - val maxOffset = with(LocalDensity.current) { height.toPx() } / 2 + StyledScaffold( + title = stringResource(R.string.equalizer), + navigateBack = navigateBack + ) { topPadding, bottomPadding -> + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + val height = 200.dp + val maxOffset = with(LocalDensity.current) { height.toPx() } / 2 - val offsets = remember(state.customEq) { - listOf( - mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.low.toFloat() / 100)), - mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.mid.toFloat() / 100)), - mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.high.toFloat() / 100)) - ) - } - - LaunchedEffect(offsets) { - snapshotFlow { - Triple( - offsets[0].floatValue, - offsets[1].floatValue, - offsets[2].floatValue + val offsets = remember(state.customEq) { + listOf( + mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.low.toFloat() / 100)), + mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.mid.toFloat() / 100)), + mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.high.toFloat() / 100)) ) } - .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) - } - } - - 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 + 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() - 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()) } - Box ( - modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer) - ) { - EqualizerScreen( - 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)))} - ) - } + EqualizerScreen( + uiState = state.value, + 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))) }, + 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 ) { val state = remember { mutableStateOf(AppleUiState()) } - Box ( - modifier = Modifier - .wrapContentHeight() - .background(MaterialTheme.colorScheme.surfaceContainer) - ) { - EqualizerScreen( - 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)))} - ) - } + + EqualizerScreen( + uiState = state.value, + 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))) }, + 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)))} + ) } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HeadTrackingScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HeadTrackingScreen.kt index eaa53d43..d473d3c2 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HeadTrackingScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HeadTrackingScreen.kt @@ -32,26 +32,28 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.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.Card import androidx.compose.material3.CardDefaults 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.Text +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -64,6 +66,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset @@ -78,14 +81,17 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch 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.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.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.AppleViewModel @@ -98,7 +104,11 @@ import kotlin.time.Duration.Companion.seconds @ExperimentalHazeMaterialsApi @OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) @Composable -fun HeadTrackingScreen(viewModel: AppleViewModel, navigateToPurchase: () -> Unit) { +fun HeadTrackingScreen( + viewModel: AppleViewModel, + navigateBack: (() -> Unit)?, + navigateToPurchase: () -> Unit +) { val uiState by viewModel.uiState.collectAsState() val settings = uiState.settings @@ -112,10 +122,6 @@ fun HeadTrackingScreen(viewModel: AppleViewModel, navigateToPurchase: () -> Unit 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("") } val coroutineScope = rememberCoroutineScope() @@ -124,137 +130,200 @@ fun HeadTrackingScreen(viewModel: AppleViewModel, navigateToPurchase: () -> Unit val scrollState = rememberScrollState() - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - .verticalScroll(scrollState), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(topPadding)) - - Column ( - modifier = Modifier - .fillMaxWidth() - .layerBackdrop(backdrop) - .padding(top = 8.dp) - .padding(horizontal = 16.dp) - ) { - - if (!uiState.isPremium) { - 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)) - } - - 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 + StyledScaffold( + title = stringResource(R.string.head_gestures), + navigateBack = navigateBack, + actionButtons = listOf( + { scaffoldBackdrop -> + if (LocalDesignSystem.current == DesignSystem.Material) { + FilledTonalIconToggleButton( + checked = uiState.state.headTrackingActive, + onCheckedChange = { if (it) viewModel.startHeadTracking() else viewModel.stopHeadTracking() }, + modifier = Modifier + .minimumInteractiveComponentSize() + .size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)), + shape = IconButtonDefaults.mediumRoundShape + ) { + Icon( + imageVector = if (uiState.state.headTrackingActive) MaterialIcons.Pause else Icons.Default.PlayArrow, + contentDescription = "Play/Pause", + modifier = Modifier.size(IconButtonDefaults.mediumIconSize), + ) + } + } else { + StyledIconButton( + onClick = if (!uiState.state.headTrackingActive) viewModel::startHeadTracking else viewModel::stopHeadTracking, + backdrop = scaffoldBackdrop + ) { + Icon( + imageVector = if (uiState.state.headTrackingActive) LocalIcons.current.Pause else LocalIcons.current.Play, + contentDescription = "Play/Pause", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onBackground + ) } } } - } - val gestureTextValue = stringResource(R.string.shake_your_head_or_nod) - StyledButton( - onClick = { - gestureText = gestureTextValue - coroutineScope.launch { - viewModel.testHeadGestures() - val accepted = viewModel.events - .filterIsInstance() - .first() - .yes - gestureText = if (accepted) "\"Yes\" gesture detected." else "\"No\" gesture detected." - } - }, - backdrop = backdrop, + ) + ) { topPadding, bottomPadding -> + Column( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - maxScale = 0.05f, - materialButtonStyle = MaterialButtonStyle.Outlined + .fillMaxSize() + .padding(horizontal = 16.dp) + .verticalScroll(scrollState), + horizontalAlignment = Alignment.CenterHorizontally ) { - 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))) + Spacer(modifier = Modifier.height(topPadding)) + + Column ( + modifier = Modifier + .fillMaxWidth() + .layerBackdrop(backdrop) + .padding(top = 8.dp) + ) { + + if (!uiState.isPremium) { + 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)) } - ) { text -> - if (shouldExplode) { - LaunchedEffect(Unit) { - CoroutineScope(coroutineScope.coroutineContext).launch { - delay(750.milliseconds) - gestureText = "" + + 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)) + 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)) } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HearingAidAdjustmentsScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HearingAidAdjustmentsScreen.kt index 7064b256..0851e7d1 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HearingAidAdjustmentsScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HearingAidAdjustmentsScreen.kt @@ -23,13 +23,9 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column 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.height -import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll 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.parseHearingAidSettingsResponse 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.StyledToggle 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 private const val TAG = "HearingAidAdjustments" @Composable -fun HearingAidAdjustmentsScreen(viewModel: AppleViewModel) { +fun HearingAidAdjustmentsScreen( + viewModel: AppleViewModel, + navigateBack: (() -> Unit)? +) { val verticalScrollState = rememberScrollState() val uiState by viewModel.uiState.collectAsState() @@ -149,83 +147,84 @@ fun HearingAidAdjustmentsScreen(viewModel: AppleViewModel) { sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::writeATTCharacteristic) } - 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 + StyledScaffold( + title = stringResource(R.string.adjustments), + 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( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - .verticalScroll(verticalScrollState) - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) + StyledSlider( + label = stringResource(R.string.amplification), + valueRange = -1f..1f, + value = amplificationSliderValue.floatValue, + onValueChange = { + amplificationSliderValue.floatValue = it + }, + startImageVector = LocalIcons.current.SpeakerMin, + endImageVector = LocalIcons.current.SpeakerMax, + independent = true, + ) - StyledSlider( - label = stringResource(R.string.amplification), - valueRange = -1f..1f, - value = amplificationSliderValue.floatValue, - onValueChange = { - amplificationSliderValue.floatValue = it - }, - startImageVector = LocalIcons.current.SpeakerMin, - endImageVector = LocalIcons.current.SpeakerMax, - independent = true, - ) + StyledToggle( + label = stringResource(R.string.swipe_to_control_amplification), + checked = state.controlStates[ControlCommandIdentifier.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(), + onCheckedChange = { viewModel.setControlCommand(ControlCommandIdentifier.HPS_GAIN_SWIPE, it) }, + description = stringResource(R.string.swipe_amplification_description) + ) - StyledToggle( - label = stringResource(R.string.swipe_to_control_amplification), - checked = state.controlStates[ControlCommandIdentifier.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(), - onCheckedChange = { viewModel.setControlCommand(ControlCommandIdentifier.HPS_GAIN_SWIPE, it) }, - description = stringResource(R.string.swipe_amplification_description) - ) + StyledSlider( + label = stringResource(R.string.balance), + valueRange = -1f..1f, + value = balanceSliderValue.floatValue, + onValueChange = { + balanceSliderValue.floatValue = it + }, + snapPoints = listOf(-1f, 0f, 1f), + startLabel = stringResource(R.string.left), + endLabel = stringResource(R.string.right), + independent = true, + ) - StyledSlider( - label = stringResource(R.string.balance), - valueRange = -1f..1f, - value = balanceSliderValue.floatValue, - onValueChange = { - balanceSliderValue.floatValue = it - }, - snapPoints = listOf(-1f, 0f, 1f), - startLabel = stringResource(R.string.left), - endLabel = stringResource(R.string.right), - independent = true, - ) + StyledSlider( + label = stringResource(R.string.tone), + valueRange = -1f..1f, + value = toneSliderValue.floatValue, + onValueChange = { + toneSliderValue.floatValue = it + }, + startLabel = stringResource(R.string.darker), + endLabel = stringResource(R.string.brighter), + independent = true, + ) - StyledSlider( - label = stringResource(R.string.tone), - valueRange = -1f..1f, - value = toneSliderValue.floatValue, - onValueChange = { - toneSliderValue.floatValue = it - }, - startLabel = stringResource(R.string.darker), - endLabel = stringResource(R.string.brighter), - independent = true, - ) + StyledSlider( + label = stringResource(R.string.ambient_noise_reduction), + valueRange = 0f..1f, + value = ambientNoiseReductionSliderValue.floatValue, + onValueChange = { + ambientNoiseReductionSliderValue.floatValue = it + }, + startLabel = stringResource(R.string.less), + endLabel = stringResource(R.string.more), + independent = true, + ) - StyledSlider( - label = stringResource(R.string.ambient_noise_reduction), - valueRange = 0f..1f, - value = ambientNoiseReductionSliderValue.floatValue, - onValueChange = { - ambientNoiseReductionSliderValue.floatValue = it - }, - startLabel = stringResource(R.string.less), - endLabel = stringResource(R.string.more), - independent = true, - ) + StyledToggle( + label = stringResource(R.string.conversation_boost), + checked = conversationBoostEnabled.value, + onCheckedChange = { conversationBoostEnabled.value = it }, + description = stringResource(R.string.conversation_boost_description) + ) - StyledToggle( - 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)) + Spacer(modifier = Modifier.height(bottomPadding)) + } } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HearingAidScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HearingAidScreen.kt index 8e758f88..efc63384 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HearingAidScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HearingAidScreen.kt @@ -24,16 +24,11 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column 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.height -import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -48,7 +43,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers 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.StyledList 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.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel -import kotlin.io.encoding.ExperimentalEncodingApi -private const val TAG = "AccessibilitySettings" +private const val TAG = "HearingAidScreen" @SuppressLint("DefaultLocale") @Composable -fun HearingAidScreen(viewModel: AppleViewModel, onNavigateHearingAidAdjustments: () -> Unit, onNavigateHearingTest: () -> Unit) { +fun HearingAidScreen( + viewModel: AppleViewModel, + navigateBack: (() -> Unit)?, + navigateToHearingAidAdjustments: () -> Unit, + navigateToHearingTest: () -> Unit +) { val verticalScrollState = rememberScrollState() val backdrop = rememberLayerBackdrop() @@ -86,35 +83,35 @@ fun HearingAidScreen(viewModel: AppleViewModel, onNavigateHearingAidAdjustments: mutableStateOf((aidStatus?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.getOrNull(0) == 0x01.toByte())) } - 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 - - Column( - modifier = Modifier - .layerBackdrop(backdrop) - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - .verticalScroll(verticalScrollState) - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) + StyledScaffold( + title = stringResource(R.string.hearing_aid), + navigateBack = navigateBack + ) { topPadding, bottomPadding -> + Column( + modifier = Modifier + .layerBackdrop(backdrop) + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + .verticalScroll(verticalScrollState) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Spacer(modifier = Modifier.height(topPadding)) // val mediaAssistEnabled = remember { mutableStateOf(false) } // val adjustMediaEnabled = remember { mutableStateOf(false) } // val adjustPhoneEnabled = remember { mutableStateOf(false) } - LaunchedEffect(hearingAidEnabled.value) { - if (hearingAidEnabled.value && !initialLoad.value) { - showDialog.value = true - } else if (!hearingAidEnabled.value && !initialLoad.value) { - viewModel.setControlCommand(ControlCommandIdentifier.HEARING_AID, byteArrayOf(0x01, 0x02)) - viewModel.setControlCommand(ControlCommandIdentifier.HEARING_ASSIST_CONFIG, 0x02.toByte()) - hearingAidEnabled.value = false + LaunchedEffect(hearingAidEnabled.value) { + if (hearingAidEnabled.value && !initialLoad.value) { + showDialog.value = true + } else if (!hearingAidEnabled.value && !initialLoad.value) { + viewModel.setControlCommand(ControlCommandIdentifier.HEARING_AID, byteArrayOf(0x01, 0x02)) + viewModel.setControlCommand(ControlCommandIdentifier.HEARING_ASSIST_CONFIG, 0x02.toByte()) + hearingAidEnabled.value = false + } + initialLoad.value = false } - initialLoad.value = false - } // fun onAdjustPhoneChange(value: Boolean) { // // TODO @@ -124,69 +121,70 @@ fun HearingAidScreen(viewModel: AppleViewModel, onNavigateHearingAidAdjustments: // // TODO // } - StyledList (title = stringResource(R.string.hearing_aid)) { - StyledToggle( - label = stringResource(R.string.hearing_aid), - checked = hearingAidEnabled.value, - onCheckedChange = { hearingAidEnabled.value = it }, + StyledList (title = stringResource(R.string.hearing_aid)) { + StyledToggle( + label = stringResource(R.string.hearing_aid), + checked = hearingAidEnabled.value, + 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( - contentText = stringResource(R.string.adjustments), - onClick = onNavigateHearingAidAdjustments, + contentText = stringResource(R.string.update_hearing_test), + 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( @@ -213,13 +211,13 @@ fun HearingAidScreen(viewModel: AppleViewModel, onNavigateHearingAidAdjustments: } val parsed = parseTransparencySettingsResponse(state.hearingAidData) if (parsed == null) { - Log.w(TAG, "transparency parse failed") + Log.w(TAG, "hearingaid parse failed") return@launch } val disabledSettings = parsed.copy(enabled = false) sendTransparencySettings(viewModel::writeATTCharacteristic, disabledSettings) } catch (e: Exception) { - Log.e(TAG, "Error disabling transparency: ${e.message}") + Log.e(TAG, "Error disabling hearingaid: ${e.message}") } } }, diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HearingProtectionScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HearingProtectionScreen.kt index 72707fbe..fbd02ac1 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HearingProtectionScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HearingProtectionScreen.kt @@ -21,14 +21,10 @@ package me.kavishdevar.librepods.presentation.screens.apple import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column 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.material3.MaterialTheme import androidx.compose.material3.Text 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.rememberLayerBackdrop 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.att.ATTHandle 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.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel @Composable -fun HearingProtectionScreen(viewModel: AppleViewModel, navigateToPurchase: () -> Unit) { +fun HearingProtectionScreen( + viewModel: AppleViewModel, + navigateBack: (() -> Unit)?, + navigateToPurchase: () -> Unit +) { val backdrop = rememberLayerBackdrop() val uiState by viewModel.uiState.collectAsState() 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 - - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - .layerBackdrop(backdrop) - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) - if (!uiState.isPremium) { - 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 - ) + StyledScaffold( + title = stringResource(R.string.hearing_protection), + navigateBack = navigateBack + ) { topPadding, bottomPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + .layerBackdrop(backdrop) + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(topPadding)) + if (!uiState.isPremium) { + 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)) } - 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( - title = stringResource(R.string.environmental_noise), - label = stringResource(R.string.loud_sound_reduction), - description = stringResource(R.string.loud_sound_reduction_description), - checked = state.loudSoundReductionEnabled, + 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.writeATTCharacteristic( - ATTHandle.LOUD_SOUND_REDUCTION, - byteArrayOf(if (it) 1.toByte() else 0.toByte()) + viewModel.setControlCommand( + ControlCommandIdentifier.PPE_TOGGLE_CONFIG, it ) }, enabled = uiState.isPremium ) - - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(bottomPadding)) } - 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)) } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HeartRateScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HeartRateScreen.kt new file mode 100644 index 00000000..db1ea9b0 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/HeartRateScreen.kt @@ -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) -> 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)) + } + } +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/MicrophoneSettingsScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/MicrophoneSettingsScreen.kt index e47c277d..81cc5b70 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/MicrophoneSettingsScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/MicrophoneSettingsScreen.kt @@ -1,16 +1,11 @@ package me.kavishdevar.librepods.presentation.screens.apple import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.height -import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme @@ -19,85 +14,77 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import me.kavishdevar.librepods.R import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier import me.kavishdevar.librepods.presentation.components.StyledList import me.kavishdevar.librepods.presentation.components.StyledListItem -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.components.StyledScaffold import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel @Composable fun MicrophoneSettingsRoute( - viewModel: AppleViewModel + viewModel: AppleViewModel, + navigateBack: (() -> Unit)? ) { val uiState by viewModel.uiState.collectAsState() 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 - Box ( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - ) { - MicrophoneSettingsScreen( - selectedMode = state.controlStates[id]?.getOrNull(0)?.toInt() ?: 0, - topPadding = topPadding, - bottomPadding = bottomPadding, - onMicrophoneSettingsChanged = { - viewModel.setControlCommand(id, it) - } - ) - } + MicrophoneSettingsScreen( + navigateBack = navigateBack, + selectedMode = state.controlStates[id]?.getOrNull(0)?.toInt() ?: 0, + onMicrophoneSettingsChanged = { + viewModel.setControlCommand(id, it) + } + ) } @Composable fun MicrophoneSettingsScreen( + navigateBack: (() -> Unit)?, selectedMode: Int, - topPadding: Dp = 16.dp, - bottomPadding: Dp = 16.dp, onMicrophoneSettingsChanged: (Int) -> Unit ) { val scrollState = rememberScrollState() - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - .verticalScroll(scrollState) - .padding(top = 8.dp) - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) + StyledScaffold( + title = stringResource(R.string.microphone_mode), + navigateBack = navigateBack + ) { topPadding, bottomPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + .verticalScroll(scrollState) + .padding(top = 8.dp) + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(topPadding)) - StyledList { - StyledListItem( - contentText = stringResource(R.string.microphone_automatic), - selected = selectedMode == 0, - onClick = { onMicrophoneSettingsChanged(0) } - ) + StyledList { + StyledListItem( + contentText = stringResource(R.string.microphone_automatic), + selected = selectedMode == 0, + onClick = { onMicrophoneSettingsChanged(0) } + ) - StyledListItem( - contentText = stringResource(R.string.microphone_always_right), - selected = selectedMode == 1, - onClick = { onMicrophoneSettingsChanged(1) } - ) + StyledListItem( + contentText = stringResource(R.string.microphone_always_right), + selected = selectedMode == 1, + onClick = { onMicrophoneSettingsChanged(1) } + ) - StyledListItem( - contentText = stringResource(R.string.microphone_always_left), - selected = selectedMode == 2, - onClick = { onMicrophoneSettingsChanged(2) } - ) + StyledListItem( + contentText = stringResource(R.string.microphone_always_left), + selected = selectedMode == 2, + onClick = { onMicrophoneSettingsChanged(2) } + ) + } + + Spacer(modifier = Modifier.height(bottomPadding)) } - - Spacer(modifier = Modifier.height(bottomPadding)) } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/PressAndHoldSettingsScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/PressAndHoldSettingsScreen.kt index 5b1bdd7c..526aae10 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/PressAndHoldSettingsScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/PressAndHoldSettingsScreen.kt @@ -24,18 +24,13 @@ import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column 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.wrapContentWidth import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme 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.unit.dp import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import me.kavishdevar.librepods.R import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier 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.StyledList import me.kavishdevar.librepods.presentation.components.StyledListItem -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation +import me.kavishdevar.librepods.presentation.components.StyledScaffold import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel import kotlin.experimental.and import kotlin.io.encoding.ExperimentalEncodingApi -@ExperimentalHazeMaterialsApi -@OptIn(ExperimentalMaterial3Api::class) @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 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", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}") - val longPressAction = if (name.lowercase() == "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 longPressAction = if (name == stringResource(R.string.left)) settings.leftLongPressAction else settings.rightLongPressAction val scrollState = rememberScrollState() - Column ( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - .verticalScroll(scrollState) - .padding(top = 8.dp) - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) + StyledScaffold( + title = name, + navigateBack = navigateBack + ) { topPadding, bottomPadding -> + Column ( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + .verticalScroll(scrollState) + .padding(top = 8.dp) + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(topPadding)) - StyledList { - StyledListItem( - contentText = stringResource(R.string.noise_control), - selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES, - onClick = { - viewModel.setLongPressAction( - name, - StemAction.CYCLE_NOISE_CONTROL_MODES - ) - } - ) + StyledList { + StyledListItem( + contentText = stringResource(R.string.noise_control), + selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES, + onClick = { + viewModel.setLongPressAction( + name, + StemAction.CYCLE_NOISE_CONTROL_MODES + ) + } + ) - StyledListItem( - contentText = stringResource(R.string.digital_assistant), - selected = longPressAction == StemAction.DIGITAL_ASSISTANT, - onClick = { - viewModel.setLongPressAction( - name, - StemAction.DIGITAL_ASSISTANT - ) - }, - 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 + StyledListItem( + contentText = stringResource(R.string.digital_assistant), + selected = longPressAction == StemAction.DIGITAL_ASSISTANT, + onClick = { + viewModel.setLongPressAction( + name, + StemAction.DIGITAL_ASSISTANT + ) + }, + enabled = uiState.isPremium ) } - Spacer(modifier = Modifier.height(16.dp)) - } - if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) { - Spacer(modifier = Modifier.height(32.dp)) + 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)) + } - 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( - contentText = stringResource(R.string.off), - supportingText = stringResource(R.string.listening_mode_off_description), - selected = (currentByte and 0x01) != 0, + contentText = stringResource(R.string.transparency), + supportingText = stringResource(R.string.listening_mode_transparency_description), + selected = (currentByte and 0x04) != 0, 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, 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)) } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/RecordingScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/RecordingScreen.kt index 6f256c03..e85be9f9 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/RecordingScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/RecordingScreen.kt @@ -8,14 +8,10 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -45,12 +41,11 @@ import androidx.core.content.FileProvider import me.kavishdevar.librepods.BuildConfig import me.kavishdevar.librepods.R 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.StyledList import me.kavishdevar.librepods.presentation.components.StyledListItem -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation +import me.kavishdevar.librepods.presentation.components.StyledScaffold import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel import java.time.Instant @@ -60,27 +55,17 @@ import java.time.format.DateTimeFormatter @Composable fun RecordingScreenRoute( viewModel: AppleViewModel, + navigateBack: (() -> Unit)? ) { val uiState by viewModel.uiState.collectAsState() - 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) - ) { - RecordingScreen( - uiState = uiState, - recordings = viewModel.recordings(), - startRecording = viewModel::startRecording, - stopRecording = viewModel::stopRecording, - topPadding = topPadding, - bottomPadding = bottomPadding - ) - } + RecordingScreen( + uiState = uiState, + navigateBack = navigateBack, + recordings = viewModel.recordings(), + startRecording = viewModel::startRecording, + stopRecording = viewModel::stopRecording, + ) DisposableEffect(Unit) { onDispose { @@ -92,11 +77,10 @@ fun RecordingScreenRoute( @Composable fun RecordingScreen( uiState: AppleUiState, + navigateBack: (() -> Unit)?, recordings: List, startRecording: () -> Unit, stopRecording: () -> Unit, - topPadding: Dp = 16.dp, - bottomPadding: Dp = 16.dp ) { val state = uiState.state @@ -139,150 +123,155 @@ fun RecordingScreen( } } - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.padding(top = topPadding)) + StyledScaffold( + title = stringResource(R.string.recorder), + navigateBack = navigateBack + ) { topPadding, bottomPadding -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.padding(top = topPadding)) - AnimatedContent( - targetState = state.recordingState.isRecording, - label = "recording", - modifier = Modifier.padding(vertical = 24.dp).weight(1f) - ) { recording -> - if (recording) { - Column( - modifier = Modifier - .fillMaxWidth() - .background( - MaterialTheme.colorScheme.surface, - RoundedCornerShape(28.dp) - ) - .padding(24.dp) - ) { - // AI-generated - Canvas( + AnimatedContent( + targetState = state.recordingState.isRecording, + label = "recording", + modifier = Modifier.padding(vertical = 24.dp).weight(1f) + ) { recording -> + if (recording) { + Column( modifier = Modifier - .fillMaxSize() - ) { - if (history.isEmpty()) return@Canvas - - 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 - ) + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.surface, + RoundedCornerShape(28.dp) ) + .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) - break + 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 + ) + ) + + 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( - 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 - ) - - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, "audio/wav") - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + context.startActivity( + Intent.createChooser(intent, null) + ) } - - context.startActivity( - Intent.createChooser(intent, null) - ) - } - ) + ) + } } } } } } - } - StyledButton( - modifier = Modifier.fillMaxWidth(), - onClick = if (state.recordingState.isRecording) { - stopRecording - } else { - startRecording + StyledButton( + modifier = Modifier.fillMaxWidth(), + onClick = if (state.recordingState.isRecording) { + stopRecording + } else { + startRecording + } + ) { + Text( + text = if (state.recordingState.isRecording) { + "Stop Recording" + } else "Start Recording", + style = MaterialTheme.typography.labelMedium + ) } - ) { - Text( - text = if (state.recordingState.isRecording) { - "Stop Recording" - } else "Start Recording", - style = MaterialTheme.typography.labelMedium - ) + Spacer(modifier = Modifier.padding(bottom = bottomPadding)) } - Spacer(modifier = Modifier.padding(bottom = bottomPadding)) } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/RenameScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/RenameScreen.kt index 32fbd425..31e70aff 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/RenameScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/RenameScreen.kt @@ -20,20 +20,13 @@ 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.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize 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.text.input.rememberTextFieldState import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -41,21 +34,22 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.core.content.edit import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import me.kavishdevar.librepods.R import me.kavishdevar.librepods.presentation.components.StyledInputField -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.components.StyledScaffold import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel import kotlin.io.encoding.ExperimentalEncodingApi @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @Composable -fun RenameScreen(viewModel: AppleViewModel) { - val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE) +fun RenameScreen( + viewModel: AppleViewModel, + navigateBack: (() -> Unit)? +) { val focusRequester = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current @@ -64,33 +58,33 @@ fun RenameScreen(viewModel: AppleViewModel) { 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 metadata = uiState.metadata - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) + StyledScaffold( + title = stringResource(R.string.name), + navigateBack = navigateBack + ) { topPadding, bottomPadding -> + Column( + modifier = Modifier + .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)) - } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/TransparencySettingsScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/TransparencySettingsScreen.kt index f92c148b..e9c6e523 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/TransparencySettingsScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/TransparencySettingsScreen.kt @@ -26,20 +26,15 @@ 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.size -import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider 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.unit.dp import androidx.compose.ui.unit.sp -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import me.kavishdevar.librepods.R import me.kavishdevar.librepods.bluetooth.att.types.TransparencySettings import me.kavishdevar.librepods.bluetooth.att.types.parseTransparencySettingsResponse 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.StyledToggle 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.sectionHeader import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel -import kotlin.io.encoding.ExperimentalEncodingApi private const val TAG = "TransparencySettings" @SuppressLint("DefaultLocale") -@ExperimentalHazeMaterialsApi -@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @Composable -fun TransparencySettingsScreen(viewModel: AppleViewModel) { +fun TransparencySettingsScreen( + viewModel: AppleViewModel, + navigateBack: (() -> Unit)?, +) { val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f val textColor = if (isDarkTheme) Color.White else Color.Black val verticalScrollState = rememberScrollState() @@ -94,33 +89,33 @@ fun TransparencySettingsScreen(viewModel: AppleViewModel) { 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 + StyledScaffold( + title = stringResource(R.string.customize_transparency_mode), + 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( - 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 - - val enabled = rememberSaveable { mutableStateOf(false) } - 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 enabled = rememberSaveable { mutableStateOf(false) } + 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( // saver = Saver( @@ -129,16 +124,44 @@ fun TransparencySettingsScreen(viewModel: AppleViewModel) { // ) // ) { mutableStateOf(FloatArray(8) { 0.5f }) } - val initialized = rememberSaveable { mutableStateOf(false) } + val initialized = rememberSaveable { mutableStateOf(false) } - val transparencySettings = remember { - mutableStateOf( - TransparencySettings( + val transparencySettings = remember { + mutableStateOf( + 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, 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, + 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, @@ -148,214 +171,187 @@ fun TransparencySettingsScreen(viewModel: AppleViewModel) { 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, - 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() + Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}") + sendTransparencySettings(viewModel::writeATTCharacteristic, transparencySettings.value) } - initialized.value = true - } - if (uiState.vendorIdHook) { - StyledToggle( - label = stringResource(R.string.transparency_mode), - checked = enabled.value, - description = stringResource(R.string.customize_transparency_mode_description), - onCheckedChange = { enabled.value = it } - ) - Spacer(modifier = Modifier.height(4.dp)) - StyledSlider( - label = stringResource(R.string.amplification), - valueRange = -1f..1f, - value = amplificationSliderValue.floatValue, - onValueChange = { - amplificationSliderValue.floatValue = it - }, - startImageVector = LocalIcons.current.SpeakerMin, - endImageVector = LocalIcons.current.SpeakerMax, - independent = true - ) + 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 + } - StyledSlider( - label = stringResource(R.string.balance), - valueRange = -1f..1f, - value = balanceSliderValue.floatValue, - onValueChange = { - balanceSliderValue.floatValue = it - }, - snapPoints = listOf(-1f, 0f, 1f), - startLabel = stringResource(R.string.left), - endLabel = stringResource(R.string.right), - independent = true, - ) + if (uiState.vendorIdHook) { + StyledToggle( + label = stringResource(R.string.transparency_mode), + checked = enabled.value, + description = stringResource(R.string.customize_transparency_mode_description), + onCheckedChange = { enabled.value = it } + ) + Spacer(modifier = Modifier.height(4.dp)) + StyledSlider( + label = stringResource(R.string.amplification), + valueRange = -1f..1f, + value = amplificationSliderValue.floatValue, + onValueChange = { + amplificationSliderValue.floatValue = it + }, + startImageVector = LocalIcons.current.SpeakerMin, + endImageVector = LocalIcons.current.SpeakerMax, + independent = true + ) - StyledSlider( - label = stringResource(R.string.tone), - valueRange = -1f..1f, - value = toneSliderValue.floatValue, - onValueChange = { - toneSliderValue.floatValue = it - }, - startLabel = stringResource(R.string.darker), - endLabel = stringResource(R.string.brighter), - independent = true, - ) + StyledSlider( + label = stringResource(R.string.balance), + valueRange = -1f..1f, + value = balanceSliderValue.floatValue, + onValueChange = { + balanceSliderValue.floatValue = it + }, + snapPoints = listOf(-1f, 0f, 1f), + startLabel = stringResource(R.string.left), + endLabel = stringResource(R.string.right), + independent = true, + ) - StyledSlider( - label = stringResource(R.string.ambient_noise_reduction), - valueRange = 0f..1f, - value = ambientNoiseReductionSliderValue.floatValue, - onValueChange = { - ambientNoiseReductionSliderValue.floatValue = it - }, - startLabel = stringResource(R.string.less), - endLabel = stringResource(R.string.more), - independent = true, - ) + StyledSlider( + label = stringResource(R.string.tone), + valueRange = -1f..1f, + value = toneSliderValue.floatValue, + onValueChange = { + toneSliderValue.floatValue = it + }, + startLabel = stringResource(R.string.darker), + endLabel = stringResource(R.string.brighter), + independent = true, + ) - StyledToggle( - label = stringResource(R.string.conversation_boost), - checked = conversationBoostEnabled.value, - description = stringResource(R.string.conversation_boost_description), - onCheckedChange = { conversationBoostEnabled.value = it } - ) + StyledSlider( + label = stringResource(R.string.ambient_noise_reduction), + valueRange = 0f..1f, + value = ambientNoiseReductionSliderValue.floatValue, + onValueChange = { + ambientNoiseReductionSliderValue.floatValue = it + }, + startLabel = stringResource(R.string.less), + endLabel = stringResource(R.string.more), + independent = true, + ) - Text( - text = stringResource(R.string.equalizer), - style = MaterialTheme.typography.labelSmallEmphasized, - color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader, - modifier = Modifier.padding(16.dp, bottom = 4.dp) - ) + StyledToggle( + label = stringResource(R.string.conversation_boost), + checked = conversationBoostEnabled.value, + description = stringResource(R.string.conversation_boost_description), + onCheckedChange = { conversationBoostEnabled.value = it } + ) - Column( - modifier = Modifier - .fillMaxWidth() - .background(backgroundColor, RoundedCornerShape(28.dp)) - .padding(16.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) - ) + Text( + text = stringResource(R.string.equalizer), + style = MaterialTheme.typography.labelSmallEmphasized, + color = if (LocalDesignSystem.current == DesignSystem.Material) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader, + modifier = Modifier.padding(16.dp, bottom = 4.dp) + ) - Slider( - value = eqValue.floatValue, - onValueChange = { newVal -> - eqValue.floatValue = newVal - val newEQ = eq.value.copyOf() - newEQ[i] = eqValue.floatValue - eq.value = newEQ - }, - valueRange = 0f..100f, + Column( + modifier = Modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(28.dp)) + .padding(16.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(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( - modifier = Modifier - .fillMaxWidth() - .height(12.dp), - contentAlignment = Alignment.CenterStart - ) - { + .fillMaxWidth() + .height(38.dp) + ) { + Text( + text = String.format("%.2f", eqValue.floatValue), + fontSize = 12.sp, + color = textColor, + modifier = Modifier.padding(bottom = 4.dp) + ) + + Slider( + value = eqValue.floatValue, + onValueChange = { newVal -> + eqValue.floatValue = newVal + val newEQ = eq.value.copyOf() + newEQ[i] = eqValue.floatValue + eq.value = newEQ + }, + valueRange = 0f..100f, + modifier = Modifier + .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( 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) - ) + .height(12.dp), + contentAlignment = Alignment.CenterStart ) + { + 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 = stringResource(R.string.band_label, i + 1), - fontSize = 12.sp, - color = textColor, - modifier = Modifier.padding(top = 4.dp) - ) + Text( + text = stringResource(R.string.band_label, i + 1), + fontSize = 12.sp, + color = textColor, + 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)) } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/UpdateHearingTestScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/UpdateHearingTestScreen.kt index 00833665..dc9ed046 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/UpdateHearingTestScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/UpdateHearingTestScreen.kt @@ -25,14 +25,9 @@ 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.layout.wrapContentHeight 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.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import kotlinx.coroutines.Job 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.parseHearingAidSettingsResponse 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.LibrePodsTheme -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel private const val TAG = "UpdateHearingTestScreen" @Composable -fun UpdateHearingTestRoute(viewModel: AppleViewModel) { +fun UpdateHearingTestRoute( + viewModel: AppleViewModel, + navigateBack: (() -> Unit)? +) { val uiState by viewModel.uiState.collectAsState() - 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) - ) { - UpdateHearingTestScreen( - uiState = uiState, - topPadding = topPadding, - bottomPadding = bottomPadding, - setATTCharacteristicValue = viewModel::writeATTCharacteristic - ) - } + UpdateHearingTestScreen( + uiState = uiState, + navigateBack = navigateBack, + setATTCharacteristicValue = viewModel::writeATTCharacteristic + ) } @Composable fun UpdateHearingTestScreen( uiState: AppleUiState, - topPadding: Dp = 16.dp, - bottomPadding: Dp = 16.dp, + navigateBack: (() -> Unit)?, setATTCharacteristicValue: (ATTHandle, ByteArray) -> Unit ) { val state = uiState.state val verticalScrollState = rememberScrollState() - Column( - modifier = Modifier - .verticalScroll(verticalScrollState) - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(topPadding)) - - Text( - 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()) } - ) + StyledScaffold( + title = stringResource(R.string.update_hearing_test), + navigateBack = navigateBack + ) { topPadding, bottomPadding -> + Column( + modifier = Modifier + .verticalScroll(verticalScrollState) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - mutableStateOf(FloatArray(8)) - } - val rightEQ = rememberSaveable( - saver = Saver( - save = { it.value.toList() }, - restore = { mutableStateOf(it.toFloatArray()) } + Spacer(modifier = Modifier.height(topPadding)) + + Text( + text = stringResource(R.string.hearing_test_value_instruction), + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, ) - ) { - mutableStateOf(FloatArray(8)) - } + 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)) + } + val rightEQ = rememberSaveable( + saver = Saver( + save = { it.value.toList() }, + restore = { mutableStateOf(it.toFloatArray()) } + ) + ) { + mutableStateOf(FloatArray(8)) + } - val debounceJob = remember { mutableStateOf(null) } - val initialized = rememberSaveable { mutableStateOf(false) } + val debounceJob = remember { mutableStateOf(null) } + val initialized = rememberSaveable { mutableStateOf(false) } - val hearingAidSettings = remember { - mutableStateOf( - HearingAidSettings( + val hearingAidSettings = remember { + mutableStateOf( + 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, rightEQ = rightEQ.value, leftAmplification = leftAmplification.floatValue, @@ -162,125 +198,79 @@ fun UpdateHearingTestScreen( 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") + Log.d(TAG, "Updated settings: ${hearingAidSettings.value}") + sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, setATTCharacteristicValue) } - } - 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, - 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") - 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( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { + Spacer(modifier = Modifier.width(60.dp)) Text( - text = freq, - modifier = Modifier - .width(60.dp) - .align(Alignment.CenterVertically), - textAlign = TextAlign.End, - style = MaterialTheme.typography.labelSmall + text = stringResource(R.string.left), + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelMediumEmphasized ) - 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) + Text( + text = stringResource(R.string.right), + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelMediumEmphasized ) } + + 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( uiState = AppleUiState(), + navigateBack = null, setATTCharacteristicValue = { _, _ -> } ) } @@ -314,6 +305,7 @@ fun UpdateHearingTestScreenPreviewMaterial() { ) { UpdateHearingTestScreen( uiState = AppleUiState(), + navigateBack = null, setATTCharacteristicValue = { _, _ -> } ) } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/VersionInfoScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/VersionInfoScreen.kt index ab2d3364..61d47c87 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/VersionInfoScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/apple/VersionInfoScreen.kt @@ -18,17 +18,11 @@ package me.kavishdevar.librepods.presentation.screens.apple -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column 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.height -import androidx.compose.foundation.layout.navigationBars 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.collectAsState import androidx.compose.runtime.getValue @@ -38,46 +32,48 @@ import androidx.compose.ui.unit.dp import me.kavishdevar.librepods.R import me.kavishdevar.librepods.presentation.components.StyledList import me.kavishdevar.librepods.presentation.components.StyledListItem -import me.kavishdevar.librepods.presentation.theme.DesignSystem -import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.components.StyledScaffold import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel @Composable -fun VersionScreen(viewModel: AppleViewModel) { +fun VersionScreen( + viewModel: AppleViewModel, + navigateBack: (() -> Unit)? +) { val uiState by viewModel.uiState.collectAsState() val metadata = uiState.metadata - 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 + StyledScaffold( + title = stringResource(R.string.version), + 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( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceContainer) - .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( + contentText = stringResource(R.string.version) + " 2", + supportingText = metadata.version2, + enabled = false + ) - StyledListItem( - contentText = stringResource(R.string.version) + " 2", - supportingText = metadata.version2, - enabled = false - ) - - StyledListItem( - contentText = stringResource(R.string.version) + " 3", - supportingText = metadata.version3, - enabled = false - ) + StyledListItem( + contentText = stringResource(R.string.version) + " 3", + supportingText = metadata.version3, + enabled = false + ) + } + Spacer(modifier = Modifier.height(bottomPadding)) } - Spacer(modifier = Modifier.height(bottomPadding)) } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/OnboardingScreen.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/OnboardingScreen.kt index 16bfff55..c828da28 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/OnboardingScreen.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/OnboardingScreen.kt @@ -93,6 +93,7 @@ fun OnboardingScreen( verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.CenterHorizontally ) { + Spacer(modifier = Modifier.height(topPadding)) HorizontalUncontainedCarousel( modifier = Modifier .fillMaxWidth() diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/PermissionsPage.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/PermissionsPage.kt index 78ded78b..a5f84d92 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/PermissionsPage.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/PermissionsPage.kt @@ -37,8 +37,11 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp 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.LifecycleEventObserver 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.rememberMultiplePermissionsState 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.StyledListItem +import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation import me.kavishdevar.librepods.presentation.icons.MaterialIcons @OptIn(ExperimentalPermissionsApi::class) @@ -63,11 +67,8 @@ fun PermissionsPage( val context = LocalContext.current val canDrawOverlays = remember { mutableStateOf(Settings.canDrawOverlays(context)) } - val phonePermissionState = rememberMultiplePermissionsState( - listOf( - "android.permission.READ_PHONE_STATE", - "android.permission.ANSWER_PHONE_CALLS" - ) + val healthPermissions = rememberPermissionState( + HealthPermission.getWritePermission(HeartRateRecord::class) ) { if (grantingAll) { 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") { if (grantingAll) { if (!phonePermissionState.allPermissionsGranted) phonePermissionState.launchMultiplePermissionRequest() + else if (!healthPermissions.status.isGranted) healthPermissions.launchPermissionRequest() else if (!canDrawOverlays.value) canDrawOverlays.value = Settings.canDrawOverlays(context) } } @@ -100,6 +114,7 @@ fun PermissionsPage( if (grantingAll) { if (!notificationPermissionState.status.isGranted) notificationPermissionState.launchPermissionRequest() else if (!phonePermissionState.allPermissionsGranted) phonePermissionState.launchMultiplePermissionRequest() + else if (!healthPermissions.status.isGranted) healthPermissions.launchPermissionRequest() else if (!canDrawOverlays.value) canDrawOverlays.value = Settings.canDrawOverlays(context) } } @@ -135,21 +150,21 @@ fun PermissionsPage( .verticalScroll(scrollState), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - StyledList(title = "Required Permissions") { - val animatedBluetoothIconColor by animateColorAsState(if (bluetoothPermissionsState.allPermissionsGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface) + StyledList(title = stringResource(R.string.required_permissions)) { + val animatedBluetoothIconColor by animateColorAsState(if (bluetoothPermissionsState.allPermissionsGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant) 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( - contentText = "Bluetooth", + contentText = stringResource(R.string.bluetooth), onClick = if (!bluetoothPermissionsState.allPermissionsGranted) { { grantingAll = false bluetoothPermissionsState.launchMultiplePermissionRequest() } } else null, - supportingText = "Required to communicate with AirPods", + supportingText = stringResource(R.string.permission_description_bluetooth), orientation = StyledListItemOrientation.Vertical, leadingContent = { Box( @@ -172,27 +187,27 @@ fun PermissionsPage( }, ) } - StyledList(title = "Optional Permissions") { + StyledList(title = stringResource(R.string.optional_permissions)) { 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( - 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( - if (phonePermissionState.allPermissionsGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest + if (phonePermissionState.allPermissionsGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant ) StyledListItem( - contentText = "Notifications", + contentText = stringResource(R.string.notifications), onClick = if (!notificationPermissionState.status.isGranted) { { grantingAll = false notificationPermissionState.launchPermissionRequest() } } else null, - supportingText = "Show battery status", + supportingText = stringResource(R.string.permission_description_notification), orientation = StyledListItemOrientation.Vertical, leadingContent = { Box( @@ -215,14 +230,14 @@ fun PermissionsPage( }, ) StyledListItem( - contentText = "Phone", + contentText = stringResource(R.string.phone), onClick = if (!phonePermissionState.allPermissionsGranted) { { grantingAll = false phonePermissionState.launchMultiplePermissionRequest() } } else null, - supportingText = "Respond to phone calls with head gestures", + supportingText = stringResource(R.string.permission_description_phone), orientation = StyledListItemOrientation.Vertical, leadingContent = { Box( @@ -237,7 +252,7 @@ fun PermissionsPage( ) { Icon( imageVector = MaterialIcons.Call, - contentDescription = "bluetooth", + contentDescription = "call", modifier = Modifier.size(24.dp), tint = animatedPhoneIconColor ) @@ -246,11 +261,44 @@ fun PermissionsPage( ) } - val animatedOverlayIconColor by animateColorAsState(if (canDrawOverlays.value) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface) - val animatedOverlayContainerColor by animateColorAsState(if (canDrawOverlays.value) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest) + val animatedHealthConnectIconColor by animateColorAsState(if (healthPermissions.status.isGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant) + val animatedHealthConnectContainerColor by animateColorAsState(if (healthPermissions.status.isGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant) 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) { { grantingAll = false @@ -261,7 +309,7 @@ fun PermissionsPage( context.startActivity(intent) } } else null, - supportingText = "Show popups when AirPods are nearby or audio switches to them.", + supportingText = stringResource(R.string.permission_description_overlay), orientation = StyledListItemOrientation.Vertical, leadingContent = { Box( @@ -276,7 +324,7 @@ fun PermissionsPage( ) { Icon( imageVector = MaterialIcons.Overlay, - contentDescription = "bluetooth", + contentDescription = "overlay", modifier = Modifier.size(24.dp), tint = animatedOverlayIconColor ) diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/PrivacyPolicyPage.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/PrivacyPolicyPage.kt index 8a36789d..ccf3bb94 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/PrivacyPolicyPage.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/screens/onboarding/PrivacyPolicyPage.kt @@ -48,42 +48,50 @@ fun PrivacyPolicyPage( Text( text = "Overview", - style = MaterialTheme.typography.titleLarge + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface ) 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.", - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface ) 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.", - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface ) Text( text = "Third Party Services", - style = MaterialTheme.typography.titleLarge + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface ) Text( 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 = "Email", - style = MaterialTheme.typography.titleMedium + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface ) 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.", - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface ) Text( 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( @@ -93,22 +101,26 @@ fun PrivacyPolicyPage( 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.", - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface ) 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.", - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface ) Text( text = "GitHub Issues", - style = MaterialTheme.typography.titleMedium + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface ) Text( 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( @@ -117,68 +129,86 @@ fun PrivacyPolicyPage( ) { Text( "• LibrePods version name and version code", - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface ) Text( text = "• Device manufacturer and model", - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface ) Text( text = "• Android build information", - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface ) Text( text = "• Installation source (Google Play or GitHub)", - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface ) } 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.", - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface ) Text( - text = "Payments", style = MaterialTheme.typography.titleLarge + text = "Payments", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface ) if (BuildConfig.PLAY_BUILD) { Text( - text = "Google Play", style = MaterialTheme.typography.titleMedium + text = "Google Play", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface ) Text( 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 = "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 { Text( - text = "GitHub Sponsors", style = MaterialTheme.typography.titleMedium + text = "GitHub Sponsors", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface ) 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.", - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface ) 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.", - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface ) } Text( - text = "Contact", style = MaterialTheme.typography.titleLarge + text = "Contact", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface ) Text( 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( @@ -187,7 +217,7 @@ fun PrivacyPolicyPage( ) { Text( text = stringResource(R.string.i_agree), - style = MaterialTheme.typography.labelMediumEmphasized + style = MaterialTheme.typography.labelMediumEmphasized, ) } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/viewmodel/AppleViewModel.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/viewmodel/AppleViewModel.kt index fa79f8da..2dc126b4 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/viewmodel/AppleViewModel.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/presentation/viewmodel/AppleViewModel.kt @@ -21,35 +21,54 @@ package me.kavishdevar.librepods.presentation.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch import me.kavishdevar.librepods.billing.BillingManager import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier import me.kavishdevar.librepods.bluetooth.att.ATTHandle 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.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.AppleMetadata import me.kavishdevar.librepods.devices.AppleSettings 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 kotlin.time.Duration +import kotlin.time.Instant data class AppleUiState( val state: AppleState = AppleState(), val settings: AppleSettings = AppleSettings(), val metadata: AppleMetadata = AppleMetadata(), + val appState: AppStateEntity = AppStateEntity(), + val appSettings: AppSettingsEntity = AppSettingsEntity(), + val isPremium: Boolean = false, val vendorIdHook: Boolean = false, val recordings: List = emptyList(), + + val heartRateSamples: List = emptyList(), + + val heartRateRange: ClosedRange? = null, ) class AppleViewModel( private val device: AppleDevice, + private val appDataRepository: AppDataRepository, private val recordingRepository: RecordingRepository, + private val heartRateRepository: HeartRateRepository ) : ViewModel(), DeviceViewModel { val billingManager = BillingManager @@ -57,12 +76,42 @@ class AppleViewModel( private var attObserveJob: Job? = null - val uiState = combine( + private val _heartRateRange = MutableStateFlow?>(null) + + private val heartRateSamples: Flow> = _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.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, - ) { state, settings, metadata, isPremium -> + _heartRateRange, + heartRateSamples + ) { (state, settings, metadata), (appState, appSettings), isPremium, heartRateRange, heartRateSamples -> AppleUiState( state = state, settings = settings, @@ -71,7 +120,11 @@ class AppleViewModel( vendorIdHook = XposedRemotePrefProvider.create().getBoolean( "vendor_id_hook", 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( viewModelScope, @@ -133,7 +186,17 @@ class AppleViewModel( fun setCustomEqEnabled(enabled: Boolean) = device.setCustomEqEnabled(enabled) 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) { + _heartRateRange.value = range + } fun sendRawPacket(data: ByteArray): Boolean = device.sendRawPacket(data) } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/repository/AppleRepository.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/repository/AppleRepository.kt index ed363b3c..e6de828f 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/repository/AppleRepository.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/repository/AppleRepository.kt @@ -39,7 +39,7 @@ class AppleRepository( } 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 { AppleCache( capabilities = state.capabilities, @@ -48,7 +48,7 @@ class AppleRepository( customEq = state.customEq ) } 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 } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/repository/HeartRateRepository.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/repository/HeartRateRepository.kt new file mode 100644 index 00000000..a8142434 --- /dev/null +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/repository/HeartRateRepository.kt @@ -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 = 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 + ) +} diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt index f7f7754b..c9bf9408 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/services/LibrePodsService.kt @@ -18,16 +18,21 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.pm.PackageManager import android.media.AudioManager import android.os.BatteryManager import android.os.Binder +import android.os.Build import android.os.IBinder import android.os.ParcelUuid +import android.os.ext.SdkExtensions import android.provider.Settings import android.util.Log import android.view.View import android.widget.RemoteViews 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.Dispatchers 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.MagicKeyType import me.kavishdevar.librepods.bluetooth.verifyRPA +import me.kavishdevar.librepods.data.heartrate.HeartRateSample import me.kavishdevar.librepods.database.app.AppSettingsEntity import me.kavishdevar.librepods.devices.AppleDevice 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.utils.MediaController import me.kavishdevar.librepods.utils.redactMac +import java.time.ZoneOffset +import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds +import kotlin.time.toJavaInstant private const val TAG = "LibrePodsService" @@ -94,6 +103,14 @@ class LibrePodsService : Service() { (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 { appDataRepository.state.value.hasConnectedToAACP } @@ -684,6 +701,24 @@ class LibrePodsService : Service() { 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 } @@ -1066,6 +1101,13 @@ class LibrePodsService : Service() { device.disableAudio(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 private fun setAppleBluetoothMetadata( @Suppress("unused") device: AppleDevice diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/GestureDetector.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/GestureDetector.kt index 30e8271c..4d81446e 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/GestureDetector.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/GestureDetector.kt @@ -16,20 +16,16 @@ along with this program. If not, see . */ -@file:OptIn(ExperimentalEncodingApi::class) - package me.kavishdevar.librepods.utils -import android.os.Build import android.util.Log -import androidx.annotation.RequiresApi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import me.kavishdevar.librepods.services.LibrePodsService import java.util.Collections import java.util.concurrent.CopyOnWriteArrayList import kotlin.io.encoding.ExperimentalEncodingApi @@ -37,10 +33,10 @@ import kotlin.math.abs import kotlin.math.max import kotlin.math.min import kotlin.math.pow +import kotlin.time.Duration.Companion.milliseconds -class GestureDetector( - private val librepodsService: LibrePodsService -) { +// TODO: rewrite +class GestureDetector { companion object { private const val TAG = "GestureDetector" @@ -54,8 +50,6 @@ class GestureDetector( private const val MAX_VALID_ORIENTATION_VALUE = 6000 } -// val audio = GestureFeedback(ServiceManager.getService()?.baseContext!!) - private val horizontalBuffer = Collections.synchronizedList(ArrayList()) private val verticalBuffer = Collections.synchronizedList(ArrayList()) @@ -86,7 +80,7 @@ class GestureDetector( private var isRunning = false private var detectionJob: Job? = null - private var gestureDetectedCallback: ((Boolean) -> Unit)? = null + private var processingJob: Job? = null private var significantMotion = false private var lastSignificantMotionTime = 0L @@ -96,49 +90,52 @@ class GestureDetector( while (verticalAvgBuffer.size < 3) verticalAvgBuffer.add(0.0) } -fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> Unit) { + fun startDetection(acceleration: StateFlow, callback: (Boolean) -> Unit) { if (isRunning) return Log.d(TAG, "Starting gesture detection...") isRunning = true - gestureDetectedCallback = onGestureDetected - -// Log.d(TAG, "started: ${airPodsService.startHeadTracking()}") clearData() prevHorizontal = 0.0 prevVertical = 0.0 + processingJob = CoroutineScope(Dispatchers.Default).launch { + acceleration.collect { + processHeadOrientation(it.horizontal.toInt(), it.vertical.toInt()) + } + } + detectionJob = CoroutineScope(Dispatchers.Default).launch { while (isRunning) { - delay(50) + delay(50.milliseconds) val gesture = detectGestures() if (gesture != null) { withContext(Dispatchers.Main) { -// audio.playConfirmation(gesture) + GestureFeedback.playConfirmation(gesture) - gestureDetectedCallback?.invoke(gesture) - stopDetection(doNotStop) + callback.invoke(gesture) + stopDetection() } break } } } } - fun stopDetection(doNotStop: Boolean = false) { + fun stopDetection() { if (!isRunning) return Log.d(TAG, "Stopping gesture detection") isRunning = false -// if (!doNotStop) airPodsService.stopHeadTracking() - detectionJob?.cancel() detectionJob = null - gestureDetectedCallback = null - } + + processingJob?.cancel() + processingJob = null + } fun processHeadOrientation(horizontal: Int, vertical: Int) { if (!isRunning) return @@ -156,7 +153,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U if (significantHorizontal && (!significantVertical || abs(horizontalDelta) > abs(verticalDelta))) { CoroutineScope(Dispatchers.Main).launch { -// audio.playDirectional(isVertical = false, value = horizontalDelta) + GestureFeedback.playDirectional(isVertical = false, value = horizontalDelta) } significantMotion = true lastSignificantMotionTime = System.currentTimeMillis() @@ -164,7 +161,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U } else if (significantVertical) { CoroutineScope(Dispatchers.Main).launch { -// audio.playDirectional(isVertical = true, value = verticalDelta) + GestureFeedback.playDirectional(isVertical = true, value = verticalDelta) } significantMotion = true lastSignificantMotionTime = System.currentTimeMillis() @@ -363,7 +360,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U private fun detectGestures(): Boolean? { val requiredExtremes = getRequiredExtremes() - Log.d(TAG, "Current required extremes: $requiredExtremes") +// Log.d(TAG, "Current required extremes: $requiredExtremes") if (verticalPeaks.size + verticalTroughs.size >= requiredExtremes) { val allExtremes = (verticalPeaks + verticalTroughs).sortedBy { it.first } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/GestureFeedback.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/GestureFeedback.kt index 88ab8cf5..e76ff627 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/GestureFeedback.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/GestureFeedback.kt @@ -23,14 +23,12 @@ package me.kavishdevar.librepods.utils import android.content.Context import android.media.AudioAttributes import android.media.SoundPool -import android.os.Build import android.os.SystemClock import android.util.Log -import androidx.annotation.RequiresApi import me.kavishdevar.librepods.R import java.util.concurrent.atomic.AtomicBoolean -class GestureFeedback(context: Context) { +object GestureFeedback { private val TAG = "GestureFeedback" @@ -71,7 +69,7 @@ class GestureFeedback(context: Context) { private val RIGHT_VOLUME = Pair(0.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) confirmYesId = soundPool.load(context, R.raw.confirm_yes, 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) { if (!soundsLoaded.get()) { Log.d(TAG, "Sounds not yet loaded, skipping playback") diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/HeadOrientation.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/HeadOrientation.kt index ebdf9149..3e476438 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/HeadOrientation.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/HeadOrientation.kt @@ -20,82 +20,19 @@ package me.kavishdevar.librepods.utils import kotlinx.coroutines.flow.MutableStateFlow 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) +// TODO: remove object HeadTracking { - private val _orientation = MutableStateFlow(Orientation()) - val orientation = _orientation.asStateFlow() - private val _acceleration = MutableStateFlow(Acceleration()) val acceleration = _acceleration.asStateFlow() - private val calibrationSamples = mutableListOf>() - private var isCalibrated = false - 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 addAccel(vert: Float, horiz: Float) { + _acceleration.value = Acceleration(vert, horiz) } fun reset() { - calibrationSamples.clear() - isCalibrated = false - _orientation.value = Orientation() _acceleration.value = Acceleration() } } diff --git a/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/MediaController.kt b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/MediaController.kt index efece1e6..42cd9510 100644 --- a/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/MediaController.kt +++ b/android/app/src/main/kotlin/me/kavishdevar/librepods/utils/MediaController.kt @@ -30,6 +30,7 @@ import android.util.Log import android.view.KeyEvent 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 { private var initialVolume: Int? = null private lateinit var audioManager: AudioManager diff --git a/android/app/src/main/proto/rtbuddy.proto b/android/app/src/main/proto/rtbuddy.proto new file mode 100644 index 00000000..c2c52a98 --- /dev/null +++ b/android/app/src/main/proto/rtbuddy.proto @@ -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; +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index c83abcca..ad0244e4 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -320,4 +320,24 @@ Enable debug mode Any value greater than 0 enables batching. Report delay (in ms) + Interval + Interval between heart rate measurements + No heart rate data + 1 second + 1 minute + Write heart rate to Health Connect + Allows the app to write heart rate data to Android\'s Health Connect platform. + Required Permissions + Bluetooth + Required to communicate with AirPods. + Optional Permissions + Notifications + Show battery status when connected or available nearby. + Respond to phone calls with head gestures. + Display over other apps + Show popups when AirPods are nearby or audio switches to them. + Swipe anywhere to go back + Use highest refresh rate available + Shows extra information and settings. + Interaction diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 019d7933..e5544789 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -6,5 +6,6 @@ plugins { alias(libs.plugins.ksp) apply false alias(libs.plugins.room3) apply false alias(libs.plugins.android.library) apply false + alias (libs.plugins.protobuf) apply false // alias(libs.plugins.hilt) apply false } diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 0310b00a..6df538a5 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -26,12 +26,10 @@ navevent = "1.1.2" m3 = "1.5.0-alpha23" ksp = "2.3.10" room = "3.0.0" -ktxSerialization = "1.11.0" -junit = "4.13.2" -junitVersion = "1.1.5" -espressoCore = "3.5.1" -appcompat = "1.6.1" -material = "1.10.0" +protobufPlugin = "0.10.0" +protobuf = "4.35.1" +healthConnect = "1.2.0-alpha05" +foundationLayout = "1.12.0" [libraries] 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-compiler = { group = "androidx.room3", name = "room3-compiler", version.ref = "room" } kotlinx-serialization-cbor = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-cbor", version = "ktxSerialization" } -junit = { group = "junit", name = "junit", version.ref = "junit" } -androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } -androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } -androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } -material = { group = "com.google.android.material", name = "material", version.ref = "material" } +protobuf-kotlin = { group = "com.google.protobuf", name = "protobuf-kotlin", version.ref = "protobuf" } +protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" } +androidx-healthconnect-client = { group = "androidx.health.connect", name = "connect-client", version.ref = "healthConnect" } +androidx-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" } [plugins] 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" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } android-library = { id = "com.android.library", version.ref = "agp" } +protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" }