android: refactor... stuff (2, see description)

implement buddy (head tracking and heartrate, finally!), added swipe anywhere to go back, and cleaned up app settings a bit.
This commit is contained in:
Kavish Devar
2026-08-16 01:08:42 +05:30
parent 8457880210
commit ddd1a9c5ce
76 changed files with 5763 additions and 5110 deletions
+22 -2
View File
@@ -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 {
@@ -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')"
]
}
}
+27 -2
View File
@@ -27,7 +27,8 @@
android:name="android.permission.INTERACT_ACROSS_USERS"
tools:ignore="ProtectedPermissions" />
<uses-permission
android:name="android.permission.MODIFY_AUDIO_ROUTING"/>
android:name="android.permission.MODIFY_AUDIO_ROUTING"
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.BLUETOOTH" />
@@ -48,6 +49,8 @@
<!-- android:maxSdkVersion="30" />-->
<uses-permission android:name="com.android.vending.BILLING" />
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE"/>
<application
android:name=".LibrePodsApplication"
android:allowBackup="true"
@@ -154,6 +157,28 @@
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
<activity
android:name=".presentation.activities.PrivacyPolicyActivity"
android:exported="true">
<intent-filter>
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
</intent-filter>
</activity>
<activity-alias
android:name="ViewPermissionUsageActivity"
android:exported="true"
android:targetActivity=".presentation.activities.PrivacyPolicyActivity"
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
<intent-filter>
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
</intent-filter>
</activity-alias>
</application>
<queries>
<package android:name="com.google.android.apps.healthdata" />
</queries>
</manifest>
@@ -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<Application>.onCreate()
}
@@ -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()}"
)
}
}
}
}
}
@@ -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()
)
}
@@ -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,
)
}
}
}
@@ -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
}
}
@@ -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
@@ -0,0 +1,9 @@
package me.kavishdevar.librepods.bluetooth.aacp.types
import me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto.SensorDataWX
data class SensorDataWxBuddyPayload(
val data: SensorDataWX,
): RTBuddyPayload {
override val descriptor: RTBuddyDescriptor = RTBuddyDescriptor.SENSOR_DATA_WX
}
@@ -0,0 +1,24 @@
package me.kavishdevar.librepods.data.heartrate
import androidx.room3.Dao
import androidx.room3.Insert
import androidx.room3.Query
import me.kavishdevar.librepods.database.heartrate.HeartRateSampleEntity
import kotlin.time.Instant
@Dao
interface HeartRateDao {
@Insert
suspend fun insert(sample: HeartRateSampleEntity)
@Query("""
SELECT * FROM HeartRateSampleEntity
WHERE timestamp >= :start
AND timestamp < :end
ORDER BY timestamp ASC
""")
suspend fun get(
start: Instant,
end: Instant,
): List<HeartRateSampleEntity>
}
@@ -0,0 +1,9 @@
package me.kavishdevar.librepods.data.heartrate
import kotlin.time.Instant
data class HeartRateSample (
val bpm: Int,
val timestamp: Instant
)
// TODO: implement records (don't have a better place to put this)
@@ -2,7 +2,7 @@ package me.kavishdevar.librepods.data.updates
import androidx.compose.runtime.Composable
import 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(
@@ -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)
}
@@ -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
}
@@ -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,
)
@@ -0,0 +1,14 @@
package me.kavishdevar.librepods.database.heartrate
import androidx.room3.Entity
import androidx.room3.PrimaryKey
import kotlin.time.Instant
@Entity
data class HeartRateSampleEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0L,
val timestamp: Instant,
val bpm: Int,
)
@@ -18,12 +18,17 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import 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)
@@ -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
@@ -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<AACPPacket> = emptyList(),
): DeviceState
@@ -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
@@ -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))
}
}
}
@@ -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))
}
}
}
}
}
}
@@ -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))
}
}
}
@@ -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)
@@ -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)
}
}
}
@@ -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))
}
}
}
@@ -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
}
@@ -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,
@@ -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
}
@@ -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<Screen>,
devicesState: State<Map<MacAddress, Device<*, *, *>>>,
showReleaseNotes: Boolean = false,
updatesShown: () -> Unit = {},
onboardingComplete: () -> Unit = {},
backStack: SnapshotStateList<Screen>,
devicesState: State<Map<MacAddress, Device<*, *, *>>>
) {
val backRequests = remember { Channel<CompletableDeferred<Unit>>(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<Unit>()
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<Screen>(
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 }
)
}
}
}
@@ -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<Screen> {
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,
)
}
@@ -0,0 +1,438 @@
package me.kavishdevar.librepods.presentation.navigation
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import androidx.navigation3.runtime.NavBackStack
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.channels.Channel
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.MacAddress
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
import me.kavishdevar.librepods.data.updates.updates
import me.kavishdevar.librepods.devices.AppleDevice
import me.kavishdevar.librepods.devices.Device
import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen
import me.kavishdevar.librepods.presentation.screens.BLESettingsScreenRoute
import me.kavishdevar.librepods.presentation.screens.DeviceListScreen
import me.kavishdevar.librepods.presentation.screens.OpenSourceLicensesScreen
import me.kavishdevar.librepods.presentation.screens.PurchaseScreen
import me.kavishdevar.librepods.presentation.screens.ReleaseNotesScreen
import me.kavishdevar.librepods.presentation.screens.apple.AccessibilitySettingsScreen
import me.kavishdevar.librepods.presentation.screens.apple.AdaptiveStrengthScreen
import me.kavishdevar.librepods.presentation.screens.apple.AppleSettingsRoute
import me.kavishdevar.librepods.presentation.screens.apple.CallControlScreen
import me.kavishdevar.librepods.presentation.screens.apple.DebugRoute
import me.kavishdevar.librepods.presentation.screens.apple.EqualizerRoute
import me.kavishdevar.librepods.presentation.screens.apple.HeadTrackingScreen
import me.kavishdevar.librepods.presentation.screens.apple.HearingAidAdjustmentsScreen
import me.kavishdevar.librepods.presentation.screens.apple.HearingAidScreen
import me.kavishdevar.librepods.presentation.screens.apple.HearingProtectionScreen
import me.kavishdevar.librepods.presentation.screens.apple.HeartRateRoute
import me.kavishdevar.librepods.presentation.screens.apple.LongPress
import me.kavishdevar.librepods.presentation.screens.apple.MicrophoneSettingsRoute
import me.kavishdevar.librepods.presentation.screens.apple.RecordingScreenRoute
import me.kavishdevar.librepods.presentation.screens.apple.RenameScreen
import me.kavishdevar.librepods.presentation.screens.apple.TransparencySettingsScreen
import me.kavishdevar.librepods.presentation.screens.apple.UpdateHearingTestRoute
import me.kavishdevar.librepods.presentation.screens.apple.VersionScreen
import me.kavishdevar.librepods.presentation.screens.onboarding.OnboardingScreen
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
import me.kavishdevar.librepods.repository.AppDataRepository
import me.kavishdevar.librepods.repository.HeartRateRepository
import me.kavishdevar.librepods.repository.RecordingRepository
private fun createAppleViewModelFactory(
device: AppleDevice,
appDataRepository: AppDataRepository,
recordingRepository: RecordingRepository,
heartRateRepository: HeartRateRepository
): ViewModelProvider.Factory = viewModelFactory {
initializer {
AppleViewModel(
device = device,
appDataRepository = appDataRepository,
recordingRepository = recordingRepository,
heartRateRepository = heartRateRepository
)
}
}
@Composable
fun RenderScreenContent(
screen: Screen,
backStack: NavBackStack<Screen>,
backRequests: Channel<CompletableDeferred<Unit>>,
devices: Map<MacAddress, Device<*, *, *>>,
appDataRepository: AppDataRepository,
recordingRepository: RecordingRepository,
heartRateRepository: HeartRateRepository,
showReleaseNotes: Boolean,
updatesShown: () -> Unit,
onboardingComplete: () -> Unit
) {
val navigate: (Screen) -> Unit = { target -> backStack.add(target) }
fun navigateToPurchase() = navigate(Screen.Purchase)
val navigateBack: (() -> Unit)? = if (backStack.size > 1) {
{
val completed = CompletableDeferred<Unit>()
backRequests.trySend(completed)
}
} else null
when (screen) {
Screen.Onboarding -> {
OnboardingScreen {
onboardingComplete()
if (showReleaseNotes) navigate(Screen.ReleaseNotes) else navigate(Screen.DeviceList)
backStack.remove(screen)
}
}
Screen.DeviceList -> {
DeviceListScreen(
devices = devices,
navigateToAppSettings = { navigate(Screen.AppSettings) },
navigateToDevice = { macAddress ->
when (devices[macAddress]) {
is AppleDevice -> navigate(Screen.AppleScreen(macAddress))
else -> {}
}
}
)
}
is Screen.AppleScreen -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
val left = stringResource(R.string.left)
val right = stringResource(R.string.right)
AppleSettingsRoute(
viewModel = appleViewModel,
navigateBack = navigateBack,
navigateToRename = { navigate(Screen.Rename(screen.macAddress)) },
navigateToHearingProtection = { navigate(Screen.HearingProtection(screen.macAddress)) },
navigateToHearingAid = { navigate(Screen.HearingAid(screen.macAddress)) },
navigateToLeftLongPress = { navigate(Screen.LongPress(screen.macAddress, left)) },
navigateToRightLongPress = { navigate(Screen.LongPress(screen.macAddress, right)) },
navigateToPurchase = ::navigateToPurchase,
navigateToAdaptiveStrength = { navigate(Screen.AdaptiveStrength(screen.macAddress)) },
navigateToEqualizer = { navigate(Screen.Equalizer(screen.macAddress)) },
navigateToHeadTracking = { navigate(Screen.HeadTracking(screen.macAddress)) },
navigateToAccessibility = { navigate(Screen.Accessibility(screen.macAddress)) },
navigateToVersion = { navigate(Screen.VersionInfo(screen.macAddress)) },
navigateToCallControlScreen = { navigate(Screen.CallControl(screen.macAddress, it)) },
navigateToMicrophoneSettings = { navigate(Screen.MicrophoneSettings(screen.macAddress)) },
navigateToRecordingScreen = { navigate(Screen.Recording(screen.macAddress)) },
navigateToHeartRateScreen = { navigate(Screen.HeartRate(screen.macAddress)) },
navigateToDebugScreen = { navigate(Screen.Debug(screen.macAddress)) }
)
}
is Screen.Rename -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
RenameScreen(
viewModel = appleViewModel,
navigateBack = navigateBack,
)
}
Screen.AppSettings -> {
val factory = viewModelFactory {
initializer { AppSettingsViewModel(appDataRepository = appDataRepository) }
}
val appSettingsViewModel: AppSettingsViewModel = viewModel(factory = factory)
AppSettingsScreen(
viewModel = appSettingsViewModel,
navigateBack = navigateBack,
navigateToPurchase = ::navigateToPurchase,
navigateToOpenSourceLicenses = { navigate(Screen.OpenSourceLicenses) },
navigateToReleaseNotesScreen = { navigate(Screen.ReleaseNotes) },
navigateToBleSettingsScreen = { navigate(Screen.BLESettings) }
)
}
is Screen.HeadTracking -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
HeadTrackingScreen(
viewModel = appleViewModel,
navigateBack = navigateBack,
navigateToPurchase = ::navigateToPurchase
)
}
is Screen.Accessibility -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
AccessibilitySettingsScreen(
viewModel = appleViewModel,
navigateBack = navigateBack,
navigateToPurchase = ::navigateToPurchase,
navigateToTransparencyCustomization = {
navigate(Screen.TransparencyCustomization(screen.macAddress))
}
)
}
is Screen.TransparencyCustomization -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
TransparencySettingsScreen(
viewModel = appleViewModel,
navigateBack = navigateBack,
)
}
is Screen.HearingAid -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
HearingAidScreen(
viewModel = appleViewModel,
navigateBack = navigateBack,
navigateToHearingAidAdjustments = { navigate(Screen.HearingAidAdjustments(screen.macAddress)) },
navigateToHearingTest = { navigate(Screen.UpdateHearingTest(screen.macAddress)) }
)
}
is Screen.HearingAidAdjustments -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
HearingAidAdjustmentsScreen(
viewModel = appleViewModel,
navigateBack = navigateBack,
)
}
is Screen.AdaptiveStrength -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
AdaptiveStrengthScreen(
viewModel = appleViewModel,
navigateBack = navigateBack,
navigateToPurchase = ::navigateToPurchase
)
}
Screen.OpenSourceLicenses -> OpenSourceLicensesScreen(navigateBack)
is Screen.UpdateHearingTest -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
UpdateHearingTestRoute(
viewModel = appleViewModel,
navigateBack = navigateBack,
)
}
is Screen.VersionInfo -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
VersionScreen(
viewModel = appleViewModel,
navigateBack = navigateBack
)
}
is Screen.HearingProtection -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
HearingProtectionScreen(
viewModel = appleViewModel,
navigateBack = navigateBack,
navigateToPurchase = ::navigateToPurchase
)
}
is Screen.Purchase -> {
val viewModel: PurchaseViewModel = viewModel()
PurchaseScreen(
viewModel = viewModel,
navigateBack = navigateBack
)
}
is Screen.Equalizer -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
EqualizerRoute(
viewModel = appleViewModel,
navigateBack = navigateBack
)
}
is Screen.LongPress -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
LongPress(
viewModel = appleViewModel,
name = screen.bud,
navigateBack = navigateBack,
navigateToPurchase = ::navigateToPurchase
)
}
is Screen.CallControl -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
CallControlScreen(
viewModel = appleViewModel,
action = screen.action,
navigateBack = navigateBack,
onCallControlValueChanged = { flipped ->
device.setControlCommand(
ControlCommandIdentifier.CALL_MANAGEMENT_CONFIG,
if (flipped) byteArrayOf(0x00, 0x02) else byteArrayOf(0x00, 0x03)
)
}
)
}
is Screen.MicrophoneSettings -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
MicrophoneSettingsRoute(
viewModel = appleViewModel,
navigateBack = navigateBack
)
}
is Screen.ReleaseNotes -> {
ReleaseNotesScreen(
updates = updates,
releaseNotesShown = {
if (showReleaseNotes) {
navigate(Screen.DeviceList)
backStack.remove(screen)
updatesShown()
} else {
backStack.removeAt(backStack.lastIndex)
}
}
)
}
is Screen.Recording -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
RecordingScreenRoute(
viewModel = appleViewModel,
navigateBack = navigateBack
)
}
is Screen.Debug -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
DebugRoute(
viewModel = appleViewModel,
navigateBack = navigateBack
)
}
is Screen.BLESettings -> {
val factory = viewModelFactory {
initializer { AppSettingsViewModel(appDataRepository = appDataRepository) }
}
val appSettingsViewModel: AppSettingsViewModel = viewModel(factory = factory)
BLESettingsScreenRoute(
viewModel = appSettingsViewModel,
navigateBack = navigateBack
)
}
is Screen.HeartRate -> {
val device = devices[screen.macAddress] as? AppleDevice ?: return
val factory = createAppleViewModelFactory(device, appDataRepository, recordingRepository, heartRateRepository)
val appleViewModel: AppleViewModel = viewModel(
key = "${screen.macAddress.value}:${device.connectionNumber}",
factory = factory
)
HeartRateRoute(
viewModel = appleViewModel,
navigateBack = navigateBack
)
}
}
}
@@ -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
@@ -0,0 +1,297 @@
package me.kavishdevar.librepods.presentation.navigation
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animate
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.scene.Scene
import androidx.navigation3.scene.SceneStrategy
import androidx.navigation3.scene.SceneStrategyScope
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
// mostly AI generated
private enum class Direction {
Forward,
Back,
None
}
class SwipeBackSceneStrategy<T : Any>(
private val enabled: Boolean,
private val backRequests: Channel<CompletableDeferred<Unit>>,
private val onDismiss: () -> Unit
) : SceneStrategy<T> {
private var previousEntries: List<NavEntry<T>> = emptyList()
override fun SceneStrategyScope<T>.calculateScene(
entries: List<NavEntry<T>>
): Scene<T>? {
if (entries.isEmpty()) return null
val currentEntry = entries.last()
val previousEntry = entries.getOrNull(entries.lastIndex - 1)
val direction = when {
previousEntries.isEmpty() -> Direction.None
currentEntry in previousEntries -> Direction.Back
else -> Direction.Forward
}
previousEntries = entries
//
// if (previousEntry == null) {
// return object : Scene<T> {
// override val key: Any
// get() = "${currentEntry.contentKey}_${currentEntry.hashCode()}"
//
// override val entries: List<NavEntry<T>>
// get() = listOf(currentEntry)
//
// override val previousEntries: List<NavEntry<T>>
// get() = emptyList()
//
// override val content: @Composable () -> Unit
// get() = { currentEntry.Content() }
// }
// }
return object : Scene<T> {
override val key: Any
get() = "${currentEntry.contentKey}_${currentEntry.hashCode()}"
override val entries: List<NavEntry<T>>
get() = listOfNotNull(previousEntry, currentEntry)
override val previousEntries: List<NavEntry<T>>
get() = listOfNotNull(previousEntry)
override val content: @Composable () -> Unit
get() = {
SwipeBackSceneContent(
previousEntry = previousEntry,
currentEntry = currentEntry,
direction = direction,
swipeEnabled = enabled,
backRequests = backRequests,
onDismiss = onDismiss
)
}
}
}
}
@Composable
private fun <T : Any> SwipeBackSceneContent(
previousEntry: NavEntry<T>?,
currentEntry: NavEntry<T>,
direction: Direction,
swipeEnabled: Boolean,
backRequests: Channel<CompletableDeferred<Unit>>,
onDismiss: () -> Unit
) {
val density = LocalDensity.current
val screenWidthPx = with(density) {
LocalWindowInfo.current.containerDpSize.width.toPx()
}
val animatedOffset = remember(
"${currentEntry.contentKey}_${currentEntry.hashCode()}"
) {
Animatable(
if (direction == Direction.Forward) {
screenWidthPx
} else {
0f
}
)
}
var transitionProgress by remember {
mutableFloatStateOf(
if (direction == Direction.Forward) 1f else 0f
)
}
val scope = rememberCoroutineScope()
LaunchedEffect(
"${currentEntry.contentKey}_${currentEntry.hashCode()}",
direction
) {
if (direction == Direction.Forward) {
transitionProgress = 1f
animatedOffset.animateTo(
0f,
tween(220)
)
transitionProgress = 0f
}
}
LaunchedEffect(Unit) {
for (completed in backRequests) {
animatedOffset.animateTo(
screenWidthPx,
tween(150)
)
transitionProgress = -1f
onDismiss()
completed.complete(Unit)
}
}
PredictiveBackHandler { progressFlow ->
try {
progressFlow.collect { backEvent ->
val progress = backEvent.progress
transitionProgress = -progress
animatedOffset.snapTo(
progress * screenWidthPx
)
}
animatedOffset.animateTo(
screenWidthPx,
tween(150)
)
transitionProgress = -1f
onDismiss()
} catch (_: CancellationException) {
animatedOffset.animateTo(
0f,
tween(150)
)
transitionProgress = 0f
}
}
val draggableState = rememberDraggableState { delta ->
scope.launch {
val offset =
(animatedOffset.value + delta)
.coerceAtLeast(0f)
animatedOffset.snapTo(offset)
transitionProgress =
-(offset / screenWidthPx)
.coerceIn(0f, 1f)
}
}
CompositionLocalProvider(
LocalTransitionProgress provides transitionProgress
) {
Box(Modifier.fillMaxSize()) {
CompositionLocalProvider(
LocalIsCurrentEntry provides false
) {
Box(
Modifier
.fillMaxSize()
.graphicsLayer {
translationX =
if (direction == Direction.Forward) {
0f
} else {
(-screenWidthPx / 3f) +
(animatedOffset.value / 3f)
}
}
) {
previousEntry?.Content()
}
}
CompositionLocalProvider(
LocalIsCurrentEntry provides true
) {
Box(
Modifier
.fillMaxSize()
.graphicsLayer {
translationX = animatedOffset.value
}
.draggable(
enabled = swipeEnabled && previousEntry != null,
state = draggableState,
orientation = Orientation.Horizontal,
onDragStopped = { velocity ->
val currentOffset = animatedOffset.value
val currentProgress =
(currentOffset / screenWidthPx).coerceIn(0f, 1f)
val shouldDismiss =
currentOffset > screenWidthPx * 0.35f ||
velocity > 1000f
scope.launch {
if (shouldDismiss) {
animate(
initialValue = currentProgress,
targetValue = 1f,
animationSpec = tween(150)
) { value, _ ->
transitionProgress = -value
scope.launch {
animatedOffset.snapTo(value * screenWidthPx)
}
}
transitionProgress = -1f
onDismiss()
} else {
animate(
initialValue = currentProgress,
targetValue = 0f,
animationSpec = tween(150)
) { value, _ ->
transitionProgress = -value
scope.launch {
animatedOffset.snapTo(value * screenWidthPx)
}
}
transitionProgress = 0f
animatedOffset.snapTo(0f)
}
}
}
)
) {
currentEntry.Content()
}
}
}
}
}
@@ -0,0 +1,11 @@
package me.kavishdevar.librepods.presentation.navigation
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.runtime.compositionLocalOf
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope> {
error("LocalSharedTransitionScope not provided")
}
val LocalTransitionProgress = compositionLocalOf { 0f }
val LocalIsCurrentEntry = compositionLocalOf { false }
@@ -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(
@@ -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))
}
}
@@ -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<MacAddress, Device<*, *, *>>,
navigateToDevice: (MacAddress) -> Unit,
) {
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Box (
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
DeviceListScreen(
devices = devices,
navigateToDevice = navigateToDevice,
topPadding = topPadding,
bottomPadding = bottomPadding
)
}
}
@Composable
fun DeviceListScreen(
devices: Map<MacAddress, Device<*, *, *>>,
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))
}
}
}
@@ -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()
}
}
@@ -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))
}
}
}
@@ -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<Screen>
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))
}
}
@@ -1,922 +0,0 @@
/*
LibrePods - AirPods liberated from Apples ecosystem
Copyright (C) 2025 LibrePods contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.presentation.screens
import android.content.Intent
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.icons.LocalIcons
import me.kavishdevar.librepods.utils.LogCollector
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun CustomIconButton(
onClick: () -> Unit,
content: @Composable () -> Unit
) {
Box(
modifier = Modifier
.clickable(onClick = onClick)
.padding(8.dp),
contentAlignment = Alignment.Center
) {
content()
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable
fun TroubleshootingScreen() {
val context = LocalContext.current
val scrollState = rememberScrollState()
val coroutineScope = rememberCoroutineScope()
val logCollector = remember { LogCollector(context) }
val savedLogs = remember { mutableStateListOf<File>() }
var isCollectingLogs by remember { mutableStateOf(false) }
var showTroubleshootingSteps by remember { mutableStateOf(false) }
var currentStep by remember { mutableIntStateOf(0) }
var logContent by remember { mutableStateOf("") }
var selectedLogFile by remember { mutableStateOf<File?>(null) }
var showDeleteDialog by remember { mutableStateOf(false) }
var showDeleteAllDialog by remember { mutableStateOf(false) }
var isLoadingLogContent by remember { mutableStateOf(false) }
var logContentLoaded by remember { mutableStateOf(false) }
LaunchedEffect(isCollectingLogs) {
while (isCollectingLogs) {
delay(250)
delay(250)
}
}
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
var showBottomSheet by remember { mutableStateOf(false) }
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
val accentColor = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val buttonBgColor = if (isSystemInDarkTheme()) Color(0xFF333333) else Color(0xFFDDDDDD)
var instructionText by remember { mutableStateOf("") }
val isDarkTheme = isSystemInDarkTheme()
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
val logsDir = File(context.filesDir, "logs")
if (logsDir.exists()) {
savedLogs.clear()
savedLogs.addAll(logsDir.listFiles()?.filter { it.name.endsWith(".txt") }
?.sortedByDescending { it.lastModified() } ?: emptyList())
}
}
}
val saveLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("text/plain")
) { uri ->
if (uri != null) {
coroutineScope.launch(Dispatchers.IO) {
try {
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(logContent.toByteArray())
}
withContext(Dispatchers.Main) {
Toast.makeText(context, "Log saved successfully", Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(
context,
"Failed to save log: ${e.localizedMessage}",
Toast.LENGTH_SHORT
).show()
}
}
}
}
}
LaunchedEffect(currentStep) {
instructionText = when (currentStep) {
0 -> "First, let's ensure Xposed module is properly configured. Tap the button below to check Xposed scope settings."
1 -> "Please put your AirPods in the case and close it, so they disconnect completely."
2 -> "Preparing to collect logs... Please wait."
3 -> "Now, open the AirPods case and connect your AirPods. Logs are being collected. Connection will be detected automatically, or you can manually stop logging when you're done."
4 -> "Log collection complete! You can now save or share the logs."
else -> ""
}
}
fun openLogBottomSheet(file: File) {
selectedLogFile = file
logContent = ""
isLoadingLogContent = false
logContentLoaded = false
showBottomSheet = true
}
val backdrop = rememberLayerBackdrop()
Box(
modifier = Modifier.fillMaxSize()
) {
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.verticalScroll(scrollState)
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
Text(
text = stringResource(R.string.saved_logs),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.inter))
),
modifier = Modifier.padding(16.dp, bottom = 4.dp, top = 8.dp)
)
Spacer(modifier = Modifier.height(2.dp))
if (savedLogs.isEmpty()) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor,
RoundedCornerShape(28.dp)
)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(R.string.no_logs_found),
fontSize = 16.sp,
color = textColor
)
}
} else {
Column(
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor,
RoundedCornerShape(28.dp)
)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Total Logs: ${savedLogs.size}",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = textColor
)
if (savedLogs.size > 1) {
TextButton(
onClick = { showDeleteAllDialog = true },
colors = ButtonDefaults.textButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("Delete All")
}
}
}
savedLogs.forEach { logFile ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.clickable {
openLogBottomSheet(logFile)
},
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = logFile.name,
fontSize = 16.sp,
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.US)
.format(Date(logFile.lastModified())),
fontSize = 14.sp,
color = textColor.copy(alpha = 0.6f)
)
}
CustomIconButton(
onClick = {
selectedLogFile = logFile
showDeleteDialog = true
}
) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
AnimatedVisibility(
visible = !showTroubleshootingSteps,
enter = fadeIn(animationSpec = tween(300)),
exit = fadeOut(animationSpec = tween(300))
) {
Button(
onClick = { showTroubleshootingSteps = true },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
),
enabled = !isCollectingLogs
) {
Text(stringResource(R.string.collect_logs))
}
}
AnimatedVisibility(
visible = showTroubleshootingSteps,
enter = fadeIn(animationSpec = tween(300)) +
slideInVertically(animationSpec = tween(300)) { it / 2 },
exit = fadeOut(animationSpec = tween(300)) +
slideOutVertically(animationSpec = tween(300)) { it / 2 }
) {
Column {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.troubleshooting_steps),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.inter))
),
modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 8.dp)
)
Spacer(modifier = Modifier.height(2.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.background(
backgroundColor,
RoundedCornerShape(28.dp)
)
.padding(16.dp)
) {
val textAlpha = animateFloatAsState(
targetValue = 1f,
animationSpec = tween(durationMillis = 300),
label = "textAlpha"
)
Text(
text = instructionText,
fontSize = 16.sp,
color = textColor.copy(alpha = textAlpha.value),
lineHeight = 22.sp
)
Spacer(modifier = Modifier.height(16.dp))
when (currentStep) {
0 -> {
Button(
onClick = {
coroutineScope.launch {
logCollector.openXposedSettings(context)
delay(2000)
currentStep = 1
}
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
)
) {
Text("Open Xposed Settings")
}
}
1 -> {
Button(
onClick = {
currentStep = 2
isCollectingLogs = true
coroutineScope.launch {
try {
logCollector.clearLogs()
logCollector.addLogMarker(LogCollector.LogMarkerType.START)
logCollector.killBluetoothService()
withContext(Dispatchers.Main) {
delay(500)
currentStep = 3
}
val timestamp = SimpleDateFormat(
"yyyyMMdd_HHmmss",
Locale.US
).format(Date())
logContent =
logCollector.startLogCollection(
listener = { /* Removed live log display */ },
connectionDetectedCallback = {
launch {
delay(5000)
withContext(Dispatchers.Main) {
if (isCollectingLogs) {
logCollector.stopLogCollection()
currentStep = 4
isCollectingLogs =
false
}
}
}
}
)
val logFile =
logCollector.saveLogToInternalStorage(
"airpods_log_$timestamp.txt",
logContent
)
logFile?.let {
withContext(Dispatchers.Main) {
savedLogs.add(0, it)
selectedLogFile = it
Toast.makeText(
context,
"Log saved: ${it.name}",
Toast.LENGTH_SHORT
).show()
}
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
Toast.makeText(
context,
"Error collecting logs: ${e.message}",
Toast.LENGTH_SHORT
).show()
isCollectingLogs = false
currentStep = 0
}
}
}
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
)
) {
Text("Continue")
}
}
2, 3 -> {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator(
color = accentColor
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = if (currentStep == 2) "Preparing..." else "Collecting logs...",
fontSize = 14.sp,
color = textColor
)
if (currentStep == 3) {
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
coroutineScope.launch {
logCollector.addLogMarker(
LogCollector.LogMarkerType.CUSTOM,
"Manual stop requested by user"
)
delay(1000)
logCollector.stopLogCollection()
delay(500)
withContext(Dispatchers.Main) {
currentStep = 4
isCollectingLogs = false
Toast.makeText(
context,
"Log collection stopped",
Toast.LENGTH_SHORT
).show()
}
}
},
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
),
modifier = Modifier
.fillMaxWidth()
) {
Text("Stop Collection")
}
}
}
}
4 -> {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Button(
onClick = {
selectedLogFile?.let { file ->
val fileUri = FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
file
)
val shareIntent =
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(
Intent.EXTRA_STREAM,
fileUri
)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(
Intent.createChooser(
shareIntent,
"Share log file"
)
)
}
},
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
),
modifier = Modifier.width(150.dp)
) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = "Share"
)
Spacer(modifier = Modifier.width(8.dp))
Text("Share")
}
Spacer(modifier = Modifier.width(16.dp))
Button(
onClick = {
selectedLogFile?.let { file ->
saveLauncher.launch(
file.absolutePath
)
}
},
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
),
modifier = Modifier.width(150.dp)
) {
Icon(
imageVector = LocalIcons.current.Save,
contentDescription = "Save"
)
Spacer(modifier = Modifier.width(8.dp))
Text("Save")
}
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
currentStep = 0
showTroubleshootingSteps = false
},
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
)
) {
Text("Done")
}
}
}
}
}
}
if (showDeleteDialog && selectedLogFile != null) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text("Delete Log File") },
text = {
Text("Are you sure you want to delete this log file? This action cannot be undone.")
},
confirmButton = {
TextButton(
onClick = {
selectedLogFile?.let { file ->
if (file.delete()) {
savedLogs.remove(file)
Toast.makeText(
context,
"Log file deleted",
Toast.LENGTH_SHORT
)
.show()
} else {
Toast.makeText(
context,
"Failed to delete log file",
Toast.LENGTH_SHORT
).show()
}
}
showDeleteDialog = false
}
) {
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text("Cancel")
}
}
)
}
if (showDeleteAllDialog) {
AlertDialog(
onDismissRequest = { showDeleteAllDialog = false },
title = { Text("Delete All Logs") },
text = {
Text("Are you sure you want to delete all log files? This action cannot be undone and will remove ${savedLogs.size} log files.")
},
confirmButton = {
TextButton(
onClick = {
coroutineScope.launch(Dispatchers.IO) {
var deletedCount = 0
savedLogs.forEach { file ->
if (file.delete()) {
deletedCount++
}
}
withContext(Dispatchers.Main) {
if (deletedCount > 0) {
savedLogs.clear()
Toast.makeText(
context,
"Deleted $deletedCount log files",
Toast.LENGTH_SHORT
).show()
} else {
Toast.makeText(
context,
"Failed to delete log files",
Toast.LENGTH_SHORT
).show()
}
}
}
showDeleteAllDialog = false
}
) {
Text("Delete All", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showDeleteAllDialog = false }) {
Text("Cancel")
}
}
)
}
Spacer(modifier = Modifier.height(bottomPadding))
}
if (showBottomSheet) {
ModalBottomSheet(
onDismissRequest = { showBottomSheet = false },
sheetState = sheetState,
containerColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7),
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
tonalElevation = 8.dp
) {
LaunchedEffect(selectedLogFile) {
if (!logContentLoaded) {
delay(300)
withContext(Dispatchers.IO) {
isLoadingLogContent = true
logContent = try {
selectedLogFile?.readText() ?: ""
} catch (e: Exception) {
"Error loading log content: ${e.message}"
}
isLoadingLogContent = false
logContentLoaded = true
}
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
.padding(bottom = 32.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
) {
Text(
text = selectedLogFile?.name ?: "Log Content",
style = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
fontFamily = FontFamily(Font(R.font.inter))
),
color = textColor
)
Text(
text = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.US)
.format(Date(selectedLogFile?.lastModified() ?: 0)),
fontSize = 14.sp,
color = textColor.copy(alpha = 0.7f),
fontFamily = FontFamily(Font(R.font.inter))
)
}
if (isLoadingLogContent) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(300.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = accentColor)
}
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.height(300.dp)
.background(
color = Color.Black,
shape = RoundedCornerShape(8.dp)
)
) {
val horizontalScrollState = rememberScrollState()
val verticalScrollState = rememberScrollState()
Box(
modifier = Modifier
.fillMaxSize()
.padding(8.dp)
.horizontalScroll(horizontalScrollState)
.verticalScroll(verticalScrollState)
) {
Text(
text = logContent,
fontSize = 14.sp,
color = Color.LightGray,
lineHeight = 20.sp,
fontFamily = FontFamily.Monospace,
softWrap = false
)
}
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Button(
onClick = {
selectedLogFile?.let { file ->
val fileUri = FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
file
)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, fileUri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(
Intent.createChooser(
shareIntent,
"Share log file"
)
)
}
},
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
),
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = "Share"
)
Spacer(modifier = Modifier.width(8.dp))
Text("Share")
}
Button(
onClick = {
selectedLogFile?.let { file ->
saveLauncher.launch(file.absolutePath)
}
},
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(
containerColor = buttonBgColor,
contentColor = textColor
),
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = LocalIcons.current.Save,
contentDescription = "Save"
)
Spacer(modifier = Modifier.width(8.dp))
Text("Save")
}
}
}
}
}
}
DisposableEffect(Unit) {
onDispose {
logCollector.stopLogCollection()
}
}
}
@@ -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))
}
}
}
@@ -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))
}
}
}
@@ -16,25 +16,17 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@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 = {}
)
}
@@ -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))
}
}
@@ -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))
}
}
}
@@ -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)))}
)
}
}
@@ -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<AppleEvent.HeadGesturesResult>()
.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))
}
}
@@ -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))
}
}
}
@@ -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}")
}
}
},
@@ -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))
}
}
@@ -0,0 +1,253 @@
package me.kavishdevar.librepods.presentation.screens.apple
import android.text.format.DateFormat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.FilledTonalIconToggleButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.health.connect.client.permission.HealthPermission
import androidx.health.connect.client.records.HeartRateRecord
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.components.StyledListItemOrientation
import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.presentation.icons.LocalIcons
import me.kavishdevar.librepods.presentation.icons.MaterialIcons
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import me.kavishdevar.librepods.presentation.viewmodel.AppleUiState
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable
fun HeartRateRoute(
viewModel: AppleViewModel,
navigateBack: (() -> Unit)?
) {
val uiState by viewModel.uiState.collectAsState()
HeartRateScreen(
uiState = uiState,
navigateBack = navigateBack,
startHr = viewModel::startHr,
stopHr = viewModel::stopHr,
setHrRange = viewModel::setHrRange
)
}
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun HeartRateScreen(
uiState: AppleUiState,
navigateBack: (() -> Unit)?,
startHr: () -> Unit,
stopHr: () -> Unit,
setHrRange: (ClosedRange<Long>) -> Unit
) {
val state = uiState.state
val scrollState = rememberScrollState()
StyledScaffold(
title = stringResource(R.string.heart_rate),
navigateBack = navigateBack,
actionButtons = listOf(
{ scaffoldBackdrop ->
if (LocalDesignSystem.current == DesignSystem.Material) {
FilledTonalIconToggleButton(
checked = state.hrmActive,
onCheckedChange = { if (it) startHr() else stopHr() },
modifier = Modifier
.minimumInteractiveComponentSize()
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)),
shape = IconButtonDefaults.mediumRoundShape
) {
Icon(
imageVector = if (state.hrmActive) MaterialIcons.Pause else Icons.Default.PlayArrow,
contentDescription = "Start/Stop",
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
)
}
} else {
StyledIconButton(
onClick = { if (!state.hrmActive) startHr() else stopHr() },
backdrop = scaffoldBackdrop
) {
Icon(
imageVector = if (state.hrmActive) LocalIcons.current.Pause else LocalIcons.current.Play,
contentDescription = "Start/Stop",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onBackground
)
}
}
}
)
) { topPadding, bottomPadding ->
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
val sliderValue = remember {
mutableFloatStateOf(state.heartRateInterval.inWholeMilliseconds.toFloat())
}
val healthPermissions = rememberPermissionState(
HealthPermission.getWritePermission(HeartRateRecord::class)
)
AnimatedVisibility(visible = !healthPermissions.status.isGranted) {
if (healthPermissions.status.isGranted) return@AnimatedVisibility
StyledListItem(
contentText = stringResource(R.string.permission_healthconnect),
onClick = { healthPermissions.launchPermissionRequest() },
supportingText = stringResource(R.string.permission_description_healthconnect),
leadingContent = {
Box(
modifier = Modifier
.size(48.dp)
.background(
MaterialTheme.colorScheme.surfaceContainerLow,
MaterialShapes.SoftBurst.normalized()
.toShape()
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = LocalIcons.current.VitalSigns,
contentDescription = "vital signs",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurface
)
}
},
orientation = StyledListItemOrientation.Vertical
)
}
// saw this while using one of the forks. 1 minute doesn't seem to work for me
// StyledList(
// title = stringResource(R.string.interval),
// description = stringResource(R.string.heart_rate_interval_description),
// ) {
// StyledListItem(
// onClick = { setHrInterval(1.seconds) },
// contentText = stringResource(R.string.one_second),
// selected = state.heartRateInterval == 1.seconds
// )
// StyledListItem(
// onClick = { setHrInterval(1.minutes) },
// contentText = stringResource(R.string.one_minute),
// selected = state.heartRateInterval == 1.minutes
// )
// }
AnimatedVisibility(state.currentHeartRate != null) {
if (state.currentHeartRate == null) return@AnimatedVisibility
StyledListItem(
onClick = null,
content = {
Text(
text = "${state.currentHeartRate.bpm} bpm",
style = MaterialTheme.typography.bodyLargeEmphasized,
modifier = Modifier.fillMaxHeight()
)
},
supportingContent = {
val locale = LocalLocale.current.platformLocale
val timePattern = DateFormat.getBestDateTimePattern(
locale,
"jms"
)
val formatter = DateTimeFormatter.ofPattern(timePattern)
val timeString = formatter.format(
Instant.ofEpochMilli(state.currentHeartRate.timestamp.toEpochMilliseconds())
.atZone(ZoneId.systemDefault())
)
Text(
text = timeString,
style = MaterialTheme.typography.bodySmall
)
},
leadingContent = {
Box(
modifier = Modifier
.size(48.dp)
.background(
MaterialTheme.colorScheme.primaryContainer,
MaterialShapes.SoftBurst.normalized()
.toShape()
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = LocalIcons.current.VitalSigns,
contentDescription = "vital signs",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
},
orientation = StyledListItemOrientation.Vertical
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 350.dp)
.background(
MaterialTheme.colorScheme.surfaceContainerHigh,
RoundedCornerShape(28.dp)
)
) {
// TODO: graph or something
}
Spacer(modifier = Modifier.height(bottomPadding))
}
}
}
@@ -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))
}
}
@@ -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))
}
}
@@ -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<Recording>,
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))
}
}
@@ -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))
}
}
@@ -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))
}
}
@@ -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<Job?>(null) }
val initialized = rememberSaveable { mutableStateOf(false) }
val debounceJob = remember { mutableStateOf<Job?>(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 = { _, _ -> }
)
}
@@ -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))
}
}
@@ -93,6 +93,7 @@ fun OnboardingScreen(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(topPadding))
HorizontalUncontainedCarousel(
modifier = Modifier
.fillMaxWidth()
@@ -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
)
@@ -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,
)
}
@@ -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<Recording> = emptyList(),
val heartRateSamples: List<HeartRateSample> = emptyList(),
val heartRateRange: ClosedRange<Long>? = 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<ClosedRange<Long>?>(null)
private val heartRateSamples: Flow<List<HeartRateSample>> = _heartRateRange
.transformLatest { range ->
if (range == null) {
emit(emptyList())
} else {
val startInstant = Instant.fromEpochMilliseconds(range.start)
val endInstant = Instant.fromEpochMilliseconds(range.endInclusive)
val samples = heartRateRepository.get(startInstant, endInstant)
emit(samples)
}
}
private val deviceDetails = combine(
device.state,
device.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<Long>) {
_heartRateRange.value = range
}
fun sendRawPacket(data: ByteArray): Boolean = device.sendRawPacket(data)
}
@@ -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
}
@@ -0,0 +1,29 @@
package me.kavishdevar.librepods.repository
import me.kavishdevar.librepods.data.heartrate.HeartRateDao
import me.kavishdevar.librepods.data.heartrate.HeartRateSample
import me.kavishdevar.librepods.database.heartrate.HeartRateSampleEntity
import kotlin.time.Instant
class HeartRateRepository(
private val dao: HeartRateDao,
) {
suspend fun insert(sample: HeartRateSample) {
dao.insert(sample.toEntity())
}
suspend fun get(
start: Instant,
end: Instant,
): List<HeartRateSample> = dao.get(start, end).map { it.toSample() }
private fun HeartRateSample.toEntity() = HeartRateSampleEntity(
timestamp = timestamp,
bpm = bpm
)
private fun HeartRateSampleEntity.toSample() = HeartRateSample(
bpm = bpm,
timestamp = timestamp
)
}
@@ -18,16 +18,21 @@ import android.content.ComponentName
import android.content.Context
import android.content.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
@@ -16,20 +16,16 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@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<Double>())
private val verticalBuffer = Collections.synchronizedList(ArrayList<Double>())
@@ -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<Acceleration>, 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 }
@@ -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")
@@ -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<Triple<Int, Int, Int>>()
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()
}
}
@@ -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
+70
View File
@@ -0,0 +1,70 @@
syntax = "proto3";
package rtbuddy;
option java_package = "me.kavishdevar.librepods.bluetooth.aacp.rtbuddy.proto";
option java_multiple_files = true;
enum SensorServiceType {
SENSOR_SERVICE_UNKNOWN = 0;
DRVCOMM = 1;
TTR = 2;
ANALYTICS = 3;
ACTIN = 4;
KADABRA = 5;
MANDO = 6;
IED = 7;
NEO = 8;
NEORELAY = 9;
COMM = 10;
ACCEL = 11;
GYRO = 12;
PDR = 13;
ACTIVITY = 14;
CMA = 15;
DEVMOTION6 = 16;
SPL0 = 17;
HOSTLIBHID = 18;
HEARTRATE = 19;
HEARTRATEv2 = 20;
SENSOR_SERVICE_UNKNOWN_82 = 82;
HEARTRATE_COMMAND = 84;
}
message SensorDataWX {
int32 seq = 1;
int32 log_type = 2;
bytes another_sensor_stream = 3;
RequestAllDescriptors request_all_descriptors = 4;
SensorDescriptor sensor_descriptor = 5;
SensorCommand command = 7;
SensorServiceSetting service_settings = 8;
SensorTypeAck start_ack = 9;
SensorTypeAck command_ack = 12;
}
message RequestAllDescriptors {
}
message SensorCommand {
SensorServiceType service = 1;
bytes payload = 3;
}
message SensorDescriptor {
SensorServiceType service = 1;
bytes sensor_descriptor = 2;
}
message SensorTypeAck {
SensorServiceType service = 1;
}
message SensorServiceSetting {
SensorServiceType service = 1;
int32 setting = 2;
bytes configuration = 3;
}
@@ -320,4 +320,24 @@
<string name="enable_debug_mode">Enable debug mode</string>
<string name="ble_report_delay_description">Any value greater than 0 enables batching.</string>
<string name="ble_report_delay">Report delay (in ms)</string>
<string name="interval">Interval</string>
<string name="heart_rate_interval_description">Interval between heart rate measurements</string>
<string name="no_heart_rate_data">No heart rate data</string>
<string name="one_second">1 second</string>
<string name="one_minute">1 minute</string>
<string name="permission_healthconnect">Write heart rate to Health Connect</string>
<string name="permission_description_healthconnect">Allows the app to write heart rate data to Android\'s Health Connect platform.</string>
<string name="required_permissions">Required Permissions</string>
<string name="bluetooth">Bluetooth</string>
<string name="permission_description_bluetooth">Required to communicate with AirPods.</string>
<string name="optional_permissions">Optional Permissions</string>
<string name="notifications">Notifications</string>
<string name="permission_description_notification">Show battery status when connected or available nearby.</string>
<string name="permission_description_phone">Respond to phone calls with head gestures.</string>
<string name="permission_overlay">Display over other apps</string>
<string name="permission_description_overlay">Show popups when AirPods are nearby or audio switches to them.</string>
<string name="swipe_anywhere_to_go_back">Swipe anywhere to go back</string>
<string name="use_highest_refresh_rate">Use highest refresh rate available</string>
<string name="debug_mode_description">Shows extra information and settings.</string>
<string name="interaction">Interaction</string>
</resources>