android: a very big commit

refactoring ui, mostly
This commit is contained in:
Kavish Devar
2025-09-26 03:22:01 +05:30
parent 7e5ee6726f
commit 86a6a28dc1
49 changed files with 2375 additions and 2091 deletions

View File

@@ -109,16 +109,17 @@ import com.google.accompanist.permissions.rememberMultiplePermissionsState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.constants.AirPodsNotifications import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen
import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen
import me.kavishdevar.librepods.screens.AirPodsSettingsScreen import me.kavishdevar.librepods.screens.AirPodsSettingsScreen
import me.kavishdevar.librepods.screens.AppSettingsScreen import me.kavishdevar.librepods.screens.AppSettingsScreen
import me.kavishdevar.librepods.screens.DebugScreen import me.kavishdevar.librepods.screens.DebugScreen
import me.kavishdevar.librepods.screens.HeadTrackingScreen import me.kavishdevar.librepods.screens.HeadTrackingScreen
import me.kavishdevar.librepods.screens.HearingAidScreen
import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen import me.kavishdevar.librepods.screens.HearingAidAdjustmentsScreen
import me.kavishdevar.librepods.screens.TransparencySettingsScreen import me.kavishdevar.librepods.screens.HearingAidScreen
import me.kavishdevar.librepods.screens.LongPress import me.kavishdevar.librepods.screens.LongPress
import me.kavishdevar.librepods.screens.Onboarding import me.kavishdevar.librepods.screens.Onboarding
import me.kavishdevar.librepods.screens.RenameScreen import me.kavishdevar.librepods.screens.RenameScreen
import me.kavishdevar.librepods.screens.TransparencySettingsScreen
import me.kavishdevar.librepods.screens.TroubleshootingScreen import me.kavishdevar.librepods.screens.TroubleshootingScreen
import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
@@ -201,15 +202,12 @@ class MainActivity : ComponentActivity() {
if (data != null && data.scheme == "librepods") { if (data != null && data.scheme == "librepods") {
when (data.host) { when (data.host) {
"add-magic-keys" -> { "add-magic-keys" -> {
// Extract query parameters
val queryParams = data.queryParameterNames val queryParams = data.queryParameterNames
queryParams.forEach { param -> queryParams.forEach { param ->
val value = data.getQueryParameter(param) val value = data.getQueryParameter(param)
// Handle your parameters here
Log.d("LibrePods", "Parameter: $param = $value") Log.d("LibrePods", "Parameter: $param = $value")
} }
// Process the magic keys addition
handleAddMagicKeys(data) handleAddMagicKeys(data)
} }
} }
@@ -369,7 +367,7 @@ fun Main() {
name = navBackStackEntry.arguments?.getString("bud")!! name = navBackStackEntry.arguments?.getString("bud")!!
) )
} }
composable("rename") { navBackStackEntry -> composable("rename") {
RenameScreen(navController) RenameScreen(navController)
} }
composable("app_settings") { composable("app_settings") {
@@ -396,6 +394,9 @@ fun Main() {
composable("hearing_aid_adjustments") { composable("hearing_aid_adjustments") {
HearingAidAdjustmentsScreen(navController) HearingAidAdjustmentsScreen(navController)
} }
composable("adaptive_strength") {
AdaptiveStrengthScreen(navController)
}
} }
} }

View File

@@ -1,3 +1,21 @@
/*
* 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 Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods package me.kavishdevar.librepods

View File

@@ -20,19 +20,17 @@
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
import android.util.Log
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@@ -40,12 +38,14 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@Composable @Composable
fun AudioSettings() { fun AudioSettings(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
@@ -63,12 +63,18 @@ fun AudioSettings() {
Column( Column(
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(14.dp))
.fillMaxWidth() .fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp)) .background(backgroundColor, RoundedCornerShape(14.dp))
.padding(top = 2.dp) .padding(top = 2.dp)
) { ) {
PersonalizedVolumeSwitch() StyledToggle(
label = stringResource(R.string.personalized_volume),
description = stringResource(R.string.personalized_volume_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
independent = false
)
HorizontalDivider( HorizontalDivider(
thickness = 1.5.dp, thickness = 1.5.dp,
color = Color(0x40888888), color = Color(0x40888888),
@@ -76,7 +82,12 @@ fun AudioSettings() {
.padding(start = 12.dp, end = 0.dp) .padding(start = 12.dp, end = 0.dp)
) )
ConversationalAwarenessSwitch() StyledToggle(
label = stringResource(R.string.conversational_awareness),
description = stringResource(R.string.conversational_awareness_description),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
independent = false
)
HorizontalDivider( HorizontalDivider(
thickness = 1.5.dp, thickness = 1.5.dp,
color = Color(0x40888888), color = Color(0x40888888),
@@ -92,39 +103,17 @@ fun AudioSettings() {
.padding(start = 12.dp, end = 0.dp) .padding(start = 12.dp, end = 0.dp)
) )
Column( NavigationButton(
modifier = Modifier to = "adaptive_strength",
.fillMaxWidth() name = stringResource(R.string.adaptive_audio),
.padding(horizontal = 8.dp, vertical = 10.dp) navController = navController,
) { independent = false
Text( )
text = stringResource(R.string.adaptive_audio),
modifier = Modifier
.padding(end = 8.dp, bottom = 2.dp, start = 2.dp)
.fillMaxWidth(),
style = TextStyle(
fontSize = 16.sp,
color = textColor
)
)
Text(
text = stringResource(R.string.adaptive_audio_description),
modifier = Modifier
.padding(bottom = 8.dp, top = 2.dp)
.padding(end = 2.dp, start = 2.dp)
.fillMaxWidth(),
style = TextStyle(
fontSize = 12.sp,
color = textColor.copy(alpha = 0.6f)
)
)
AdaptiveStrengthSlider()
}
} }
} }
@Preview @Preview
@Composable @Composable
fun AudioSettingsPreview() { fun AudioSettingsPreview() {
AudioSettings() AudioSettings(rememberNavController())
} }

View File

@@ -20,6 +20,7 @@
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
import android.content.Context.MODE_PRIVATE
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
@@ -35,8 +36,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -45,7 +46,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import android.content.Context.MODE_PRIVATE
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -59,9 +59,9 @@ import kotlin.io.encoding.ExperimentalEncodingApi
fun AutomaticConnectionSwitch() { fun AutomaticConnectionSwitch() {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE) val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
val service = ServiceManager.getService()!! val service = ServiceManager.getService()!!
val shared_preference_key = "automatic_connection_ctrl_cmd" val sharedPreferenceKey = "automatic_connection_ctrl_cmd"
val automaticConnectionEnabledValue = service.aacpManager.controlCommandStatusList.find { val automaticConnectionEnabledValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG it.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG
}?.value?.takeIf { it.isNotEmpty() }?.get(0) }?.value?.takeIf { it.isNotEmpty() }?.get(0)
@@ -71,7 +71,7 @@ fun AutomaticConnectionSwitch() {
if (automaticConnectionEnabledValue != null) { if (automaticConnectionEnabledValue != null) {
automaticConnectionEnabledValue == 1.toByte() automaticConnectionEnabledValue == 1.toByte()
} else { } else {
sharedPreferences.getBoolean(shared_preference_key, false) sharedPreferences.getBoolean(sharedPreferenceKey, false)
} }
) )
} }
@@ -83,9 +83,9 @@ fun AutomaticConnectionSwitch() {
enabled enabled
) )
// todo: send other connected devices smartAudioRoutingDisabled or something, check packets again. // todo: send other connected devices smartAudioRoutingDisabled or something, check packets again.
sharedPreferences.edit() sharedPreferences.edit()
.putBoolean(shared_preference_key, enabled) .putBoolean(sharedPreferenceKey, enabled)
.apply() .apply()
} }
@@ -95,14 +95,14 @@ fun AutomaticConnectionSwitch() {
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0) val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
val enabled = newValue == 1.toByte() val enabled = newValue == 1.toByte()
automaticConnectionEnabled = enabled automaticConnectionEnabled = enabled
sharedPreferences.edit() sharedPreferences.edit()
.putBoolean(shared_preference_key, enabled) .putBoolean(sharedPreferenceKey, enabled)
.apply() .apply()
} }
} }
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
service.aacpManager.registerControlCommandListener( service.aacpManager.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG, AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG,

View File

@@ -1,17 +1,17 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
* *
* Copyright (C) 2025 LibrePods contributors * Copyright (C) 2025 LibrePods contributors
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published * it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License. * by the Free Software Foundation, either version 3 of the License.
* *
* This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. * GNU Affero General Public License for more details.
* *
* You should have received a copy of the GNU Affero General Public License * You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@@ -19,31 +19,28 @@
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
import androidx.compose.animation.core.animateFloatAsState import android.content.res.Configuration
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -51,85 +48,78 @@ import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
@Composable @Composable
fun BatteryIndicator(batteryPercentage: Int, charging: Boolean = false) { fun BatteryIndicator(
val batteryOutlineColor = Color(0xFFBFBFBF) batteryPercentage: Int,
val batteryFillColor = if (batteryPercentage > 30) Color(0xFF30D158) else Color(0xFFFC3C3C) charging: Boolean = false,
val batteryTextColor = MaterialTheme.colorScheme.onSurface prefix: String = "",
previousCharging: Boolean = false,
) {
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)
val batteryTextColor = if (isDarkTheme) Color.White else Color.Black
val batteryFillColor = if (batteryPercentage > 25)
if (isDarkTheme) Color(0xFF2ED158) else Color(0xFF35C759)
else if (isDarkTheme) Color(0xFFFC4244) else Color(0xFFfe373C)
val batteryWidth = 40.dp val initialScale = if (previousCharging) 1f else 0f
val batteryHeight = 15.dp val scaleAnim = remember { Animatable(initialScale) }
val batteryCornerRadius = 4.dp val targetScale = if (charging) 1f else 0f
val tipWidth = 5.dp
val tipHeight = batteryHeight * 0.375f
val animatedFillWidth by animateFloatAsState(targetValue = batteryPercentage / 100f) LaunchedEffect(previousCharging, charging) {
val animatedScale by animateFloatAsState(targetValue = if (charging) 1.2f else 1f) scaleAnim.animateTo(targetScale, animationSpec = tween(durationMillis = 250))
}
Column( Column(
modifier = Modifier
.padding(12.dp)
.background(backgroundColor), // just for haze to work
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Row( Box(
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(0.dp), contentAlignment = Alignment.Center
modifier = Modifier.padding(bottom = 4.dp)
) { ) {
Box( CircularProgressIndicator(
modifier = Modifier progress = { batteryPercentage / 100f },
.width(batteryWidth) modifier = Modifier.size(40.dp),
.height(batteryHeight) color = batteryFillColor,
) { gapSize = 0.dp,
Box ( strokeCap = StrokeCap.Round,
modifier = Modifier strokeWidth = 2.dp,
.fillMaxSize() trackColor = if (isDarkTheme) Color(0xFF0E0E0F) else Color(0xFFE3E3E8)
.border(1.dp, batteryOutlineColor, RoundedCornerShape(batteryCornerRadius)) )
)
Box( Text(
modifier = Modifier text = "\uDBC0\uDEE6",
.fillMaxHeight() style = TextStyle(
.padding(2.dp) fontSize = 12.sp,
.width(batteryWidth * animatedFillWidth) fontFamily = FontFamily(Font(R.font.sf_pro)),
.background(batteryFillColor, RoundedCornerShape(2.dp)) color = batteryFillColor,
) textAlign = TextAlign.Center
if (charging) { ),
Text( modifier = Modifier.scale(scaleAnim.value)
text = "\uDBC0\uDEE6",
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White,
modifier = Modifier
.scale(animatedScale)
.fillMaxSize(),
textAlign = TextAlign.Center
)
}
}
Box(
modifier = Modifier
.width(tipWidth)
.height(tipHeight)
.padding(start = 1.dp)
.background(
batteryOutlineColor,
RoundedCornerShape(
topStart = 0.dp,
topEnd = 12.dp,
bottomStart = 0.dp,
bottomEnd = 12.dp
)
)
) )
} }
Text( Text(
text = "$batteryPercentage%", text = "$prefix $batteryPercentage%",
color = batteryTextColor, color = batteryTextColor,
style = TextStyle(fontSize = 16.sp, fontWeight = FontWeight.Bold) style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
textAlign = TextAlign.Center
),
) )
} }
} }
@Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable @Composable
fun BatteryIndicatorPreview() { fun BatteryIndicatorPreview() {
BatteryIndicator(batteryPercentage = 48, charging = true) val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
} Box(
modifier = Modifier.background(bg)
) {
BatteryIndicator(batteryPercentage = 24, charging = true, prefix = "\uDBC6\uDCE5", previousCharging = false)
}
}

View File

@@ -24,14 +24,19 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.res.Configuration
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -39,7 +44,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.imageResource import androidx.compose.ui.res.imageResource
@@ -57,6 +62,9 @@ import kotlin.io.encoding.ExperimentalEncodingApi
@Composable @Composable
fun BatteryView(service: AirPodsService, preview: Boolean = false) { fun BatteryView(service: AirPodsService, preview: Boolean = false) {
val batteryStatus = remember { mutableStateOf<List<Battery>>(listOf()) } val batteryStatus = remember { mutableStateOf<List<Battery>>(listOf()) }
val previousBatteryStatus = remember { mutableStateOf<List<Battery>>(listOf()) }
@Suppress("DEPRECATION") val batteryReceiver = remember { @Suppress("DEPRECATION") val batteryReceiver = remember {
object : BroadcastReceiver() { object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
@@ -96,16 +104,37 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) {
} }
} }
previousBatteryStatus.value = batteryStatus.value
batteryStatus.value = service.getBattery() batteryStatus.value = service.getBattery()
if (preview) { if (preview) {
batteryStatus.value = listOf( batteryStatus.value = listOf(
Battery(BatteryComponent.LEFT, 100, BatteryStatus.CHARGING), Battery(BatteryComponent.LEFT, 100, BatteryStatus.NOT_CHARGING),
Battery(BatteryComponent.RIGHT, 50, BatteryStatus.NOT_CHARGING), Battery(BatteryComponent.RIGHT, 94, BatteryStatus.NOT_CHARGING),
Battery(BatteryComponent.CASE, 5, BatteryStatus.CHARGING) Battery(BatteryComponent.CASE, 40, BatteryStatus.CHARGING)
) )
previousBatteryStatus.value = batteryStatus.value
} }
val left = batteryStatus.value.find { it.component == BatteryComponent.LEFT }
val right = batteryStatus.value.find { it.component == BatteryComponent.RIGHT }
val case = batteryStatus.value.find { it.component == BatteryComponent.CASE }
val leftLevel = left?.level ?: 0
val rightLevel = right?.level ?: 0
val caseLevel = case?.level ?: 0
val leftCharging = left?.status == BatteryStatus.CHARGING
val rightCharging = right?.status == BatteryStatus.CHARGING
val caseCharging = case?.status == BatteryStatus.CHARGING
val prevLeft = previousBatteryStatus.value.find { it.component == BatteryComponent.LEFT }
val prevRight = previousBatteryStatus.value.find { it.component == BatteryComponent.RIGHT }
val prevCase = previousBatteryStatus.value.find { it.component == BatteryComponent.CASE }
val prevLeftCharging = prevLeft?.status == BatteryStatus.CHARGING
val prevRightCharging = prevRight?.status == BatteryStatus.CHARGING
val prevCaseCharging = prevCase?.status == BatteryStatus.CHARGING
val singleDisplayed = remember { mutableStateOf(false) }
Row { Row {
Column ( Column (
modifier = Modifier modifier = Modifier
@@ -117,43 +146,48 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) {
contentDescription = stringResource(R.string.buds), contentDescription = stringResource(R.string.buds),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.scale(0.80f) .padding(12.dp)
)
if (
leftCharging == rightCharging &&
(leftLevel - rightLevel) in -3..3
) )
val left = batteryStatus.value.find { it.component == BatteryComponent.LEFT }
val right = batteryStatus.value.find { it.component == BatteryComponent.RIGHT }
if ((right?.status == BatteryStatus.CHARGING && left?.status == BatteryStatus.CHARGING) || (left?.status == BatteryStatus.NOT_CHARGING && right?.status == BatteryStatus.NOT_CHARGING))
{ {
BatteryIndicator(right.level.let { left.level.coerceAtMost(it) }, left.status == BatteryStatus.CHARGING) BatteryIndicator(
leftLevel.coerceAtMost(rightLevel),
leftCharging,
previousCharging = (prevLeftCharging && prevRightCharging)
)
singleDisplayed.value = true
} }
else { else {
singleDisplayed.value = false
Row ( Row (
modifier = Modifier modifier = Modifier
.fillMaxWidth(), .fillMaxWidth(),
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
// if (left?.status != BatteryStatus.DISCONNECTED) { if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) {
if (left?.level != null) {
BatteryIndicator( BatteryIndicator(
left.level, leftLevel,
left.status == BatteryStatus.CHARGING leftCharging,
"\uDBC6\uDCE5",
previousCharging = prevLeftCharging
) )
} }
// } if (leftLevel > 0 && rightLevel > 0)
// if (left?.status != BatteryStatus.DISCONNECTED && right?.status != BatteryStatus.DISCONNECTED) {
if (left?.level != null && right?.level != null)
{ {
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(4.dp))
} }
// } if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED)
// if (right?.status != BatteryStatus.DISCONNECTED) {
if (right?.level != null)
{ {
BatteryIndicator( BatteryIndicator(
right.level, rightLevel,
right.status == BatteryStatus.CHARGING rightCharging,
"\uDBC6\uDCE8",
previousCharging = prevRightCharging
) )
} }
// }
} }
} }
} }
@@ -163,26 +197,32 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) {
.fillMaxWidth(), .fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
val case = batteryStatus.value.find { it.component == BatteryComponent.CASE }
Image( Image(
bitmap = ImageBitmap.imageResource(R.drawable.pro_2_case), bitmap = ImageBitmap.imageResource(R.drawable.pro_2_case),
contentDescription = stringResource(R.string.case_alt), contentDescription = stringResource(R.string.case_alt),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.scale(1.25f) .padding(12.dp)
) )
// if (case?.status != BatteryStatus.DISCONNECTED) { if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) {
if (case?.level != null) { BatteryIndicator(
BatteryIndicator(case.level, case.status == BatteryStatus.CHARGING) caseLevel,
caseCharging,
prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else "",
previousCharging = prevCaseCharging
)
} }
// }
} }
} }
} }
@Preview @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable @Composable
fun BatteryViewPreview() { fun BatteryViewPreview() {
BatteryView(AirPodsService(), preview = true) val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
Box(
modifier = Modifier.background(bg)
) {
BatteryView(AirPodsService(), preview = true)
}
} }

View File

@@ -1,7 +1,36 @@
/*
* 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 Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
import androidx.compose.foundation.background import android.graphics.RuntimeShader
import android.os.Build
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -17,72 +46,207 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.fastCoerceAtMost
import androidx.compose.ui.util.fastCoerceIn
import androidx.compose.ui.util.lerp
import com.kyant.backdrop.Backdrop import com.kyant.backdrop.Backdrop
import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.colorFilter import com.kyant.backdrop.effects.colorControls
import com.kyant.backdrop.effects.refraction import com.kyant.backdrop.effects.refraction
import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.highlight.HighlightStyle
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.inspectDragGestures
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.tanh
@Composable @Composable
fun ConfirmationDialog( fun ConfirmationDialog(
showDialog: MutableState<Boolean>, showDialog: MutableState<Boolean>,
title: String, title: String,
message: String, message: String,
confirmText: String = "Enable", confirmText: String = "Ok",
dismissText: String = "Cancel", dismissText: String = "Cancel",
onConfirm: () -> Unit, onConfirm: () -> Unit,
onDismiss: () -> Unit = { showDialog.value = false }, onDismiss: () -> Unit = { showDialog.value = false },
backdrop: Backdrop, backdrop: Backdrop,
) { ) {
if (showDialog.value) { AnimatedVisibility(
visible = showDialog.value,
enter = fadeIn() + scaleIn(initialScale = 1.25f),
exit = fadeOut() + scaleOut(targetScale = 0.9f)
) {
val animationScope = rememberCoroutineScope()
val progressAnimation = remember { Animatable(0f) }
var pressStartPosition by remember { mutableStateOf(Offset.Zero) }
val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
val interactiveHighlightShader = remember {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
RuntimeShader(
"""
uniform float2 size;
layout(color) uniform half4 color;
uniform float radius;
uniform float2 offset;
half4 main(float2 coord) {
float2 center = offset;
float dist = distance(coord, center);
float intensity = smoothstep(radius, radius * 0.5, dist);
return color * intensity;
}"""
)
} else {
null
}
}
val isLightTheme = !isSystemInDarkTheme() val isLightTheme = !isSystemInDarkTheme()
val contentColor = if (isLightTheme) Color.Black else Color.White val contentColor = if (isLightTheme) Color.Black else Color.White
val accentColor = if (isLightTheme) Color(0xFF0088FF) else Color(0xFF0091FF) val accentColor = if (isLightTheme) Color(0xFF0088FF) else Color(0xFF0091FF)
val containerColor = if (isLightTheme) Color(0xFFFAFAFA).copy(0.6f) else Color(0xFF121212).copy(0.4f) val containerColor = if (isLightTheme) Color(0xFFFFFFFF).copy(0.6f) else Color(0xFF101010).copy(0.6f)
val dimColor = if (isLightTheme) Color(0xFF29293A).copy(0.23f) else Color(0xFF121212).copy(0.56f)
Box( Box(
Modifier Modifier
.background(dimColor)
.fillMaxSize() .fillMaxSize()
.clickable(onClick = onDismiss) .clickable(onClick = onDismiss, indication = null, interactionSource = remember { MutableInteractionSource() } )
) { ) {
Box( Box(
Modifier Modifier
.align(Alignment.Center) .align(Alignment.Center)
.clickable(onClick = {}, indication = null, interactionSource = remember { MutableInteractionSource() } )
.drawBackdrop( .drawBackdrop(
backdrop, backdrop,
{ RoundedCornerShape(48f.dp) }, { RoundedCornerShape(48f.dp) },
// highlight = { Highlight { HighlightStyle.Solid } }, highlight = { Highlight { HighlightStyle.Solid } },
onDrawSurface = { drawRect(containerColor) } onDrawSurface = { drawRect(containerColor) },
) { effects = {
colorFilter( colorControls(
brightness = if (isLightTheme) 0.2f else 0.1f, brightness = if (isLightTheme) 0.4f else 0.2f,
saturation = 1.5f saturation = 1.5f
) )
blur(if (isLightTheme) 16f.dp.toPx() else 8f.dp.toPx()) blur(if (isLightTheme) 16f.dp.toPx() else 8f.dp.toPx())
refraction(24f.dp.toPx(), 48f.dp.toPx(), true) refraction(24f.dp.toPx(), 48f.dp.toPx(), true)
} },
layer = {
val width = size.width
val height = size.height
val progress = progressAnimation.value
val maxScale = 0f
val scale = lerp(1f, 1f + maxScale, progress)
val maxOffset = size.minDimension
val initialDerivative = 0.05f
val offset = offsetAnimation.value
translationX = maxOffset * tanh(initialDerivative * offset.x / maxOffset)
translationY = maxOffset * tanh(initialDerivative * offset.y / maxOffset)
val maxDragScale = 0.1f
val offsetAngle = atan2(offset.y, offset.x)
scaleX =
scale +
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
(width / height).fastCoerceAtMost(1f)
scaleY =
scale +
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
(height / width).fastCoerceAtMost(1f)
},
onDrawFront = {
val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
if (progress > 0f) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) {
drawRect(
Color.White.copy(0.05f * progress),
blendMode = BlendMode.Plus
)
interactiveHighlightShader.apply {
val offset = pressStartPosition + offsetAnimation.value
setFloatUniform("size", size.width, size.height)
setColorUniform("color", Color.White.copy(0.075f * progress).toArgb())
setFloatUniform("radius", size.maxDimension / 2)
setFloatUniform(
"offset",
offset.x.fastCoerceIn(0f, size.width),
offset.y.fastCoerceIn(0f, size.height)
)
}
drawRect(
ShaderBrush(interactiveHighlightShader),
blendMode = BlendMode.Plus
)
} else {
drawRect(
Color.White.copy(0.125f * progress),
blendMode = BlendMode.Plus
)
}
}
},
contentEffects = {
refraction(8f.dp.toPx(), 24f.dp.toPx(), false)
}
)
.fillMaxWidth(0.75f) .fillMaxWidth(0.75f)
.requiredWidthIn(min = 200.dp, max = 360.dp) .requiredWidthIn(min = 200.dp, max = 360.dp)
.pointerInput(animationScope) {
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
val onDragStop: () -> Unit = {
animationScope.launch {
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
}
}
inspectDragGestures(
onDragStart = { down ->
pressStartPosition = down.position
animationScope.launch {
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
launch { offsetAnimation.snapTo(Offset.Zero) }
}
},
onDragEnd = { onDragStop() },
onDragCancel = onDragStop
) { _, dragAmount ->
animationScope.launch {
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
}
}
}
) { ) {
Column(horizontalAlignment = Alignment.Start) { Column(horizontalAlignment = Alignment.Start) {
Spacer(modifier = Modifier.height(28.dp)) Spacer(modifier = Modifier.height(28.dp))
Text( Text(
title, title,
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 18.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = contentColor, color = contentColor,
fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
@@ -103,35 +267,50 @@ fun ConfirmationDialog(
Row( Row(
Modifier Modifier
.padding(24.dp, 12.dp, 24.dp, 24.dp) .padding(horizontal = 12.dp)
.padding(top = 12.dp, bottom = 24.dp)
.fillMaxWidth(), .fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Box( // Box(
Modifier // Modifier
.clip(RoundedCornerShape(50.dp)) // .clip(RoundedCornerShape(50.dp))
.background(containerColor.copy(0.2f)) // .background(containerColor.copy(0.2f))
.clickable(onClick = onDismiss) // .clickable(onClick = onDismiss)
.height(48.dp) // .height(48.dp)
.weight(1f) // .weight(1f)
.padding(horizontal = 16.dp), // .padding(horizontal = 16.dp),
contentAlignment = Alignment.Center // contentAlignment = Alignment.Center
// ) {
StyledButton(
onClick = onDismiss,
backdrop = backdrop,
surfaceColor = if (isLightTheme) Color(0xFFAAAAAA).copy(0.8f) else Color(0xFF202020).copy(0.8f),
modifier = Modifier.weight(1f),
isInteractive = false
) { ) {
Text( Text(
dismissText, dismissText,
style = TextStyle(contentColor, 16.sp) style = TextStyle(contentColor, 16.sp)
) )
} }
Box( // Box(
Modifier // Modifier
.clip(RoundedCornerShape(50.dp)) // .clip(RoundedCornerShape(50.dp))
.background(accentColor) // .background(accentColor)
.clickable(onClick = onConfirm) // .clickable(onClick = onConfirm)
.height(48.dp) // .height(48.dp)
.weight(1f) // .weight(1f)
.padding(horizontal = 16.dp), // .padding(horizontal = 16.dp),
contentAlignment = Alignment.Center // contentAlignment = Alignment.Center
// ) {
StyledButton(
onClick = onConfirm,
backdrop = backdrop,
surfaceColor = accentColor,
modifier = Modifier.weight(1f),
isInteractive = false
) { ) {
Text( Text(
confirmText, confirmText,

View File

@@ -20,34 +20,23 @@
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
import android.util.Log
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@Composable @Composable
fun ConnectionSettings() { fun ConnectionSettings() {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Column( Column(
@@ -72,4 +61,4 @@ fun ConnectionSettings() {
@Composable @Composable
fun ConnectionSettingsPreview() { fun ConnectionSettingsPreview() {
ConnectionSettings() ConnectionSettings()
} }

View File

@@ -1,161 +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 Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun ConversationalAwarenessSwitch() {
val service = ServiceManager.getService()!!
val conversationEnabledValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
var conversationalAwarenessEnabled by remember {
mutableStateOf(
conversationEnabledValue == 1.toByte()
)
}
fun updateConversationalAwareness(enabled: Boolean) {
conversationalAwarenessEnabled = enabled
service.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value,
enabled
)
}
val conversationalAwarenessListener = object: AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value) {
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
conversationalAwarenessEnabled = newValue == 1.toByte()
}
}
}
LaunchedEffect(Unit) {
service.aacpManager.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
conversationalAwarenessListener
)
}
DisposableEffect(Unit) {
onDispose {
service.aacpManager.unregisterControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
conversationalAwarenessListener
)
}
}
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val isPressed = remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.background(
shape = RoundedCornerShape(14.dp),
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
)
.padding(horizontal = 12.dp, vertical = 12.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed.value = true
tryAwaitRelease()
isPressed.value = false
}
)
}
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
updateConversationalAwareness(!conversationalAwarenessEnabled)
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = stringResource(R.string.conversational_awareness),
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.conversational_awareness_description),
fontSize = 12.sp,
color = textColor.copy(0.6f),
lineHeight = 14.sp,
)
}
StyledSwitch(
checked = conversationalAwarenessEnabled,
onCheckedChange = {
updateConversationalAwareness(it)
},
)
}
}
@Preview
@Composable
fun ConversationalAwarenessSwitchPreview() {
ConversationalAwarenessSwitch()
}

View File

@@ -20,6 +20,7 @@
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
import android.content.Context.MODE_PRIVATE
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
@@ -27,16 +28,14 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -49,8 +48,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import android.content.Context.MODE_PRIVATE
import android.content.SharedPreferences
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
@@ -60,8 +57,8 @@ import kotlin.io.encoding.ExperimentalEncodingApi
fun EarDetectionSwitch() { fun EarDetectionSwitch() {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE) val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
val service = ServiceManager.getService()!! val service = ServiceManager.getService()!!
val shared_preference_key = "automatic_ear_detection" val sharedPreferenceKey = "automatic_ear_detection"
val earDetectionEnabledValue = service.aacpManager.controlCommandStatusList.find { val earDetectionEnabledValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG it.identifier == AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG
@@ -72,7 +69,7 @@ fun EarDetectionSwitch() {
if (earDetectionEnabledValue != null) { if (earDetectionEnabledValue != null) {
earDetectionEnabledValue == 1.toByte() earDetectionEnabledValue == 1.toByte()
} else { } else {
sharedPreferences.getBoolean(shared_preference_key, false) sharedPreferences.getBoolean(sharedPreferenceKey, false)
} }
) )
} }
@@ -84,9 +81,9 @@ fun EarDetectionSwitch() {
enabled enabled
) )
service.setEarDetection(enabled) service.setEarDetection(enabled)
sharedPreferences.edit() sharedPreferences.edit()
.putBoolean(shared_preference_key, enabled) .putBoolean(sharedPreferenceKey, enabled)
.apply() .apply()
} }
@@ -96,14 +93,14 @@ fun EarDetectionSwitch() {
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0) val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
val enabled = newValue == 1.toByte() val enabled = newValue == 1.toByte()
earDetectionEnabled = enabled earDetectionEnabled = enabled
sharedPreferences.edit() sharedPreferences.edit()
.putBoolean(shared_preference_key, enabled) .putBoolean(sharedPreferenceKey, enabled)
.apply() .apply()
} }
} }
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
service.aacpManager.registerControlCommandListener( service.aacpManager.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG, AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG,

View File

@@ -1,189 +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 Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.SharedPreferences
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
import androidx.core.content.edit
import android.util.Log
@Composable
fun IndependentToggle(name: String, service: AirPodsService? = null, functionName: String? = null, sharedPreferences: SharedPreferences, default: Boolean = false, controlCommandIdentifier: AACPManager.Companion.ControlCommandIdentifiers? = null, description: String? = null) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val snakeCasedName =
controlCommandIdentifier?.name ?: name.replace(Regex("[\\W\\s]+"), "_").lowercase()
var checked by remember { mutableStateOf(default) }
if (controlCommandIdentifier != null) {
checked = service!!.aacpManager.controlCommandStatusList.find {
it.identifier == controlCommandIdentifier
}?.value?.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
}
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
fun cb() {
if (controlCommandIdentifier == null) {
sharedPreferences.edit { putBoolean(snakeCasedName, checked) }
}
if (functionName != null && service != null) {
val method =
service::class.java.getMethod(functionName, Boolean::class.java)
method.invoke(service, checked)
}
if (controlCommandIdentifier != null) {
service?.aacpManager?.sendControlCommand(identifier = controlCommandIdentifier.value, value = checked)
}
}
LaunchedEffect(sharedPreferences) {
checked = sharedPreferences.getBoolean(snakeCasedName, true)
}
if (controlCommandIdentifier != null) {
val listener = remember {
object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == controlCommandIdentifier.value) {
Log.d("IndependentToggle", "Received control command for $name: ${controlCommand.value}")
checked = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
}
}
}
}
LaunchedEffect(Unit) {
service?.aacpManager?.registerControlCommandListener(controlCommandIdentifier, listener)
}
DisposableEffect(Unit) {
onDispose {
service?.aacpManager?.unregisterControlCommandListener(controlCommandIdentifier, listener)
}
}
}
Column (
modifier = Modifier
.padding(vertical = 8.dp),
) {
Box (
modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(14.dp))
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor = if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
checked = !checked
cb()
}
)
},
)
{
Row(
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = name,
modifier = Modifier.weight(1f),
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = textColor
)
)
StyledSwitch(
checked = checked,
onCheckedChange = {
checked = it
cb()
},
)
}
}
if (description != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier
.padding(horizontal = 8.dp)
)
}
}
}
@Preview
@Composable
fun IndependentTogglePreview() {
IndependentToggle("Test", AirPodsService(), "test", LocalContext.current.getSharedPreferences("preview", 0), true)
}

View File

@@ -51,7 +51,6 @@ import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.ATTManager
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.utils.ATTHandles
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -62,7 +61,7 @@ fun LoudSoundReductionSwitch() {
false false
) )
} }
val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available") val attManager = ServiceManager.getService()?.attManager ?: return
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
attManager.enableNotifications(ATTHandles.LOUD_SOUND_REDUCTION) attManager.enableNotifications(ATTHandles.LOUD_SOUND_REDUCTION)

View File

@@ -20,19 +20,10 @@
package me.kavishdevar.librepods.composables package me.kavishdevar.librepods.composables
import android.annotation.SuppressLint
import android.util.Log import android.util.Log
import androidx.compose.animation.AnimatedVisibility
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.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -42,15 +33,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -63,7 +48,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
@@ -71,20 +55,10 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource 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.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Popup
import dev.chrisbanes.haze.HazeEffectScope
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeTint
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager

View File

@@ -50,14 +50,14 @@ import androidx.navigation.NavController
@Composable @Composable
fun NavigationButton(to: String, name: String, navController: NavController, onClick: (() -> Unit)? = null) { fun NavigationButton(to: String, name: String, navController: NavController, onClick: (() -> Unit)? = null, independent: Boolean = true) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) } var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
Row( Row(
modifier = Modifier modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(14.dp)) .background(animatedBackgroundColor, RoundedCornerShape(if (independent) 14.dp else 0.dp))
.height(55.dp) .height(55.dp)
.pointerInput(Unit) { .pointerInput(Unit) {
detectTapGestures( detectTapGestures(

View File

@@ -189,6 +189,7 @@ fun NoiseControlSettings(
), ),
modifier = Modifier.padding(8.dp, bottom = 2.dp) modifier = Modifier.padding(8.dp, bottom = 2.dp)
) )
@Suppress("COMPOSE_APPLIER_CALL_MISMATCH")
BoxWithConstraints( BoxWithConstraints(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -437,7 +438,7 @@ fun NoiseControlSettings(
} }
} }
@Preview() @Preview
@Composable @Composable
fun NoiseControlSettingsPreview() { fun NoiseControlSettingsPreview() {
NoiseControlSettings(AirPodsService()) NoiseControlSettings(AirPodsService())

View File

@@ -1,4 +1,4 @@
/* /*
* LibrePods - AirPods liberated from Apples ecosystem * LibrePods - AirPods liberated from Apples ecosystem
* *
* Copyright (C) 2025 LibrePods contributors * Copyright (C) 2025 LibrePods contributors
@@ -35,8 +35,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -53,10 +53,10 @@ import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@Composable @Composable
fun PersonalizedVolumeSwitch() { fun PersonalizedVolumeSwitch() {
val service = ServiceManager.getService()!! val service = ServiceManager.getService()!!
val adaptiveVolumeEnabledValue = service.aacpManager.controlCommandStatusList.find { val adaptiveVolumeEnabledValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG
}?.value?.takeIf { it.isNotEmpty() }?.get(0) }?.value?.takeIf { it.isNotEmpty() }?.get(0)
@@ -83,7 +83,7 @@ fun PersonalizedVolumeSwitch() {
} }
} }
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
service.aacpManager.registerControlCommandListener( service.aacpManager.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG, AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,

View File

@@ -0,0 +1,284 @@
/*
* 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 Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.composables
import android.graphics.RuntimeShader
import android.os.Build
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastCoerceAtMost
import androidx.compose.ui.util.fastCoerceIn
import androidx.compose.ui.util.lerp
import com.kyant.backdrop.Backdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.blur
import com.kyant.backdrop.effects.refraction
import com.kyant.backdrop.effects.vibrancy
import com.kyant.backdrop.highlight.Highlight
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.utils.inspectDragGestures
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.tanh
@Composable
fun StyledButton(
onClick: () -> Unit,
backdrop: Backdrop,
modifier: Modifier = Modifier,
isInteractive: Boolean = true,
tint: Color = Color.Unspecified,
surfaceColor: Color = Color.Unspecified,
content: @Composable RowScope.() -> Unit
) {
val animationScope = rememberCoroutineScope()
val progressAnimation = remember { Animatable(0f) }
var pressStartPosition by remember { mutableStateOf(Offset.Zero) }
val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
var isPressed by remember { mutableStateOf(false) }
val interactiveHighlightShader = remember {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
RuntimeShader(
"""
uniform float2 size;
layout(color) uniform half4 color;
uniform float radius;
uniform float2 offset;
half4 main(float2 coord) {
float2 center = offset;
float dist = distance(coord, center);
float intensity = smoothstep(radius, radius * 0.5, dist);
return color * intensity;
}"""
)
} else {
null
}
}
Row(
modifier
.then(
if (!isInteractive) {
Modifier.drawBackdrop(
backdrop = backdrop,
shape = { RoundedCornerShape(28f.dp) },
effects = {
blur(16f.dp.toPx())
},
layer = null,
onDrawSurface = {
if (tint.isSpecified) {
drawRect(tint, blendMode = BlendMode.Hue)
drawRect(tint.copy(alpha = 0.75f))
} else {
drawRect(Color.White.copy(0.1f))
}
if (surfaceColor.isSpecified) {
val color = if (!isInteractive && isPressed) {
Color(
red = surfaceColor.red * 0.5f,
green = surfaceColor.green * 0.5f,
blue = surfaceColor.blue * 0.5f,
alpha = surfaceColor.alpha
)
} else {
surfaceColor
}
drawRect(color)
}
},
onDrawFront = null,
highlight = { Highlight.AmbientDefault.copy(alpha = 0f) }
)
} else {
Modifier.drawBackdrop(
backdrop = backdrop,
shape = { RoundedCornerShape(28f.dp) },
effects = {
vibrancy()
blur(2f.dp.toPx())
refraction(12f.dp.toPx(), 24f.dp.toPx())
},
layer = {
val width = size.width
val height = size.height
val progress = progressAnimation.value
val maxScale = 0.1f
val scale = lerp(1f, 1f + maxScale, progress)
val maxOffset = size.minDimension
val initialDerivative = 0.05f
val offset = offsetAnimation.value
translationX = maxOffset * tanh(initialDerivative * offset.x / maxOffset)
translationY = maxOffset * tanh(initialDerivative * offset.y / maxOffset)
val maxDragScale = 0.1f
val offsetAngle = atan2(offset.y, offset.x)
scaleX =
scale +
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
(width / height).fastCoerceAtMost(1f)
scaleY =
scale +
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
(height / width).fastCoerceAtMost(1f)
},
onDrawSurface = {
if (tint.isSpecified) {
drawRect(tint, blendMode = BlendMode.Hue)
drawRect(tint.copy(alpha = 0.75f))
} else {
drawRect(Color.White.copy(0.1f))
}
if (surfaceColor.isSpecified) {
val color = if (!isInteractive && isPressed) {
Color(
red = surfaceColor.red * 0.5f,
green = surfaceColor.green * 0.5f,
blue = surfaceColor.blue * 0.5f,
alpha = surfaceColor.alpha
)
} else {
surfaceColor
}
drawRect(color)
}
},
onDrawFront = {
val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
if (progress > 0f) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) {
drawRect(
Color.White.copy(0.1f * progress),
blendMode = BlendMode.Plus
)
interactiveHighlightShader.apply {
val offset = pressStartPosition + offsetAnimation.value
setFloatUniform("size", size.width, size.height)
setColorUniform("color", Color.White.copy(0.15f * progress).toArgb())
setFloatUniform("radius", size.maxDimension)
setFloatUniform(
"offset",
offset.x.fastCoerceIn(0f, size.width),
offset.y.fastCoerceIn(0f, size.height)
)
}
drawRect(
ShaderBrush(interactiveHighlightShader),
blendMode = BlendMode.Plus
)
} else {
drawRect(
Color.White.copy(0.25f * progress),
blendMode = BlendMode.Plus
)
}
}
}
)
}
)
.clickable(
interactionSource = null,
indication = null,
role = Role.Button,
onClick = onClick
)
.then(
if (isInteractive) {
Modifier.pointerInput(animationScope) {
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
val onDragStop: () -> Unit = {
animationScope.launch {
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
}
}
inspectDragGestures(
onDragStart = { down ->
pressStartPosition = down.position
animationScope.launch {
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
launch { offsetAnimation.snapTo(Offset.Zero) }
}
},
onDragEnd = { onDragStop() },
onDragCancel = onDragStop
) { _, dragAmount ->
animationScope.launch {
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
}
}
}
} else {
Modifier.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed = true
tryAwaitRelease()
isPressed = false
},
onTap = {
onClick()
}
)
}
}
)
.height(48f.dp)
.padding(horizontal = 16f.dp),
horizontalArrangement = Arrangement.spacedBy(8f.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
content = content
)
}

View File

@@ -0,0 +1,258 @@
/*
* 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 Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.composables
import android.graphics.RuntimeShader
import android.os.Build
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.BlurEffect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.layer.CompositingStrategy
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.fastCoerceAtMost
import androidx.compose.ui.util.fastCoerceIn
import androidx.compose.ui.util.lerp
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.refractionWithDispersion
import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.Shadow
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.inspectDragGestures
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.tanh
@Composable
fun StyledIconButton(
onClick: () -> Unit,
icon: String,
darkMode: Boolean,
tint: Color = Color.Unspecified,
) {
val animationScope = rememberCoroutineScope()
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
val progressAnimation = remember { Animatable(0f) }
val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
var pressStartPosition by remember { mutableStateOf(Offset.Zero) }
val innerShadowLayer = rememberGraphicsLayer().apply {
compositingStrategy = CompositingStrategy.Offscreen
}
val interactiveHighlightShader = remember {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
RuntimeShader(
"""
uniform float2 size;
layout(color) uniform half4 color;
uniform float radius;
uniform float2 offset;
half4 main(float2 coord) {
float2 center = offset;
float dist = distance(coord, center);
float intensity = smoothstep(radius, radius * 0.5, dist);
return color * intensity;
}"""
)
} else {
null
}
}
TextButton(
onClick = onClick,
shape = RoundedCornerShape(56.dp),
modifier = Modifier
.padding(horizontal = 12.dp)
.drawBackdrop(
backdrop = rememberLayerBackdrop(),
shape = { RoundedCornerShape(56.dp) },
highlight = {
val progress = progressAnimation.value
Highlight.AmbientDefault.copy(alpha = progress.coerceIn(0.45f, 1f))
},
shadow = {
Shadow(
radius = 4f.dp,
color = Color.Black.copy(0.08f)
)
},
layer = {
val width = size.width
val height = size.height
val progress = progressAnimation.value
val maxScale = 0.1f
val scale = lerp(1f, 1f + maxScale, progress)
val maxOffset = size.minDimension
val initialDerivative = 0.05f
val offset = offsetAnimation.value
translationX = maxOffset * tanh(initialDerivative * offset.x / maxOffset)
translationY = maxOffset * tanh(initialDerivative * offset.y / maxOffset)
val maxDragScale = 0.1f
val offsetAngle = atan2(offset.y, offset.x)
scaleX =
scale +
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
(width / height).fastCoerceAtMost(1f)
scaleY =
scale +
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
(height / width).fastCoerceAtMost(1f)
},
onDrawSurface = {
val progress = progressAnimation.value.coerceIn(0f, 1f)
val shape = RoundedCornerShape(56.dp)
val outline = shape.createOutline(size, layoutDirection, this)
val innerShadowOffset = 4f.dp.toPx()
val innerShadowBlurRadius = 4f.dp.toPx()
innerShadowLayer.alpha = progress
innerShadowLayer.renderEffect =
BlurEffect(
innerShadowBlurRadius,
innerShadowBlurRadius,
TileMode.Decal
)
innerShadowLayer.record {
drawOutline(outline, Color.Black.copy(0.2f))
translate(0f, innerShadowOffset) {
drawOutline(
outline,
Color.Transparent,
blendMode = BlendMode.Clear
)
}
}
drawLayer(innerShadowLayer)
drawRect(
Color.White.copy(progress.coerceIn(0.15f, 0.35f))
)
},
onDrawFront = {
val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
if (progress > 0f) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) {
drawRect(
Color.White.copy(0.1f * progress),
blendMode = BlendMode.Plus
)
interactiveHighlightShader.apply {
val offset = pressStartPosition + offsetAnimation.value
setFloatUniform("size", size.width, size.height)
setColorUniform("color", Color.White.copy(0.15f * progress).toArgb())
setFloatUniform("radius", size.maxDimension)
setFloatUniform(
"offset",
offset.x.fastCoerceIn(0f, size.width),
offset.y.fastCoerceIn(0f, size.height)
)
}
drawRect(
ShaderBrush(interactiveHighlightShader),
blendMode = BlendMode.Plus
)
} else {
drawRect(
Color.White.copy(0.25f * progress),
blendMode = BlendMode.Plus
)
}
}
},
effects = {
refractionWithDispersion(6f.dp.toPx(), size.height / 2f)
},
)
.pointerInput(animationScope) {
val onDragStop: () -> Unit = {
animationScope.launch {
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
}
}
inspectDragGestures(
onDragStart = { down ->
pressStartPosition = down.position
animationScope.launch {
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
launch { offsetAnimation.snapTo(Offset.Zero) }
}
},
onDragEnd = { onDragStop() },
onDragCancel = onDragStop
) { _, dragAmount ->
animationScope.launch {
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
}
}
}
.size(48.dp),
) {
Text(
text = icon,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = if (tint.isSpecified) tint else if (darkMode) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}

View File

@@ -0,0 +1,166 @@
/*
* 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 Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.composables
import androidx.compose.foundation.isSystemInDarkTheme
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection
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.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeTint
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.rememberHazeState
import me.kavishdevar.librepods.R
@ExperimentalHazeMaterialsApi
@Composable
fun StyledScaffold(
title: String,
navigationButton: @Composable () -> Unit = {},
actionButtons: List<@Composable () -> Unit> = emptyList(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable (spacerValue: Dp, hazeState: HazeState) -> Unit
) {
val isDarkTheme = isSystemInDarkTheme()
val hazeState = rememberHazeState(blurEnabled = true)
Scaffold(
containerColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7),
snackbarHost = { SnackbarHost(snackbarHostState) }
) { paddingValues ->
val topPadding = paddingValues.calculateTopPadding()
val bottomPadding = paddingValues.calculateBottomPadding()
val startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current)
val endPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current)
Box(
modifier = Modifier
.fillMaxSize()
.padding(start = startPadding, end = endPadding, bottom = bottomPadding)
) {
Box(
modifier = Modifier
.zIndex(2f)
.height(64.dp + topPadding)
.fillMaxWidth()
.hazeEffect(state = hazeState) {
tints = listOf(HazeTint(color = if (isDarkTheme) Color.Black else Color.White))
progressive = HazeProgressive.verticalGradient(startIntensity = 1f, endIntensity = 0f)
}
) {
Column(modifier = Modifier.fillMaxSize()) {
Spacer(modifier = Modifier.height(topPadding))
Box(
modifier = Modifier.fillMaxWidth()
) {
navigationButton()
Text(
text = title,
style = TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
color = if (isDarkTheme) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.align(Alignment.Center),
textAlign = TextAlign.Center
)
Row(
modifier = Modifier.align(Alignment.CenterEnd)
) {
actionButtons.forEach { it() }
}
}
}
}
Box(
modifier = Modifier
.padding(horizontal = 16.dp)
) {
content(topPadding + 64.dp, hazeState)
}
}
}
}
@ExperimentalHazeMaterialsApi
@Composable
fun StyledScaffold(
title: String,
navigationButton: @Composable () -> Unit = {},
actionButtons: List<@Composable () -> Unit> = emptyList(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable () -> Unit
) {
StyledScaffold(
title = title,
navigationButton = navigationButton,
actionButtons = actionButtons,
snackbarHostState = snackbarHostState
) { _, _ ->
content()
}
}
@ExperimentalHazeMaterialsApi
@Composable
fun StyledScaffold(
title: String,
navigationButton: @Composable () -> Unit = {},
actionButtons: List<@Composable () -> Unit> = emptyList(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable (spacerValue: Dp) -> Unit
) {
StyledScaffold(
title = title,
navigationButton = navigationButton,
actionButtons = actionButtons,
snackbarHostState = snackbarHostState
) { spacerValue, _ ->
content(spacerValue)
}
}

View File

@@ -75,12 +75,12 @@ import androidx.compose.ui.util.fastCoerceIn
import androidx.compose.ui.util.fastRoundToInt import androidx.compose.ui.util.fastRoundToInt
import androidx.compose.ui.util.lerp import androidx.compose.ui.util.lerp
import com.kyant.backdrop.Backdrop import com.kyant.backdrop.Backdrop
import com.kyant.backdrop.backdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.effects.refractionWithDispersion import com.kyant.backdrop.effects.refractionWithDispersion
import com.kyant.backdrop.highlight.Highlight import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.rememberBackdrop
import com.kyant.backdrop.rememberCombinedBackdropDrawer
import com.kyant.backdrop.shadow.Shadow import com.kyant.backdrop.shadow.Shadow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
@@ -88,18 +88,19 @@ import kotlin.math.roundToInt
@Composable @Composable
fun StyledSlider( fun StyledSlider(
label: String? = null, // New optional parameter for the label label: String? = null,
mutableFloatState: MutableFloatState, mutableFloatState: MutableFloatState,
onValueChange: (Float) -> Unit, onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float>, valueRange: ClosedFloatingPointRange<Float>,
backdrop: Backdrop = rememberBackdrop(), backdrop: Backdrop = rememberLayerBackdrop(),
snapPoints: List<Float> = emptyList(), snapPoints: List<Float> = emptyList(),
snapThreshold: Float = 0.05f, snapThreshold: Float = 0.05f,
startIcon: String? = null, startIcon: String? = null,
endIcon: String? = null, endIcon: String? = null,
startLabel: String? = null, startLabel: String? = null,
endLabel: String? = null, endLabel: String? = null,
independent: Boolean = false independent: Boolean = false,
description: String? = null
) { ) {
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val isLightTheme = !isSystemInDarkTheme() val isLightTheme = !isSystemInDarkTheme()
@@ -126,7 +127,7 @@ fun StyledSlider(
compositingStrategy = CompositingStrategy.Offscreen compositingStrategy = CompositingStrategy.Offscreen
} }
val sliderBackdrop = rememberBackdrop() val sliderBackdrop = rememberLayerBackdrop()
val trackWidthState = remember { mutableFloatStateOf(0f) } val trackWidthState = remember { mutableFloatStateOf(0f) }
val trackPositionState = remember { mutableFloatStateOf(0f) } val trackPositionState = remember { mutableFloatStateOf(0f) }
val startIconWidthState = remember { mutableFloatStateOf(0f) } val startIconWidthState = remember { mutableFloatStateOf(0f) }
@@ -137,8 +138,9 @@ fun StyledSlider(
Box( Box(
Modifier.fillMaxWidth(if (startIcon == null && endIcon == null) 0.95f else 1f) Modifier.fillMaxWidth(if (startIcon == null && endIcon == null) 0.95f else 1f)
) { ) {
Box(Modifier Box(
.backdrop(sliderBackdrop) Modifier
.layerBackdrop(sliderBackdrop)
.fillMaxWidth()) { .fillMaxWidth()) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -188,7 +190,7 @@ fun StyledSlider(
style = TextStyle( style = TextStyle(
fontSize = 18.sp, fontSize = 18.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
color = labelTextColor, color = accentColor,
fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
), ),
modifier = Modifier modifier = Modifier
@@ -237,7 +239,7 @@ fun StyledSlider(
style = TextStyle( style = TextStyle(
fontSize = 18.sp, fontSize = 18.sp,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
color = labelTextColor, color = accentColor,
fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
), ),
modifier = Modifier modifier = Modifier
@@ -291,7 +293,7 @@ fun StyledSlider(
} }
) )
.drawBackdrop( .drawBackdrop(
rememberCombinedBackdropDrawer(backdrop, sliderBackdrop), rememberCombinedBackdrop(backdrop, sliderBackdrop),
{ RoundedCornerShape(28.dp) }, { RoundedCornerShape(28.dp) },
highlight = { highlight = {
val progress = progressAnimation.value val progress = progressAnimation.value
@@ -299,8 +301,8 @@ fun StyledSlider(
}, },
shadow = { shadow = {
Shadow( Shadow(
elevation = 4f.dp, radius = 4f.dp,
color = Color.Black.copy(0.08f) color = Color.Black.copy(0.05f)
) )
}, },
layer = { layer = {
@@ -337,10 +339,11 @@ fun StyledSlider(
drawLayer(innerShadowLayer) drawLayer(innerShadowLayer)
drawRect(Color.White.copy(1f - progress)) drawRect(Color.White.copy(1f - progress))
},
effects = {
refractionWithDispersion(6f.dp.toPx(), size.height / 2f)
} }
) { )
refractionWithDispersion(6f.dp.toPx(), size.height / 2f)
}
.size(40f.dp, 24f.dp) .size(40f.dp, 24f.dp)
) )
} }
@@ -365,7 +368,7 @@ fun StyledSlider(
modifier = Modifier.padding(8.dp) modifier = Modifier.padding(8.dp)
) )
} }
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -376,9 +379,24 @@ fun StyledSlider(
) { ) {
content() content()
} }
if (description != null) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 4.dp)
)
}
} }
} else { } else {
if (label != null) Log.w("StyledSlider", "Label is ignored when independent is false") if (label != null) Log.w("StyledSlider", "Label is ignored when independent is false")
if (description != null) Log.w("StyledSlider", "Description is ignored when independent is false")
content() content()
} }
} }

View File

@@ -0,0 +1,416 @@
/*
* 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 Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.composables
import android.content.SharedPreferences
import android.util.Log
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.edit
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun StyledToggle(
title: String? = null,
label: String,
description: String? = null,
checkedState: MutableState<Boolean> = remember { mutableStateOf(false) } ,
sharedPreferenceKey: String? = null,
sharedPreferences: SharedPreferences? = null,
independent: Boolean = true
) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
var checked by checkedState
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
fun cb() {
if (sharedPreferences != null) {
if (sharedPreferenceKey == null) {
Log.e("StyledToggle", "SharedPreferenceKey is null but SharedPreferences is provided.")
return
}
sharedPreferences.edit { putBoolean(sharedPreferenceKey, checked) }
}
}
if (independent) {
Column(modifier = Modifier.padding(vertical = 8.dp)) {
if (title != null) {
Text(
text = title,
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f)
),
modifier = Modifier.padding(8.dp, bottom = 2.dp)
)
}
Box(
modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(14.dp))
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor =
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor =
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
checked = !checked
cb()
}
)
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
modifier = Modifier.weight(1f),
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = textColor
)
)
StyledSwitch(
checked = checked,
onCheckedChange = {
checked = it
cb()
}
)
}
}
if (description != null) {
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.padding(horizontal = 8.dp)
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
} else {
val isPressed = remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.background(
shape = RoundedCornerShape(14.dp),
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
)
.padding(horizontal = 12.dp, vertical = 12.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed.value = true
tryAwaitRelease()
isPressed.value = false
}
)
}
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
checked = !checked
cb()
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = label,
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
if (description != null) {
Text(
text = description,
fontSize = 12.sp,
color = textColor.copy(0.6f),
lineHeight = 14.sp,
)
}
}
StyledSwitch(
checked = checked,
onCheckedChange = {
checked = it
cb()
}
)
}
}
}
@Composable
fun StyledToggle(
title: String? = null,
label: String,
description: String? = null,
controlCommandIdentifier: AACPManager.Companion.ControlCommandIdentifiers,
independent: Boolean = true
) {
val service = ServiceManager.getService() ?: return
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val checkedValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == controlCommandIdentifier
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
var checked by remember { mutableStateOf(checkedValue == 1.toByte()) }
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
fun cb() {
service.aacpManager.sendControlCommand(identifier = controlCommandIdentifier.value, value = checked)
}
val listener = remember {
object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
if (controlCommand.identifier == controlCommandIdentifier.value) {
Log.d("StyledToggle", "Received control command for $label: ${controlCommand.value}")
checked = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0) == 1.toByte()
}
}
}
}
LaunchedEffect(Unit) {
service.aacpManager.registerControlCommandListener(controlCommandIdentifier, listener)
}
DisposableEffect(Unit) {
onDispose {
service.aacpManager.unregisterControlCommandListener(controlCommandIdentifier, listener)
}
}
if (independent) {
Column(modifier = Modifier.padding(vertical = 8.dp)) {
if (title != null) {
Text(
text = title,
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f)
),
modifier = Modifier.padding(8.dp, bottom = 2.dp)
)
}
Box(
modifier = Modifier
.background(animatedBackgroundColor, RoundedCornerShape(14.dp))
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColor =
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColor =
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
checked = !checked
cb()
}
)
}
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
modifier = Modifier.weight(1f),
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = textColor
)
)
StyledSwitch(
checked = checked,
onCheckedChange = {
checked = it
cb()
}
)
}
}
if (description != null) {
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.padding(horizontal = 8.dp)
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
}
}
} else {
val isPressed = remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.background(
shape = RoundedCornerShape(14.dp),
color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
)
.padding(horizontal = 12.dp, vertical = 12.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed.value = true
tryAwaitRelease()
isPressed.value = false
}
)
}
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
checked = !checked
cb()
},
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
) {
Text(
text = label,
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
if (description != null) {
Text(
text = description,
fontSize = 12.sp,
color = textColor.copy(0.6f),
lineHeight = 14.sp,
)
}
}
StyledSwitch(
checked = checked,
onCheckedChange = {
checked = it
cb()
}
)
}
}
}
@Preview
@Composable
fun StyledTogglePreview() {
val context = LocalContext.current
val sharedPrefs = context.getSharedPreferences("preview", 0)
StyledToggle(
label = "Example Toggle",
description = "This is an example description for the styled toggle.",
sharedPreferences = sharedPrefs
)
}

View File

@@ -182,21 +182,31 @@ class AirPodsNotifications {
if (data.size != 22) { if (data.size != 22) {
return return
} }
first = if (data[10].toInt() == BatteryStatus.DISCONNECTED) { // first = if (data[10].toInt() == BatteryStatus.DISCONNECTED) {
Battery(first.component, first.level, data[10].toInt()) // Battery(first.component, first.level, data[10].toInt())
} else { // } else {
Battery(data[7].toInt(), data[9].toInt(), data[10].toInt()) // Battery(data[7].toInt(), data[9].toInt(), data[10].toInt())
} // }
second = if (data[15].toInt() == BatteryStatus.DISCONNECTED) { // second = if (data[15].toInt() == BatteryStatus.DISCONNECTED) {
Battery(second.component, second.level, data[15].toInt()) // Battery(second.component, second.level, data[15].toInt())
} else { // } else {
Battery(data[12].toInt(), data[14].toInt(), data[15].toInt()) // Battery(data[12].toInt(), data[14].toInt(), data[15].toInt())
} // }
case = if (data[20].toInt() == BatteryStatus.DISCONNECTED && case.status != BatteryStatus.DISCONNECTED) { // case = if (data[20].toInt() == BatteryStatus.DISCONNECTED && case.status != BatteryStatus.DISCONNECTED) {
Battery(case.component, case.level, data[20].toInt()) // Battery(case.component, case.level, data[20].toInt())
} else { // } else {
Battery(data[17].toInt(), data[19].toInt(), data[20].toInt()) // Battery(data[17].toInt(), data[19].toInt(), data[20].toInt())
} // }
// sometimes it shows battery as -1%, just skip all that and set it normally
first = Battery(
data[7].toInt(), data[9].toInt(), data[10].toInt()
)
second = Battery(
data[12].toInt(), data[14].toInt(), data[15].toInt()
)
case = Battery(
data[17].toInt(), data[19].toInt(), data[20].toInt()
)
} }
fun getBattery(): List<Battery> { fun getBattery(): List<Battery> {

View File

@@ -19,12 +19,10 @@
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.bluetooth.BluetoothDevice
import android.util.Log import android.util.Log
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
@@ -44,35 +42,26 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
@@ -86,15 +75,11 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
import dev.chrisbanes.haze.HazeEffectScope
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -105,18 +90,15 @@ import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.LoudSoundReductionSwitch import me.kavishdevar.librepods.composables.LoudSoundReductionSwitch
import me.kavishdevar.librepods.composables.NavigationButton import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.SinglePodANCSwitch import me.kavishdevar.librepods.composables.SinglePodANCSwitch
import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.composables.StyledDropdown import me.kavishdevar.librepods.composables.StyledDropdown
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.composables.StyledSwitch import me.kavishdevar.librepods.composables.StyledSwitch
import me.kavishdevar.librepods.composables.VolumeControlSwitch import me.kavishdevar.librepods.composables.VolumeControlSwitch
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.RadareOffsetFinder import me.kavishdevar.librepods.utils.RadareOffsetFinder
import me.kavishdevar.librepods.utils.TransparencySettings
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse
import me.kavishdevar.librepods.utils.sendTransparencySettings
import java.io.IOException
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
private var phoneMediaDebounceJob: Job? = null private var phoneMediaDebounceJob: Job? = null
@@ -130,9 +112,6 @@ private const val TAG = "AccessibilitySettings"
fun AccessibilitySettingsScreen(navController: NavController) { fun AccessibilitySettingsScreen(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() }
val snackbarHostState = remember { SnackbarHostState() }
val aacpManager = remember { ServiceManager.getService()?.aacpManager } val aacpManager = remember { ServiceManager.getService()?.aacpManager }
val isSdpOffsetAvailable = val isSdpOffsetAvailable =
remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) } remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
@@ -143,7 +122,7 @@ fun AccessibilitySettingsScreen(navController: NavController) {
val hearingAidEnabled = remember { mutableStateOf( val hearingAidEnabled = remember { mutableStateOf(
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(1) == 0x01.toByte() && aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }?.value?.getOrNull(1) == 0x01.toByte() &&
aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }?.value?.getOrNull(0) == 0x01.toByte() aacpManager.controlCommandStatusList.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG }?.value?.getOrNull(0) == 0x01.toByte()
) } ) }
val hearingAidListener = remember { val hearingAidListener = remember {
@@ -171,125 +150,30 @@ fun AccessibilitySettingsScreen(navController: NavController) {
} }
} }
Scaffold( StyledScaffold(
containerColor = if (isSystemInDarkTheme()) Color( title = stringResource(R.string.accessibility),
0xFF000000 navigationButton = {
) else Color( StyledIconButton(
0xFFF2F2F7 onClick = { navController.popBackStack() },
), icon = "􀯶",
topBar = { darkMode = isDarkTheme
val darkMode = isSystemInDarkTheme()
val mDensity = remember { mutableFloatStateOf(1f) }
CenterAlignedTopAppBar(
title = {
Text(
text = stringResource(R.string.accessibility),
style = TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
color = if (darkMode) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
},
modifier = Modifier
.hazeEffect(
state = hazeState,
style = CupertinoMaterials.thick(),
block = fun HazeEffectScope.() {
alpha =
if (verticalScrollState.value > 60.dp.value * mDensity.floatValue) 1f else 0f
})
.drawBehind {
mDensity.floatValue = density
val strokeWidth = 0.7.dp.value * density
val y = size.height - strokeWidth / 2
if (verticalScrollState.value > 60.dp.value * density) {
drawLine(
if (darkMode) Color.DarkGray else Color.LightGray,
Offset(0f, y),
Offset(size.width, y),
strokeWidth
)
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent
)
) )
}, },
snackbarHost = { SnackbarHost(snackbarHostState) } ) { spacerHeight, hazeState ->
) { paddingValues ->
Column( Column(
modifier = Modifier modifier = Modifier
.hazeSource(hazeState)
.fillMaxSize() .fillMaxSize()
.padding(paddingValues) .hazeSource(hazeState)
.padding(horizontal = 16.dp) .verticalScroll(rememberScrollState()),
.verticalScroll(verticalScrollState),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight))
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val enabled = remember { mutableStateOf(false) }
val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) }
val balanceSliderValue = remember { mutableFloatStateOf(0.5f) }
val toneSliderValue = remember { mutableFloatStateOf(0.5f) }
val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) }
val conversationBoostEnabled = remember { mutableStateOf(false) }
val eq = remember { mutableStateOf(FloatArray(8)) }
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) } val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
val phoneEQEnabled = remember { mutableStateOf(false) } val phoneEQEnabled = remember { mutableStateOf(false) }
val mediaEQEnabled = remember { mutableStateOf(false) } val mediaEQEnabled = remember { mutableStateOf(false) }
val initialLoadComplete = remember { mutableStateOf(false) }
val initialReadSucceeded = remember { mutableStateOf(false) }
val initialReadAttempts = remember { mutableIntStateOf(0) }
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
)
)
}
val transparencyListener = remember {
object : (ByteArray) -> Unit {
override fun invoke(value: ByteArray) {
val parsed = parseTransparencySettingsResponse(value)
if (parsed != null) {
enabled.value = parsed.enabled
amplificationSliderValue.floatValue = parsed.netAmplification
balanceSliderValue.floatValue = parsed.balance
toneSliderValue.floatValue = parsed.leftTone
ambientNoiseReductionSliderValue.floatValue =
parsed.leftAmbientNoiseReduction
conversationBoostEnabled.value = parsed.leftConversationBoost
eq.value = parsed.leftEQ.copyOf()
Log.d(TAG, "Updated transparency settings from notification")
} else {
Log.w(TAG, "Failed to parse transparency settings from notification")
}
}
}
}
val pressSpeedOptions = mapOf( val pressSpeedOptions = mapOf(
0.toByte() to "Default", 0.toByte() to "Default",
1.toByte() to "Slower", 1.toByte() to "Slower",
@@ -402,7 +286,6 @@ fun AccessibilitySettingsScreen(navController: NavController) {
} }
} }
// Debounced write for phone/media EQ using AACP manager when values/toggles change
LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) { LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
phoneMediaDebounceJob?.cancel() phoneMediaDebounceJob?.cancel()
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch { phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
@@ -541,7 +424,7 @@ fun AccessibilitySettingsScreen(navController: NavController) {
color = Color(0x40888888), color = Color(0x40888888),
modifier = Modifier.padding(start = 12.dp, end = 0.dp) modifier = Modifier.padding(start = 12.dp, end = 0.dp)
) )
DropdownMenuComponent( DropdownMenuComponent(
label = stringResource(R.string.volume_swipe_speed), label = stringResource(R.string.volume_swipe_speed),
options = listOf( options = listOf(
@@ -563,7 +446,7 @@ fun AccessibilitySettingsScreen(navController: NavController) {
) )
} }
if (!hearingAidEnabled.value) { if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) {
NavigationButton( NavigationButton(
to = "transparency_customization", to = "transparency_customization",
name = stringResource(R.string.customize_transparency_mode), name = stringResource(R.string.customize_transparency_mode),
@@ -881,22 +764,29 @@ fun AccessibilityToggle(
} }
if (description != null) { if (description != null) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Box ( // for some reason, haze and backdrop don't work for uncontained text
text = description,
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier modifier = Modifier
.padding(horizontal = 8.dp) .fillMaxWidth()
) .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7), cornerShape)
) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
// modifier = Modifier
// .padding(horizontal = 8.dp)
)
}
} }
} }
} }
@ExperimentalHazeMaterialsApi
@Composable @Composable
private fun DropdownMenuComponent( private fun DropdownMenuComponent(
label: String, label: String,
@@ -904,7 +794,8 @@ private fun DropdownMenuComponent(
selectedOption: String, selectedOption: String,
onOptionSelected: (String) -> Unit, onOptionSelected: (String) -> Unit,
textColor: Color, textColor: Color,
hazeState: HazeState hazeState: HazeState,
description: String? = null,
) { ) {
val density = LocalDensity.current val density = LocalDensity.current
val itemHeightPx = with(density) { 48.dp.toPx() } val itemHeightPx = with(density) { 48.dp.toPx() }
@@ -1020,5 +911,21 @@ private fun DropdownMenuComponent(
hazeState = hazeState hazeState = hazeState
) )
} }
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
.background(if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7))
){
Text(
text = description ?: "",
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
} }
} }

View File

@@ -16,40 +16,51 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
@file:OptIn(ExperimentalEncodingApi::class) package me.kavishdevar.librepods.screens
package me.kavishdevar.librepods.composables
import android.annotation.SuppressLint
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.navigation.NavController
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.roundToInt
private var debounceJob: Job? = null
@SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun AdaptiveStrengthSlider() { fun AdaptiveStrengthScreen(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val sliderValue = remember { mutableFloatStateOf(0f) } val sliderValue = remember { mutableFloatStateOf(0f) }
val service = ServiceManager.getService()!! val service = ServiceManager.getService()!!
LaunchedEffect(sliderValue) { LaunchedEffect(sliderValue) {
val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find { val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH it.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH
@@ -82,38 +93,44 @@ fun AdaptiveStrengthSlider() {
} }
} }
val isDarkTheme = isSystemInDarkTheme()
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFFD9D9D9) StyledScaffold(
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF) title = stringResource(R.string.customize_adaptive_audio),
val labelTextColor = if (isDarkTheme) Color.White else Color.Black navigationButton = {
StyledIconButton(
Column( onClick = { navController.popBackStack() },
modifier = Modifier icon = "􀯶",
.fillMaxWidth(), darkMode = isDarkTheme
horizontalAlignment = Alignment.CenterHorizontally )
) { }
StyledSlider( ) { spacerHeight ->
mutableFloatState = sliderValue, Column(
onValueChange = { modifier = Modifier
sliderValue.floatValue = snapIfClose(it, listOf(0f, 50f, 100f)) .fillMaxSize(),
}, verticalArrangement = Arrangement.spacedBy(16.dp)
valueRange = 0f..100f, ) {
snapPoints = listOf(0f, 50f, 100f), Spacer(modifier = Modifier.height(spacerHeight))
startLabel = stringResource(R.string.less_noise), StyledSlider(
endLabel = stringResource(R.string.more_noise), label = stringResource(R.string.customize_adaptive_audio).uppercase(),
independent = false mutableFloatState = sliderValue,
) onValueChange = {
sliderValue.floatValue = it
debounceJob?.cancel()
debounceJob = CoroutineScope(Dispatchers.Default).launch {
delay(300)
service.aacpManager.sendControlCommand(
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH.value,
(100 - it).toInt()
)
}
},
valueRange = 0f..100f,
snapPoints = listOf(0f, 50f, 100f),
startIcon = "􀊥",
endIcon = "􀊩",
independent = true,
description = stringResource(R.string.adaptive_audio_description)
)
}
} }
} }
@Preview
@Composable
fun AdaptiveStrengthSliderPreview() {
AdaptiveStrengthSlider()
}
private fun snapIfClose(value: Float, points: List<Float>, threshold: Float = 0.05f): Float {
val nearest = points.minByOrNull { kotlin.math.abs(it - value) } ?: value
return if (kotlin.math.abs(nearest - value) <= threshold) nearest else value
}

View File

@@ -41,34 +41,19 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -83,24 +68,26 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import dev.chrisbanes.haze.HazeEffectScope import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeEffect import com.kyant.backdrop.drawBackdrop
import com.kyant.backdrop.highlight.Highlight
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.AudioSettings import me.kavishdevar.librepods.composables.AudioSettings
import me.kavishdevar.librepods.composables.BatteryView import me.kavishdevar.librepods.composables.BatteryView
import me.kavishdevar.librepods.composables.CallControlSettings import me.kavishdevar.librepods.composables.CallControlSettings
import me.kavishdevar.librepods.composables.ConnectionSettings import me.kavishdevar.librepods.composables.ConnectionSettings
import me.kavishdevar.librepods.composables.IndependentToggle
import me.kavishdevar.librepods.composables.MicrophoneSettings import me.kavishdevar.librepods.composables.MicrophoneSettings
import me.kavishdevar.librepods.composables.NameField import me.kavishdevar.librepods.composables.NameField
import me.kavishdevar.librepods.composables.NavigationButton import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.NoiseControlSettings import me.kavishdevar.librepods.composables.NoiseControlSettings
import me.kavishdevar.librepods.composables.PressAndHoldSettings import me.kavishdevar.librepods.composables.PressAndHoldSettings
import me.kavishdevar.librepods.composables.StyledButton
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.constants.AirPodsNotifications import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
@@ -144,8 +131,6 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
} }
} }
val verticalScrollState = rememberScrollState()
val hazeState = rememberHazeState( blurEnabled = true )
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
@@ -153,12 +138,6 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
isRemotelyConnected = connected isRemotelyConnected = connected
} }
fun showSnackbar(message: String) {
coroutineScope.launch {
snackbarHostState.showSnackbar(message)
}
}
val context = LocalContext.current val context = LocalContext.current
val connectionReceiver = remember { val connectionReceiver = remember {
@@ -219,118 +198,38 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
} }
} }
} }
val darkMode = isSystemInDarkTheme()
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") StyledScaffold(
Scaffold( title = deviceName.text,
containerColor = if (isSystemInDarkTheme()) Color( actionButtons = listOf {
0xFF000000 StyledIconButton(
) else Color( onClick = { navController.navigate("app_settings") },
0xFFF2F2F7 icon = "􀍟",
), darkMode = darkMode
topBar = {
val darkMode = isSystemInDarkTheme()
val mDensity = remember { mutableFloatStateOf(1f) }
CenterAlignedTopAppBar(
title = {
Text(
text = deviceName.text,
style = TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
color = if (darkMode) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
},
modifier = Modifier
.hazeEffect(
state = hazeState,
style = CupertinoMaterials.thick(),
block = fun HazeEffectScope.() {
alpha =
if (verticalScrollState.value > 60.dp.value * mDensity.floatValue) 1f else 0f
}
)
.drawBehind {
mDensity.floatValue = density
val strokeWidth = 0.7.dp.value * density
val y = size.height - strokeWidth / 2
if (verticalScrollState.value > 60.dp.value * density) {
drawLine(
if (darkMode) Color.DarkGray else Color.LightGray,
Offset(0f, y),
Offset(size.width, y),
strokeWidth
)
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent
),
actions = {
if (isRemotelyConnected) {
IconButton(
onClick = {
showSnackbar("Connected remotely to AirPods via Linux.")
},
colors = IconButtonDefaults.iconButtonColors(
containerColor = Color.Transparent,
contentColor = if (isSystemInDarkTheme()) Color.White else Color.Black
)
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = "Info",
)
}
}
IconButton(
onClick = {
navController.navigate("app_settings")
},
colors = IconButtonDefaults.iconButtonColors(
containerColor = Color.Transparent,
contentColor = if (isSystemInDarkTheme()) Color.White else Color.Black
)
) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = "Settings",
)
}
}
) )
}, },
snackbarHost = { SnackbarHost(snackbarHostState) } snackbarHostState = snackbarHostState
) { paddingValues -> ) { spacerHeight, hazeState ->
if (isLocallyConnected || isRemotelyConnected) { if (isLocallyConnected || isRemotelyConnected) {
Column( Column(
modifier = Modifier modifier = Modifier
.hazeSource(hazeState)
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 16.dp) .hazeSource(hazeState)
.verticalScroll( .verticalScroll(rememberScrollState())
state = verticalScrollState,
enabled = true,
)
) { ) {
Spacer(Modifier.height(75.dp)) Spacer(modifier = Modifier.height(spacerHeight))
LaunchedEffect(service) { LaunchedEffect(service) {
service.let { service.let {
it.sendBroadcast(Intent(AirPodsNotifications.Companion.BATTERY_DATA).apply { it.sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
putParcelableArrayListExtra("data", ArrayList(it.getBattery())) putParcelableArrayListExtra("data", ArrayList(it.getBattery()))
}) })
it.sendBroadcast(Intent(AirPodsNotifications.Companion.ANC_DATA).apply { it.sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply {
putExtra("data", it.getANC()) putExtra("data", it.getANC())
}) })
} }
} }
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
Spacer(modifier = Modifier.height(64.dp))
BatteryView(service = service) BatteryView(service = service)
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
// Show BLE-only mode indicator // Show BLE-only mode indicator
@@ -372,7 +271,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
PressAndHoldSettings(navController = navController) PressAndHoldSettings(navController = navController)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
AudioSettings() AudioSettings(navController = navController)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
ConnectionSettings() ConnectionSettings()
@@ -381,11 +280,8 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
MicrophoneSettings(hazeState) MicrophoneSettings(hazeState)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
IndependentToggle( StyledToggle(
name = stringResource(R.string.sleep_detection), label = stringResource(R.string.sleep_detection),
service = service,
sharedPreferences = sharedPreferences,
default = false,
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG
) )
@@ -396,11 +292,8 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
NavigationButton(to = "accessibility", "Accessibility", navController = navController) NavigationButton(to = "accessibility", "Accessibility", navController = navController)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
IndependentToggle( StyledToggle(
name = stringResource(R.string.off_listening_mode), label = stringResource(R.string.off_listening_mode).uppercase(),
service = service,
sharedPreferences = sharedPreferences,
default = false,
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION, controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
description = stringResource(R.string.off_listening_mode_description) description = stringResource(R.string.off_listening_mode_description)
) )
@@ -415,19 +308,24 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
} }
} }
else { else {
val backdrop = rememberLayerBackdrop()
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(horizontal = 8.dp) .drawBackdrop(
.verticalScroll( backdrop = rememberLayerBackdrop(),
state = verticalScrollState, exportedBackdrop = backdrop,
enabled = true, shape = { RoundedCornerShape(0.dp) },
), highlight = {
Highlight.AmbientDefault.copy(alpha = 0f)
}
)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Text( Text(
text = "AirPods not connected", text = stringResource(R.string.airpods_not_connected),
style = TextStyle( style = TextStyle(
fontSize = 24.sp, fontSize = 24.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
@@ -439,7 +337,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
) )
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
Text( Text(
text = "Please connect your AirPods to access settings.", text = stringResource(R.string.airpods_not_connected_description),
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
fontWeight = FontWeight.Light, fontWeight = FontWeight.Light,
@@ -450,13 +348,9 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
Button( StyledButton(
onClick = { navController.navigate("troubleshooting") }, onClick = { navController.navigate("troubleshooting") },
shape = RoundedCornerShape(10.dp), backdrop = backdrop
colors = ButtonDefaults.buttonColors(
containerColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFF2F2F7),
contentColor = if (isSystemInDarkTheme()) Color.White else Color.Black,
)
) { ) {
Text( Text(
text = "Troubleshoot Connection", text = "Troubleshoot Connection",
@@ -472,7 +366,6 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
} }
} }
@Preview @Preview
@Composable @Composable
fun AirPodsSettingsScreenPreview() { fun AirPodsSettingsScreenPreview() {

View File

@@ -42,38 +42,31 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@@ -82,39 +75,32 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.edit import androidx.core.content.edit
import androidx.navigation.NavController import androidx.navigation.NavController
import dev.chrisbanes.haze.HazeEffectScope
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSwitch import me.kavishdevar.librepods.composables.StyledSwitch
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.RadareOffsetFinder import me.kavishdevar.librepods.utils.RadareOffsetFinder
import kotlin.io.encoding.Base64 import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.roundToInt import kotlin.math.roundToInt
import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun AppSettingsScreen(navController: NavController) { fun AppSettingsScreen(navController: NavController) {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE) val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
val name = remember { mutableStateOf(sharedPreferences.getString("name", "") ?: "") }
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val context = LocalContext.current val context = LocalContext.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val hazeState = remember { HazeState() }
var showResetDialog by remember { mutableStateOf(false) } var showResetDialog by remember { mutableStateOf(false) }
var showIrkDialog by remember { mutableStateOf(false) } var showIrkDialog by remember { mutableStateOf(false) }
@@ -134,6 +120,7 @@ fun AppSettingsScreen(navController: NavController) {
irkValue = decoded.joinToString("") { "%02x".format(it) } irkValue = decoded.joinToString("") { "%02x".format(it) }
} catch (e: Exception) { } catch (e: Exception) {
irkValue = "" irkValue = ""
e.printStackTrace()
} }
} }
@@ -143,6 +130,7 @@ fun AppSettingsScreen(navController: NavController) {
encKeyValue = decoded.joinToString("") { "%02x".format(it) } encKeyValue = decoded.joinToString("") { "%02x".format(it) }
} catch (e: Exception) { } catch (e: Exception) {
encKeyValue = "" encKeyValue = ""
e.printStackTrace()
} }
} }
} }
@@ -198,8 +186,6 @@ fun AppSettingsScreen(navController: NavController) {
} }
} }
var mDensity by remember { mutableFloatStateOf(0f) }
fun validateHexInput(input: String): Boolean { fun validateHexInput(input: String): Boolean {
val hexPattern = Regex("^[0-9a-fA-F]{32}$") val hexPattern = Regex("^[0-9a-fA-F]{32}$")
return hexPattern.matches(input) return hexPattern.matches(input)
@@ -210,84 +196,24 @@ fun AppSettingsScreen(navController: NavController) {
BackHandler(enabled = isProcessingSdp) {} BackHandler(enabled = isProcessingSdp) {}
Scaffold( StyledScaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), title = stringResource(R.string.app_settings),
topBar = { navigationButton = {
CenterAlignedTopAppBar( StyledIconButton(
modifier = Modifier.hazeEffect( onClick = { navController.popBackStack() },
state = hazeState, icon = "􀯶",
style = CupertinoMaterials.thick(), darkMode = isDarkTheme
block = fun HazeEffectScope.() {
alpha =
if (scrollState.value > 60.dp.value * mDensity) 1f else 0f
})
.drawBehind {
mDensity = density
val strokeWidth = 0.7.dp.value * density
val y = size.height - strokeWidth / 2
if (scrollState.value > 60.dp.value * density) {
drawLine(
if (isDarkTheme) Color.DarkGray else Color.LightGray,
Offset(0f, y),
Offset(size.width, y),
strokeWidth
)
}
},
title = {
Text(
text = stringResource(R.string.app_settings),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
},
navigationIcon = {
TextButton(
onClick = {
if (!isProcessingSdp) {
navController.popBackStack()
}
},
enabled = !isProcessingSdp,
shape = RoundedCornerShape(8.dp),
modifier = Modifier.width(180.dp)
) {
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
contentDescription = "Back",
tint = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
modifier = Modifier.scale(1.5f)
)
Text(
text = name.value,
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent
),
scrollBehavior = scrollBehavior
) )
}, }
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) ) { spacerHeight, hazeState ->
else Color(0xFFF2F2F7), Column(
) { paddingValues ->
Column (
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp)
.verticalScroll(scrollState) .verticalScroll(scrollState)
.hazeSource(state = hazeState) .hazeSource(state = hazeState)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight))
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
@@ -295,7 +221,7 @@ fun AppSettingsScreen(navController: NavController) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "Widget".uppercase(), text = stringResource(R.string.widget).uppercase(),
style = TextStyle( style = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Light, fontWeight = FontWeight.Light,
@@ -335,13 +261,13 @@ fun AppSettingsScreen(navController: NavController) {
.padding(end = 4.dp) .padding(end = 4.dp)
) { ) {
Text( Text(
text = "Show phone battery in widget", text = stringResource(R.string.show_phone_battery_in_widget),
fontSize = 16.sp, fontSize = 16.sp,
color = textColor color = textColor
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "Display your phone's battery level in the widget alongside AirPods battery", text = stringResource(R.string.show_phone_battery_in_widget_description),
fontSize = 14.sp, fontSize = 14.sp,
color = textColor.copy(0.6f), color = textColor.copy(0.6f),
lineHeight = 16.sp, lineHeight = 16.sp,
@@ -359,7 +285,7 @@ fun AppSettingsScreen(navController: NavController) {
} }
Text( Text(
text = "Connection Mode".uppercase(), text = stringResource(R.string.connection_mode).uppercase(),
style = TextStyle( style = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Light, fontWeight = FontWeight.Light,
@@ -399,12 +325,12 @@ fun AppSettingsScreen(navController: NavController) {
.padding(end = 4.dp) .padding(end = 4.dp)
) { ) {
Text( Text(
text = "BLE Only Mode", text = stringResource(R.string.ble_only_mode),
fontSize = 16.sp, fontSize = 16.sp,
color = textColor color = textColor
) )
Text( Text(
text = "Only use Bluetooth Low Energy for battery data and ear detection. Disables advanced features requiring L2CAP connection.", text = stringResource(R.string.ble_only_mode_description),
fontSize = 13.sp, fontSize = 13.sp,
color = textColor.copy(0.6f), color = textColor.copy(0.6f),
lineHeight = 16.sp, lineHeight = 16.sp,
@@ -422,7 +348,7 @@ fun AppSettingsScreen(navController: NavController) {
} }
Text( Text(
text = "Conversational Awareness".uppercase(), text = stringResource(R.string.conversational_awareness).uppercase(),
style = TextStyle( style = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Light, fontWeight = FontWeight.Light,
@@ -539,7 +465,7 @@ fun AppSettingsScreen(navController: NavController) {
} }
Text( Text(
text = "Conversational Awareness Volume", text = stringResource(R.string.conversational_awareness_volume),
fontSize = 16.sp, fontSize = 16.sp,
color = textColor, color = textColor,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp) modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)
@@ -628,7 +554,7 @@ fun AppSettingsScreen(navController: NavController) {
} }
Text( Text(
text = "Quick Settings Tile".uppercase(), text = stringResource(R.string.quick_settings_tile).uppercase(),
style = TextStyle( style = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Light, fontWeight = FontWeight.Light,
@@ -672,15 +598,13 @@ fun AppSettingsScreen(navController: NavController) {
.padding(end = 4.dp) .padding(end = 4.dp)
) { ) {
Text( Text(
text = "Open dialog for controlling", text = stringResource(R.string.open_dialog_for_controlling),
fontSize = 16.sp, fontSize = 16.sp,
color = textColor color = textColor
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = if (openDialogForControlling) text = stringResource(R.string.open_dialog_for_controlling_description),
"If disabled, clicking on the QS will cycle through modes"
else "If enabled, it will show a dialog for controlling noise control mode and conversational awareness",
fontSize = 14.sp, fontSize = 14.sp,
color = textColor.copy(0.6f), color = textColor.copy(0.6f),
lineHeight = 16.sp, lineHeight = 16.sp,
@@ -697,7 +621,7 @@ fun AppSettingsScreen(navController: NavController) {
} }
Text( Text(
text = "Ear Detection".uppercase(), text = stringResource(R.string.ear_detection).uppercase(),
style = TextStyle( style = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Light, fontWeight = FontWeight.Light,
@@ -741,13 +665,13 @@ fun AppSettingsScreen(navController: NavController) {
.padding(end = 4.dp) .padding(end = 4.dp)
) { ) {
Text( Text(
text = "Disconnect AirPods when not wearing", text = stringResource(R.string.disconnect_when_not_wearing),
fontSize = 16.sp, fontSize = 16.sp,
color = textColor color = textColor
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "You will still be able to control them with the app - this just disconnects the audio.", text = stringResource(R.string.disconnect_when_not_wearing_description),
fontSize = 14.sp, fontSize = 14.sp,
color = textColor.copy(0.6f), color = textColor.copy(0.6f),
lineHeight = 16.sp, lineHeight = 16.sp,
@@ -1051,7 +975,7 @@ fun AppSettingsScreen(navController: NavController) {
} }
Text( Text(
text = "Advanced Options".uppercase(), text = stringResource(R.string.advanced_options).uppercase(),
style = TextStyle( style = TextStyle(
fontSize = 14.sp, fontSize = 14.sp,
fontWeight = FontWeight.Light, fontWeight = FontWeight.Light,
@@ -1087,13 +1011,13 @@ fun AppSettingsScreen(navController: NavController) {
.padding(end = 4.dp) .padding(end = 4.dp)
) { ) {
Text( Text(
text = "Set Identity Resolving Key (IRK)", text = stringResource(R.string.set_identity_resolving_key),
fontSize = 16.sp, fontSize = 16.sp,
color = textColor color = textColor
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "Manually set the IRK value used for resolving BLE random addresses", text = stringResource(R.string.set_identity_resolving_key_description),
fontSize = 14.sp, fontSize = 14.sp,
color = textColor.copy(0.6f), color = textColor.copy(0.6f),
lineHeight = 16.sp, lineHeight = 16.sp,
@@ -1116,13 +1040,13 @@ fun AppSettingsScreen(navController: NavController) {
.padding(end = 4.dp) .padding(end = 4.dp)
) { ) {
Text( Text(
text = "Set Encryption Key", text = stringResource(R.string.set_encryption_key),
fontSize = 16.sp, fontSize = 16.sp,
color = textColor color = textColor
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "Manually set the ENC_KEY value used for decrypting BLE advertisements", text = stringResource(R.string.set_encryption_key_description),
fontSize = 14.sp, fontSize = 14.sp,
color = textColor.copy(0.6f), color = textColor.copy(0.6f),
lineHeight = 16.sp, lineHeight = 16.sp,
@@ -1152,13 +1076,13 @@ fun AppSettingsScreen(navController: NavController) {
.padding(end = 4.dp) .padding(end = 4.dp)
) { ) {
Text( Text(
text = "Use alternate head tracking packets", text = stringResource(R.string.use_alternate_head_tracking_packets),
fontSize = 16.sp, fontSize = 16.sp,
color = textColor color = textColor
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "Enable this if head tracking doesn't work for you. This sends different data to AirPods for requesting/stopping head tracking data.", text = stringResource(R.string.use_alternate_head_tracking_packets_description),
fontSize = 14.sp, fontSize = 14.sp,
color = textColor.copy(0.6f), color = textColor.copy(0.6f),
lineHeight = 16.sp, lineHeight = 16.sp,
@@ -1206,6 +1130,7 @@ fun AppSettingsScreen(navController: NavController) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
actAsAppleDevice = RadareOffsetFinder.isSdpOffsetAvailable() actAsAppleDevice = RadareOffsetFinder.isSdpOffsetAvailable()
} }
val restartBluetoothText = stringResource(R.string.found_offset_restart_bluetooth)
Row( Row(
modifier = Modifier modifier = Modifier
@@ -1222,9 +1147,9 @@ fun AppSettingsScreen(navController: NavController) {
coroutineScope.launch { coroutineScope.launch {
if (newValue) { if (newValue) {
val radareOffsetFinder = RadareOffsetFinder(context) val radareOffsetFinder = RadareOffsetFinder(context)
val success = radareOffsetFinder.findSdpOffset() ?: false val success = radareOffsetFinder.findSdpOffset()
if (success) { if (success) {
Toast.makeText(context, "Found offset please restart the Bluetooth process", Toast.LENGTH_LONG).show() Toast.makeText(context, restartBluetoothText, Toast.LENGTH_LONG).show()
} }
} else { } else {
RadareOffsetFinder.clearSdpOffset() RadareOffsetFinder.clearSdpOffset()
@@ -1242,13 +1167,13 @@ fun AppSettingsScreen(navController: NavController) {
.padding(end = 4.dp) .padding(end = 4.dp)
) { ) {
Text( Text(
text = "Act as an Apple device", text = stringResource(R.string.act_as_an_apple_device),
fontSize = 16.sp, fontSize = 16.sp,
color = textColor color = textColor
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "Enables multi-device connectivity and Accessibility features like customizing transparency mode (amplification, tone, ambient noise reduction, conversation boost, and EQ)", text = stringResource(R.string.act_as_an_apple_device_description),
fontSize = 14.sp, fontSize = 14.sp,
color = textColor.copy(0.6f), color = textColor.copy(0.6f),
lineHeight = 16.sp, lineHeight = 16.sp,
@@ -1256,14 +1181,13 @@ fun AppSettingsScreen(navController: NavController) {
if (actAsAppleDevice) { if (actAsAppleDevice) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "Might be unstable!! A maximum of two devices can be connected to your AirPods. If you are using with an Apple device like an iPad or Mac, then please connect that device first and then your Android.", text = stringResource(R.string.act_as_an_apple_device_warning),
fontSize = 12.sp, fontSize = 12.sp,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
lineHeight = 14.sp, lineHeight = 14.sp,
) )
} }
} }
StyledSwitch( StyledSwitch(
checked = actAsAppleDevice, checked = actAsAppleDevice,
onCheckedChange = { onCheckedChange = {
@@ -1273,9 +1197,9 @@ fun AppSettingsScreen(navController: NavController) {
coroutineScope.launch { coroutineScope.launch {
if (it) { if (it) {
val radareOffsetFinder = RadareOffsetFinder(context) val radareOffsetFinder = RadareOffsetFinder(context)
val success = radareOffsetFinder.findSdpOffset() ?: false val success = radareOffsetFinder.findSdpOffset()
if (success) { if (success) {
Toast.makeText(context, "Found offset please restart the Bluetooth process", Toast.LENGTH_LONG).show() Toast.makeText(context, restartBluetoothText, Toast.LENGTH_LONG).show()
} }
} else { } else {
RadareOffsetFinder.clearSdpOffset() RadareOffsetFinder.clearSdpOffset()
@@ -1313,7 +1237,7 @@ fun AppSettingsScreen(navController: NavController) {
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = "Reset Hook Offset", text = stringResource(R.string.reset_hook_offset),
color = MaterialTheme.colorScheme.onErrorContainer, color = MaterialTheme.colorScheme.onErrorContainer,
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 16.sp,
@@ -1338,17 +1262,19 @@ fun AppSettingsScreen(navController: NavController) {
}, },
text = { text = {
Text( Text(
"This will clear the current hook offset and require you to go through the setup process again. Are you sure you want to continue?", stringResource(R.string.reset_hook_offset_description),
fontFamily = FontFamily(Font(R.font.sf_pro)) fontFamily = FontFamily(Font(R.font.sf_pro))
) )
}, },
confirmButton = { confirmButton = {
val successText = stringResource(R.string.hook_offset_reset_success)
val failureText = stringResource(R.string.hook_offset_reset_failure)
TextButton( TextButton(
onClick = { onClick = {
if (RadareOffsetFinder.clearHookOffsets()) { if (RadareOffsetFinder.clearHookOffsets()) {
Toast.makeText( Toast.makeText(
context, context,
"Hook offset has been reset. Redirecting to setup...", successText,
Toast.LENGTH_LONG Toast.LENGTH_LONG
).show() ).show()
@@ -1358,7 +1284,7 @@ fun AppSettingsScreen(navController: NavController) {
} else { } else {
Toast.makeText( Toast.makeText(
context, context,
"Failed to reset hook offset", failureText,
Toast.LENGTH_SHORT Toast.LENGTH_SHORT
).show() ).show()
} }
@@ -1369,7 +1295,7 @@ fun AppSettingsScreen(navController: NavController) {
) )
) { ) {
Text( Text(
"Reset", stringResource(R.string.reset),
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
@@ -1394,7 +1320,7 @@ fun AppSettingsScreen(navController: NavController) {
onDismissRequest = { showIrkDialog = false }, onDismissRequest = { showIrkDialog = false },
title = { title = {
Text( Text(
"Set Identity Resolving Key (IRK)", stringResource(R.string.set_identity_resolving_key),
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
@@ -1402,7 +1328,7 @@ fun AppSettingsScreen(navController: NavController) {
text = { text = {
Column { Column {
Text( Text(
"Enter 16-byte IRK as hex string (32 characters):", stringResource(R.string.enter_irk_hex),
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier.padding(bottom = 8.dp) modifier = Modifier.padding(bottom = 8.dp)
) )
@@ -1425,14 +1351,16 @@ fun AppSettingsScreen(navController: NavController) {
), ),
supportingText = { supportingText = {
if (irkError != null) { if (irkError != null) {
Text(irkError!!, color = MaterialTheme.colorScheme.error) Text(stringResource(R.string.must_be_32_hex_chars), color = MaterialTheme.colorScheme.error)
} }
}, },
label = { Text("IRK Hex Value") } label = { Text(stringResource(R.string.irk_hex_value)) }
) )
} }
}, },
confirmButton = { confirmButton = {
val successText = stringResource(R.string.irk_set_success)
val errorText = stringResource(R.string.error_converting_hex)
TextButton( TextButton(
onClick = { onClick = {
if (!validateHexInput(irkValue)) { if (!validateHexInput(irkValue)) {
@@ -1450,10 +1378,10 @@ fun AppSettingsScreen(navController: NavController) {
val base64Value = Base64.encode(hexBytes) val base64Value = Base64.encode(hexBytes)
sharedPreferences.edit { putString(AACPManager.Companion.ProximityKeyType.IRK.name, base64Value)} sharedPreferences.edit { putString(AACPManager.Companion.ProximityKeyType.IRK.name, base64Value)}
Toast.makeText(context, "IRK has been set successfully", Toast.LENGTH_SHORT).show() Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
showIrkDialog = false showIrkDialog = false
} catch (e: Exception) { } catch (e: Exception) {
irkError = "Error converting hex: ${e.message}" irkError = errorText + " " + (e.message ?: "Unknown error")
} }
} }
) { ) {
@@ -1483,7 +1411,7 @@ fun AppSettingsScreen(navController: NavController) {
onDismissRequest = { showEncKeyDialog = false }, onDismissRequest = { showEncKeyDialog = false },
title = { title = {
Text( Text(
"Set Encryption Key", stringResource(R.string.set_encryption_key),
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
@@ -1491,7 +1419,7 @@ fun AppSettingsScreen(navController: NavController) {
text = { text = {
Column { Column {
Text( Text(
"Enter 16-byte ENC_KEY as hex string (32 characters):", stringResource(R.string.enter_enc_key_hex),
fontFamily = FontFamily(Font(R.font.sf_pro)), fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier.padding(bottom = 8.dp) modifier = Modifier.padding(bottom = 8.dp)
) )
@@ -1514,14 +1442,16 @@ fun AppSettingsScreen(navController: NavController) {
), ),
supportingText = { supportingText = {
if (encKeyError != null) { if (encKeyError != null) {
Text(encKeyError!!, color = MaterialTheme.colorScheme.error) Text(stringResource(R.string.must_be_32_hex_chars), color = MaterialTheme.colorScheme.error)
} }
}, },
label = { Text("ENC_KEY Hex Value") } label = { Text(stringResource(R.string.enc_key_hex_value)) }
) )
} }
}, },
confirmButton = { confirmButton = {
val successText = stringResource(R.string.encryption_key_set_success)
val errorText = stringResource(R.string.error_converting_hex)
TextButton( TextButton(
onClick = { onClick = {
if (!validateHexInput(encKeyValue)) { if (!validateHexInput(encKeyValue)) {
@@ -1539,10 +1469,10 @@ fun AppSettingsScreen(navController: NavController) {
val base64Value = Base64.encode(hexBytes) val base64Value = Base64.encode(hexBytes)
sharedPreferences.edit { putString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, base64Value)} sharedPreferences.edit { putString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, base64Value)}
Toast.makeText(context, "Encryption key has been set successfully", Toast.LENGTH_SHORT).show() Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
showEncKeyDialog = false showEncKeyDialog = false
} catch (e: Exception) { } catch (e: Exception) {
encKeyError = "Error converting hex: ${e.message}" encKeyError = errorText + " " + (e.message ?: "Unknown error")
} }
} }
) { ) {

View File

@@ -29,10 +29,8 @@ import android.widget.Toast
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -50,37 +48,25 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Send import androidx.compose.material.icons.filled.Send
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
@@ -92,15 +78,13 @@ import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
import dev.chrisbanes.haze.HazeEffectScope
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.constants.BatteryStatus import me.kavishdevar.librepods.constants.BatteryStatus
import me.kavishdevar.librepods.constants.isHeadTrackingData import me.kavishdevar.librepods.constants.isHeadTrackingData
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
@@ -304,52 +288,24 @@ fun parseOutgoingPacket(bytes: ByteArray, rawData: String): PacketInfo {
} }
} }
@Composable
fun IOSCheckbox(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.size(24.dp)
.clickable { onCheckedChange(!checked) },
contentAlignment = Alignment.Center
) {
if (checked) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Checked",
tint = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5),
modifier = Modifier.size(20.dp)
)
}
}
}
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, ExperimentalFoundationApi::class)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter", "UnspecifiedRegisterReceiverFlag") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter", "UnspecifiedRegisterReceiverFlag")
@Composable @Composable
fun DebugScreen(navController: NavController) { fun DebugScreen(navController: NavController) {
val hazeState = remember { HazeState() }
val context = LocalContext.current val context = LocalContext.current
val listState = rememberLazyListState() val listState = rememberLazyListState()
val scrollOffset by remember { derivedStateOf { listState.firstVisibleItemScrollOffset } }
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val showMenu = remember { mutableStateOf(false) }
val airPodsService = remember { ServiceManager.getService() } val airPodsService = remember { ServiceManager.getService() }
val packetLogs = airPodsService?.packetLogsFlow?.collectAsState(emptySet())?.value ?: emptySet() val packetLogs = airPodsService?.packetLogsFlow?.collectAsState(emptySet())?.value ?: emptySet()
val shouldScrollToBottom = remember { mutableStateOf(true) }
val refreshTrigger = remember { mutableIntStateOf(0) } val refreshTrigger = remember { mutableIntStateOf(0) }
LaunchedEffect(refreshTrigger.intValue) { LaunchedEffect(refreshTrigger.intValue) {
while(true) { while(true) {
delay(1000) delay(1000)
refreshTrigger.intValue = refreshTrigger.intValue + 1 refreshTrigger.intValue += 1
} }
} }
@@ -363,137 +319,42 @@ fun DebugScreen(navController: NavController) {
} }
LaunchedEffect(packetLogs.size, refreshTrigger.intValue) { LaunchedEffect(packetLogs.size, refreshTrigger.intValue) {
if (shouldScrollToBottom.value && packetLogs.isNotEmpty()) { if (packetLogs.isNotEmpty()) {
listState.animateScrollToItem(packetLogs.size - 1) listState.animateScrollToItem(packetLogs.size - 1)
} }
} }
Scaffold( val isDarkTheme = isSystemInDarkTheme()
topBar = {
CenterAlignedTopAppBar(
title = { Text("Debug") },
navigationIcon = {
TextButton(
onClick = { navController.popBackStack() },
shape = RoundedCornerShape(8.dp),
) {
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
contentDescription = "Back",
tint = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5),
modifier = Modifier.scale(1.5f)
)
Text(
sharedPreferences.getString("name", "AirPods")!!,
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
)
}
},
actions = {
Box {
IconButton(onClick = { showMenu.value = true }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "More Options",
tint = if (isSystemInDarkTheme()) Color.White else Color.Black
)
}
DropdownMenu( StyledScaffold(
expanded = showMenu.value, title = "Debug",
onDismissRequest = { showMenu.value = false }, navigationButton = {
modifier = Modifier StyledIconButton(
.width(250.dp) onClick = { navController.popBackStack() },
.background( icon = "􀯶",
if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7) darkMode = isDarkTheme
)
.padding(vertical = 4.dp)
) {
DropdownMenuItem(
text = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
"Auto-scroll",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal
)
)
Spacer(modifier = Modifier.weight(1f))
IOSCheckbox(
checked = shouldScrollToBottom.value,
onCheckedChange = { shouldScrollToBottom.value = it }
)
}
},
onClick = {
shouldScrollToBottom.value = !shouldScrollToBottom.value
showMenu.value = false
}
)
HorizontalDivider(
color = if (isSystemInDarkTheme()) Color(0xFF3A3A3C) else Color(0xFFE5E5EA),
thickness = 0.5.dp
)
DropdownMenuItem(
text = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
"Clear logs",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal
)
)
Spacer(modifier = Modifier.weight(1f))
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Clear logs",
tint = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5)
)
}
},
onClick = {
ServiceManager.getService()?.clearLogs()
expandedItems.value = emptySet()
showMenu.value = false
}
)
}
}
},
modifier = Modifier.hazeEffect(
state = hazeState,
style = CupertinoMaterials.thick(),
block = fun HazeEffectScope.() {
alpha = if (scrollOffset > 0) 1f else 0f
}),
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
) )
}, },
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7), actionButtons = listOf(
) { paddingValues -> {
StyledIconButton(
onClick = {
airPodsService?.clearLogs()
expandedItems.value = emptySet()
},
icon = "􀈑",
darkMode = isDarkTheme,
)
}
),
) { spacerHeight, hazeState ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.hazeSource(hazeState) .hazeSource(hazeState)
.padding(top = paddingValues.calculateTopPadding())
.navigationBarsPadding() .navigationBarsPadding()
) { ) {
Spacer(modifier = Modifier.height(spacerHeight))
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier modifier = Modifier
@@ -509,7 +370,7 @@ fun DebugScreen(navController: NavController) {
Card( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 2.dp, horizontal = 4.dp) .padding(vertical = 2.dp)
.combinedClickable( .combinedClickable(
onClick = { onClick = {
expandedItems.value = if (isExpanded) { expandedItems.value = if (isExpanded) {
@@ -528,67 +389,65 @@ fun DebugScreen(navController: NavController) {
containerColor = if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7), containerColor = if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7),
) )
) { ) {
Column(modifier = Modifier.padding(8.dp)) { Row(verticalAlignment = Alignment.CenterVertically) {
Row(verticalAlignment = Alignment.CenterVertically) { Icon(
Icon( imageVector = if (isSent) Icons.AutoMirrored.Filled.KeyboardArrowLeft else Icons.AutoMirrored.Filled.KeyboardArrowRight,
imageVector = if (isSent) Icons.AutoMirrored.Filled.KeyboardArrowLeft else Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null,
contentDescription = null, tint = if (isSent) Color.Green else Color.Red,
tint = if (isSent) Color.Green else Color.Red, modifier = Modifier.size(24.dp)
modifier = Modifier.size(24.dp) )
Spacer(modifier = Modifier.width(4.dp))
Column {
Text(
text = if (packetInfo.isUnknown) {
val shortenedData = packetInfo.rawData.take(60) +
(if (packetInfo.rawData.length > 60) "..." else "")
shortenedData
} else {
"${packetInfo.type}: ${packetInfo.description}"
},
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.hack))
)
) )
Spacer(modifier = Modifier.width(4.dp)) if (isExpanded) {
Column { Spacer(modifier = Modifier.height(4.dp))
if (packetInfo.parsedData.isNotEmpty()) {
packetInfo.parsedData.forEach { (key, value) ->
Row {
Text(
text = "$key: ",
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily(Font(R.font.hack))
),
color = Color.Gray
)
Text(
text = value,
style = TextStyle(
fontSize = 12.sp,
fontFamily = FontFamily(Font(R.font.hack))
),
color = Color.Gray
)
}
}
Spacer(modifier = Modifier.height(4.dp))
}
Text( Text(
text = if (packetInfo.isUnknown) { text = "Raw: ${packetInfo.rawData}",
val shortenedData = packetInfo.rawData.take(60) +
(if (packetInfo.rawData.length > 60) "..." else "")
shortenedData
} else {
"${packetInfo.type}: ${packetInfo.description}"
},
style = TextStyle( style = TextStyle(
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.hack)) fontFamily = FontFamily(Font(R.font.hack))
) ),
color = Color.Gray
) )
if (isExpanded) {
Spacer(modifier = Modifier.height(4.dp))
if (packetInfo.parsedData.isNotEmpty()) {
packetInfo.parsedData.forEach { (key, value) ->
Row {
Text(
text = "$key: ",
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily(Font(R.font.hack))
),
color = Color.Gray
)
Text(
text = value,
style = TextStyle(
fontSize = 12.sp,
fontFamily = FontFamily(Font(R.font.hack))
),
color = Color.Gray
)
}
}
Spacer(modifier = Modifier.height(4.dp))
}
Text(
text = "Raw: ${packetInfo.rawData}",
style = TextStyle(
fontSize = 12.sp,
fontFamily = FontFamily(Font(R.font.hack))
),
color = Color.Gray
)
}
} }
} }
} }
@@ -627,7 +486,7 @@ fun DebugScreen(navController: NavController) {
packet.value = TextFieldValue("") packet.value = TextFieldValue("")
focusManager.clearFocus() focusManager.clearFocus()
if (shouldScrollToBottom.value && packetLogs.isNotEmpty()) { if (packetLogs.isNotEmpty()) {
coroutineScope.launch { coroutineScope.launch {
try { try {
delay(100) delay(100)

View File

@@ -41,25 +41,15 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -74,22 +64,16 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asAndroidPath import androidx.compose.ui.graphics.asAndroidPath
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@@ -99,22 +83,19 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
import dev.chrisbanes.haze.HazeEffectScope
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.IndependentToggle import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.HeadTracking import me.kavishdevar.librepods.utils.HeadTracking
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -134,147 +115,59 @@ fun HeadTrackingScreen(navController: NavController) {
ServiceManager.getService()?.stopHeadTracking() ServiceManager.getService()?.stopHeadTracking()
} }
} }
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val hazeState = remember { HazeState() }
var mDensity by remember { mutableFloatStateOf(0f) } StyledScaffold (
Scaffold( title = stringResource(R.string.head_tracking),
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), navigationButton = {
topBar = { StyledIconButton(
CenterAlignedTopAppBar( onClick = { navController.popBackStack() },
modifier = Modifier.hazeEffect( icon = "􀯶",
state = hazeState, darkMode = isDarkTheme
style = CupertinoMaterials.thick(),
block = fun HazeEffectScope.() {
alpha =
if (scrollState.value > 60.dp.value * mDensity) 1f else 0f
})
.drawBehind {
mDensity = density
val strokeWidth = 0.7.dp.value * density
val y = size.height - strokeWidth / 2
if (scrollState.value > 60.dp.value * density) {
drawLine(
if (isDarkTheme) Color.DarkGray else Color.LightGray,
Offset(0f, y),
Offset(size.width, y),
strokeWidth
)
}
},
title = {
Text(
stringResource(R.string.head_tracking),
fontFamily = FontFamily(Font(R.font.sf_pro)),
)
},
navigationIcon = {
TextButton(
onClick = {
navController.popBackStack()
if (ServiceManager.getService()?.isHeadTrackingActive == true) ServiceManager.getService()?.stopHeadTracking()
},
shape = RoundedCornerShape(8.dp),
modifier = Modifier.width(180.dp)
) {
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
contentDescription = "Back",
tint = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
modifier = Modifier.scale(1.5f)
)
Text(
sharedPreferences.getString("name", "AirPods")!!,
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent
),
actions = {
var isActive by remember { mutableStateOf(ServiceManager.getService()?.isHeadTrackingActive == true) }
IconButton(
onClick = {
if (ServiceManager.getService()?.isHeadTrackingActive == false) {
ServiceManager.getService()?.startHeadTracking()
Log.d("HeadTrackingScreen", "Head tracking started")
isActive = true
} else {
ServiceManager.getService()?.stopHeadTracking()
Log.d("HeadTrackingScreen", "Head tracking stopped")
isActive = false
}
},
) {
Icon(
if (isActive) {
ImageVector.Builder(
name = "Pause",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f
).apply {
path(
fill = SolidColor(Color.Black),
pathBuilder = {
moveTo(6f, 5f)
lineTo(10f, 5f)
lineTo(10f, 19f)
lineTo(6f, 19f)
lineTo(6f, 5f)
moveTo(14f, 5f)
lineTo(18f, 5f)
lineTo(18f, 19f)
lineTo(14f, 19f)
lineTo(14f, 5f)
}
)
}.build()
} else Icons.Filled.PlayArrow,
contentDescription = "Start",
tint = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
modifier = Modifier.scale(1.5f)
)
}
},
scrollBehavior = scrollBehavior
) )
}, },
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) actionButtons = listOf(
else Color(0xFFF2F2F7), {
) { paddingValues -> var isActive by remember { mutableStateOf(ServiceManager.getService()?.isHeadTrackingActive == true) }
StyledIconButton(
onClick = {
if (ServiceManager.getService()?.isHeadTrackingActive == false) {
ServiceManager.getService()?.startHeadTracking()
Log.d("HeadTrackingScreen", "Head tracking started")
} else {
ServiceManager.getService()?.stopHeadTracking()
Log.d("HeadTrackingScreen", "Head tracking stopped")
}
},
icon = if (isActive) "􀊅" else "􀊃",
darkMode = isDarkTheme
)
}
),
) { spacerHeight, hazeState ->
Column ( Column (
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues = paddingValues)
.padding(horizontal = 16.dp)
.padding(top = 8.dp) .padding(top = 8.dp)
.verticalScroll(scrollState) .verticalScroll(scrollState)
.hazeSource(state = hazeState) .hazeSource(state = hazeState)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight))
val sharedPreferences = val sharedPreferences =
LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE) LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
var gestureText by remember { mutableStateOf("") } var gestureText by remember { mutableStateOf("") }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
IndependentToggle(name = "Head Gestures", sharedPreferences = sharedPreferences) StyledToggle(
label = "Head Gestures",
sharedPreferences = sharedPreferences,
sharedPreferenceKey = "head_gestures",
)
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
Text( Text(
stringResource(R.string.head_gestures_details), stringResource(R.string.head_gestures_details),
@@ -302,7 +195,7 @@ fun HeadTrackingScreen(navController: NavController) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
"Acceleration", "Velocity",
style = TextStyle( style = TextStyle(
fontSize = 18.sp, fontSize = 18.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
@@ -441,14 +334,13 @@ private fun ParticleText(
if (particles.isEmpty()) { if (particles.isEmpty()) {
val random = Random(System.currentTimeMillis()) val random = Random(System.currentTimeMillis())
for (i in 0..100) { for (@Suppress("Unused")i in 0..100) {
val x = centerX + random.nextFloat() * textBounds.width val x = centerX + random.nextFloat() * textBounds.width
val y = centerY - textBounds.height / 2 + random.nextFloat() * textBounds.height val y = centerY - textBounds.height / 2 + random.nextFloat() * textBounds.height
val vx = (random.nextFloat() - 0.5f) * 20 val vx = (random.nextFloat() - 0.5f) * 20
val vy = (random.nextFloat() - 0.5f) * 20 val vy = (random.nextFloat() - 0.5f) * 20
particles.add(Particle(Offset(x, y), Offset(vx, vy))) particles.add(Particle(Offset(x, y), Offset(vx, vy)))
} }
textVisible = false
} }
particles.forEach { particle -> particles.forEach { particle ->
@@ -518,14 +410,12 @@ private fun HeadVisualization() {
fun rotate3D(point: Triple<Float, Float, Float>): Triple<Float, Float, Float> { fun rotate3D(point: Triple<Float, Float, Float>): Triple<Float, Float, Float> {
val (x, y, z) = point val (x, y, z) = point
val x1 = x * cosY - z * sinY val x1 = x * cosY - z * sinY
val y1 = y
val z1 = x * sinY + z * cosY val z1 = x * sinY + z * cosY
val x2 = x1 val y2 = y * cosP - z1 * sinP
val y2 = y1 * cosP - z1 * sinP val z2 = y * sinP + z1 * cosP
val z2 = y1 * sinP + z1 * cosP
return Triple(x2, y2, z2) return Triple(x1, y2, z2)
} }
fun project(point: Triple<Float, Float, Float>): Pair<Float, Float> { fun project(point: Triple<Float, Float, Float>): Pair<Float, Float> {

View File

@@ -19,22 +19,16 @@
package me.kavishdevar.librepods.screens package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.util.Log import android.util.Log
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -43,23 +37,11 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource 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.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
import dev.chrisbanes.haze.HazeEffectScope
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -67,13 +49,14 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.IndependentToggle import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.ATTManager import me.kavishdevar.librepods.utils.ATTManager
import me.kavishdevar.librepods.utils.RadareOffsetFinder
import java.io.IOException import java.io.IOException
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
@@ -88,78 +71,31 @@ private const val TAG = "HearingAidAdjustments"
@Composable @Composable
fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController) { fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() } val hazeState = remember { HazeState() }
val snackbarHostState = remember { SnackbarHostState() }
val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available") val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
val aacpManager = remember { ServiceManager.getService()?.aacpManager } val aacpManager = remember { ServiceManager.getService()?.aacpManager }
val context = LocalContext.current
remember { RadareOffsetFinder(context) }
remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
val service = ServiceManager.getService()
if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491) StyledScaffold(
if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) title = stringResource(R.string.adjustments),
if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF) navigationButton = {
if (isDarkTheme) Color.White else Color.Black StyledIconButton(
onClick = { navController.popBackStack() },
Scaffold( icon = "􀯶",
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7), darkMode = isDarkTheme
topBar = {
val darkMode = isSystemInDarkTheme()
val mDensity = remember { mutableFloatStateOf(1f) }
CenterAlignedTopAppBar(
title = {
Text(
text = stringResource(R.string.adjustments),
style = TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
color = if (darkMode) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
},
modifier = Modifier
.hazeEffect(
state = hazeState,
style = CupertinoMaterials.thick(),
block = fun HazeEffectScope.() {
alpha = if (verticalScrollState.value > 60.dp.value * mDensity.floatValue) 1f else 0f
})
.drawBehind {
mDensity.floatValue = density
val strokeWidth = 0.7.dp.value * density
val y = size.height - strokeWidth / 2
if (verticalScrollState.value > 60.dp.value * density) {
drawLine(
if (darkMode) Color.DarkGray else Color.LightGray,
Offset(0f, y),
Offset(size.width, y),
strokeWidth
)
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(containerColor = Color.Transparent)
) )
}, }
snackbarHost = { SnackbarHost(snackbarHostState) } ) { spacerHeight ->
) { paddingValues ->
Column( Column(
modifier = Modifier modifier = Modifier
.hazeSource(hazeState) .hazeSource(hazeState)
.fillMaxSize() .fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp)
.verticalScroll(verticalScrollState), .verticalScroll(verticalScrollState),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) Spacer(modifier = Modifier.height(spacerHeight))
remember { mutableStateOf(false) }
val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) } val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) }
val balanceSliderValue = remember { mutableFloatStateOf(0.5f) } val balanceSliderValue = remember { mutableFloatStateOf(0.5f) }
val toneSliderValue = remember { mutableFloatStateOf(0.5f) } val toneSliderValue = remember { mutableFloatStateOf(0.5f) }
@@ -355,12 +291,9 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
independent = true, independent = true,
) )
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
IndependentToggle( StyledToggle(
name = stringResource(R.string.swipe_to_control_amplification), label = stringResource(R.string.swipe_to_control_amplification),
service = service,
sharedPreferences = sharedPreferences,
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE, controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE,
description = stringResource(R.string.swipe_amplification_description) description = stringResource(R.string.swipe_amplification_description)
) )

View File

@@ -39,27 +39,21 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -70,20 +64,20 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
import com.kyant.backdrop.backdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.rememberBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeEffectScope
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.ConfirmationDialog import me.kavishdevar.librepods.composables.ConfirmationDialog
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSwitch import me.kavishdevar.librepods.composables.StyledSwitch
import me.kavishdevar.librepods.composables.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.utils.ATTHandles
@@ -108,7 +102,8 @@ fun HearingAidScreen(navController: NavController) {
val aacpManager = remember { ServiceManager.getService()?.aacpManager } val aacpManager = remember { ServiceManager.getService()?.aacpManager }
val showDialog = remember { mutableStateOf(false) } val showDialog = remember { mutableStateOf(false) }
val backdrop = rememberBackdrop() val backdrop = rememberLayerBackdrop()
val initialLoad = remember { mutableStateOf(true) }
val hearingAidEnabled = remember { val hearingAidEnabled = remember {
val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID } val aidStatus = aacpManager?.controlCommandStatusList?.find { it.identifier == AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID }
@@ -116,67 +111,28 @@ fun HearingAidScreen(navController: NavController) {
mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte())) mutableStateOf((aidStatus?.value?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.value?.getOrNull(0) == 0x01.toByte()))
} }
Scaffold( StyledScaffold(
containerColor = if (isSystemInDarkTheme()) Color( title = stringResource(R.string.hearing_aid),
0xFF000000 navigationButton = {
) else Color( StyledIconButton(
0xFFF2F2F7 onClick = { navController.popBackStack() },
), icon = "􀯶",
topBar = { darkMode = isDarkTheme
val darkMode = isSystemInDarkTheme()
val mDensity = remember { mutableFloatStateOf(1f) }
CenterAlignedTopAppBar(
title = {
Text(
text = stringResource(R.string.hearing_aid),
style = TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
color = if (darkMode) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
},
modifier = Modifier
.hazeEffect(
state = hazeState,
style = CupertinoMaterials.thick(),
block = fun HazeEffectScope.() {
alpha =
if (verticalScrollState.value > 60.dp.value * mDensity.floatValue) 1f else 0f
})
.drawBehind {
mDensity.floatValue = density
val strokeWidth = 0.7.dp.value * density
val y = size.height - strokeWidth / 2
if (verticalScrollState.value > 60.dp.value * density) {
drawLine(
if (darkMode) Color.DarkGray else Color.LightGray,
Offset(0f, y),
Offset(size.width, y),
strokeWidth
)
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent
)
) )
}, },
snackbarHost = { SnackbarHost(snackbarHostState) }, actionButtons = emptyList(),
modifier = Modifier snackbarHostState = snackbarHostState,
.backdrop(backdrop) ) { spacerHeight ->
) { paddingValues ->
Column( Column(
modifier = Modifier modifier = Modifier
.layerBackdrop(backdrop)
.hazeSource(hazeState) .hazeSource(hazeState)
.fillMaxSize() .fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp)
.verticalScroll(verticalScrollState), .verticalScroll(verticalScrollState),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight))
val hearingAidListener = remember { val hearingAidListener = remember {
object : AACPManager.ControlCommandListener { object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) { override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
@@ -206,14 +162,15 @@ fun HearingAidScreen(navController: NavController) {
} }
} }
fun onChange(value: Boolean) { LaunchedEffect(hearingAidEnabled.value) {
if (value) { if (hearingAidEnabled.value && !initialLoad.value) {
showDialog.value = true showDialog.value = true
} else { } else if (!hearingAidEnabled.value) {
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x02)) aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID.value, byteArrayOf(0x01, 0x02))
aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x02.toByte()) aacpManager?.sendControlCommand(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG.value, 0x02.toByte())
hearingAidEnabled.value = false hearingAidEnabled.value = false
} }
initialLoad.value = false
} }
fun onAdjustPhoneChange(value: Boolean) { fun onAdjustPhoneChange(value: Boolean) {
@@ -241,37 +198,15 @@ fun HearingAidScreen(navController: NavController) {
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp)) .background(backgroundColor, RoundedCornerShape(14.dp))
) { .clip(
val isDarkThemeLocal = isSystemInDarkTheme() RoundedCornerShape(14.dp)
var backgroundColorHA by remember { mutableStateOf(if (isDarkThemeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColorHA by animateColorAsState(targetValue = backgroundColorHA, animationSpec = tween(durationMillis = 500))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
backgroundColorHA = if (isDarkThemeLocal) Color(0x40888888) else Color(0x40D9D9D9)
tryAwaitRelease()
backgroundColorHA = if (isDarkThemeLocal) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
},
onTap = {
onChange(value = !hearingAidEnabled.value)
}
)
},
verticalAlignment = Alignment.CenterVertically
) {
Text(text = stringResource(R.string.hearing_aid), modifier = Modifier.weight(1f), fontSize = 16.sp, color = textColor)
StyledSwitch(
checked = hearingAidEnabled.value,
onCheckedChange = {
onChange(value = it)
},
) )
} ) {
StyledToggle(
label = stringResource(R.string.hearing_aid),
checkedState = hearingAidEnabled,
independent = false
)
HorizontalDivider( HorizontalDivider(
thickness = 1.5.dp, thickness = 1.5.dp,
color = Color(0x40888888), color = Color(0x40888888),
@@ -299,7 +234,6 @@ fun HearingAidScreen(navController: NavController) {
) )
} }
} }
Text( Text(
text = stringResource(R.string.hearing_aid_description), text = stringResource(R.string.hearing_aid_description),
style = TextStyle( style = TextStyle(
@@ -310,7 +244,6 @@ fun HearingAidScreen(navController: NavController) {
), ),
modifier = Modifier.padding(horizontal = 8.dp) modifier = Modifier.padding(horizontal = 8.dp)
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
AccessibilityToggle( AccessibilityToggle(

View File

@@ -39,25 +39,18 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -78,14 +71,18 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.edit
import androidx.navigation.NavController import androidx.navigation.NavController
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.utils.RadareOffsetFinder import me.kavishdevar.librepods.utils.RadareOffsetFinder
import androidx.core.content.edit
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun Onboarding(navController: NavController, activityContext: Context) { fun Onboarding(navController: NavController, activityContext: Context) {
@@ -104,7 +101,6 @@ fun Onboarding(navController: NavController, activityContext: Context) {
var moduleEnabled by remember { mutableStateOf(false) } var moduleEnabled by remember { mutableStateOf(false) }
var bluetoothToggled by remember { mutableStateOf(false) } var bluetoothToggled by remember { mutableStateOf(false) }
var showMenu by remember { mutableStateOf(false) }
var showSkipDialog by remember { mutableStateOf(false) } var showSkipDialog by remember { mutableStateOf(false) }
fun checkRootAccess() { fun checkRootAccess() {
@@ -155,55 +151,27 @@ fun Onboarding(navController: NavController, activityContext: Context) {
isComplete = true isComplete = true
} }
} }
StyledScaffold(
Scaffold( title = "Setting Up",
topBar = { actionButtons = listOf(
CenterAlignedTopAppBar( {
title = { StyledIconButton(
Text( onClick = {
"Setting Up", showSkipDialog = true
fontFamily = FontFamily(Font(R.font.sf_pro)), },
fontWeight = FontWeight.Medium icon = "􀊋",
) darkMode = isDarkTheme
}, )
colors = TopAppBarDefaults.topAppBarColors( }
containerColor = Color.Transparent )
), ) { spacerHeight ->
actions = {
Box {
IconButton(onClick = { showMenu = true }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "More Options"
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { Text("Skip Setup") },
onClick = {
showMenu = false
showSkipDialog = true
}
)
}
}
}
)
},
containerColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)
) { paddingValues ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize(),
.padding(paddingValues)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(spacerHeight))
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -300,7 +268,8 @@ fun Onboarding(navController: NavController, activityContext: Context) {
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
AnimatedContent( AnimatedContent(
targetState = if (hasStarted) getStatusTitle(progressState, isComplete, moduleEnabled, bluetoothToggled) else "Setup Required", targetState = if (hasStarted) getStatusTitle(progressState,
moduleEnabled, bluetoothToggled) else "Setup Required",
transitionSpec = { fadeIn() togetherWith fadeOut() } transitionSpec = { fadeIn() togetherWith fadeOut() }
) { text -> ) { text ->
Text( Text(
@@ -319,7 +288,7 @@ fun Onboarding(navController: NavController, activityContext: Context) {
AnimatedContent( AnimatedContent(
targetState = if (hasStarted) targetState = if (hasStarted)
getStatusDescription(progressState, isComplete, moduleEnabled, bluetoothToggled) getStatusDescription(progressState, moduleEnabled, bluetoothToggled)
else else
"AirPods functionality requires one-time setup for hooking into Bluetooth library", "AirPods functionality requires one-time setup for hooking into Bluetooth library",
transitionSpec = { fadeIn() togetherWith fadeOut() } transitionSpec = { fadeIn() togetherWith fadeOut() }
@@ -608,7 +577,6 @@ private fun StatusIcon(
private fun getStatusTitle( private fun getStatusTitle(
state: RadareOffsetFinder.ProgressState, state: RadareOffsetFinder.ProgressState,
isComplete: Boolean,
moduleEnabled: Boolean, moduleEnabled: Boolean,
bluetoothToggled: Boolean bluetoothToggled: Boolean
): String { ): String {
@@ -635,7 +603,6 @@ private fun getStatusTitle(
private fun getStatusDescription( private fun getStatusDescription(
state: RadareOffsetFinder.ProgressState, state: RadareOffsetFinder.ProgressState,
isComplete: Boolean,
moduleEnabled: Boolean, moduleEnabled: Boolean,
bluetoothToggled: Boolean bluetoothToggled: Boolean
): String { ): String {
@@ -660,6 +627,7 @@ private fun getStatusDescription(
} }
} }
@ExperimentalHazeMaterialsApi
@Preview @Preview
@Composable @Composable
fun OnboardingPreview() { fun OnboardingPreview() {

View File

@@ -30,24 +30,19 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -68,14 +63,17 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.edit import androidx.core.content.edit
import androidx.navigation.NavController import androidx.navigation.NavController
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.constants.StemAction import me.kavishdevar.librepods.constants.StemAction
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager import me.kavishdevar.librepods.utils.AACPManager
import kotlin.experimental.and import kotlin.experimental.and
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@Composable() @Composable
fun RightDivider() { fun RightDivider() {
HorizontalDivider( HorizontalDivider(
thickness = 1.5.dp, thickness = 1.5.dp,
@@ -85,7 +83,7 @@ fun RightDivider() {
) )
} }
@Composable() @Composable
fun RightDividerNoIcon() { fun RightDividerNoIcon() {
HorizontalDivider( HorizontalDivider(
thickness = 1.5.dp, thickness = 1.5.dp,
@@ -95,6 +93,7 @@ fun RightDividerNoIcon() {
) )
} }
@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LongPress(navController: NavController, name: String) { fun LongPress(navController: NavController, name: String) {
@@ -114,60 +113,27 @@ fun LongPress(navController: NavController, name: String) {
} }
val context = LocalContext.current val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val deviceName = sharedPreferences.getString("name", "AirPods Pro")
val prefKey = if (name.lowercase() == "left") "left_long_press_action" else "right_long_press_action" val prefKey = if (name.lowercase() == "left") "left_long_press_action" else "right_long_press_action"
val longPressActionPref = sharedPreferences.getString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name) val longPressActionPref = sharedPreferences.getString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name)
Log.d("PressAndHoldSettingsScreen", "Long press action preference ($prefKey): $longPressActionPref") Log.d("PressAndHoldSettingsScreen", "Long press action preference ($prefKey): $longPressActionPref")
var longPressAction by remember { mutableStateOf(StemAction.valueOf(longPressActionPref ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) } var longPressAction by remember { mutableStateOf(StemAction.valueOf(longPressActionPref ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) }
Scaffold( StyledScaffold(
topBar = { title = name,
CenterAlignedTopAppBar( navigationButton = {
title = { StyledIconButton(
Text( onClick = { navController.popBackStack() },
name, icon = "􀯶",
fontFamily = FontFamily(Font(R.font.sf_pro)), darkMode = isDarkTheme
)
},
navigationIcon = {
TextButton(
onClick = {
navController.popBackStack()
},
shape = RoundedCornerShape(8.dp),
) {
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
contentDescription = "Back",
tint = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
modifier = Modifier.scale(1.5f)
)
Text(
deviceName?: "AirPods Pro",
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
) )
}, }
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) ) { spacerHeight ->
else Color(0xFFF2F2F7),
) { paddingValues ->
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Column ( Column (
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues = paddingValues)
.padding(horizontal = 16.dp)
.padding(top = 8.dp) .padding(top = 8.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight))
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -221,33 +187,37 @@ fun LongPress(navController: NavController, name: String) {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
}?.value?.takeIf { it.isNotEmpty() }?.get(0) }?.value?.takeIf { it.isNotEmpty() }?.get(0)
val offListeningMode = offListeningModeValue == 1.toByte() val offListeningMode = offListeningModeValue == 1.toByte()
LongPressElement( ListeningModeElement(
name = "Off", name = "Off",
enabled = offListeningMode, enabled = offListeningMode,
resourceId = R.drawable.noise_cancellation, resourceId = R.drawable.noise_cancellation,
isFirst = true) isFirst = true)
if (offListeningMode) RightDivider() if (offListeningMode) RightDivider()
LongPressElement( ListeningModeElement(
name = "Transparency", name = "Transparency",
resourceId = R.drawable.transparency, resourceId = R.drawable.transparency,
isFirst = !offListeningMode) isFirst = !offListeningMode)
RightDivider() RightDivider()
LongPressElement( ListeningModeElement(
name = "Adaptive", name = "Adaptive",
resourceId = R.drawable.adaptive) resourceId = R.drawable.adaptive)
RightDivider() RightDivider()
LongPressElement( ListeningModeElement(
name = "Noise Cancellation", name = "Noise Cancellation",
resourceId = R.drawable.noise_cancellation, resourceId = R.drawable.noise_cancellation,
isLast = true) isLast = true)
} }
Spacer(modifier = Modifier.height(4.dp))
Text( Text(
"Press and hold the stem to cycle between the selected noise control modes.", text = "Press and hold the stem to cycle between the selected noise control modes.",
fontSize = 16.sp, style = TextStyle(
fontFamily = FontFamily(Font(R.font.sf_pro)), fontSize = 12.sp,
color = textColor.copy(alpha = 0.6f), fontWeight = FontWeight.Light,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier modifier = Modifier
.padding(start = 16.dp, top = 4.dp) .padding(horizontal = 8.dp)
) )
} }
} }
@@ -258,7 +228,7 @@ fun LongPress(navController: NavController, name: String) {
} }
@Composable @Composable
fun LongPressElement(name: String, enabled: Boolean = true, resourceId: Int, isFirst: Boolean = false, isLast: Boolean = false) { fun ListeningModeElement(name: String, enabled: Boolean = true, resourceId: Int, isFirst: Boolean = false, isLast: Boolean = false) {
val bit = when (name) { val bit = when (name) {
"Off" -> 0x01 "Off" -> 0x01
"Transparency" -> 0x02 "Transparency" -> 0x02
@@ -280,7 +250,7 @@ fun LongPressElement(name: String, enabled: Boolean = true, resourceId: Int, isF
val isChecked = (byteValue.toInt() and bit) != 0 val isChecked = (byteValue.toInt() and bit) != 0
val checked = remember { mutableStateOf(isChecked) } val checked = remember { mutableStateOf(isChecked) }
Log.d("PressAndHoldSettingsScreen", "LongPressElement: $name, checked: ${checked.value}, byteValue: ${byteValue.toInt()}, in bits: ${byteValue.toInt().toString(2)}") Log.d("PressAndHoldSettingsScreen", "ListeningModeElement: $name, checked: ${checked.value}, byteValue: ${byteValue.toInt()}, in bits: ${byteValue.toInt().toString(2)}")
val darkMode = isSystemInDarkTheme() val darkMode = isSystemInDarkTheme()
val textColor = if (darkMode) Color.White else Color.Black val textColor = if (darkMode) Color.White else Color.Black
val desc = when (name) { val desc = when (name) {

View File

@@ -25,6 +25,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -32,23 +33,16 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Clear
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -58,21 +52,21 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle 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.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.edit
import androidx.navigation.NavController import androidx.navigation.NavController
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import androidx.core.content.edit
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable @Composable
fun RenameScreen(navController: NavController) { fun RenameScreen(navController: NavController) {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE) val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
@@ -87,54 +81,21 @@ fun RenameScreen(navController: NavController) {
name.value = name.value.copy(selection = TextRange(name.value.text.length)) name.value = name.value.copy(selection = TextRange(name.value.text.length))
} }
Scaffold( StyledScaffold(
topBar = { title = stringResource(R.string.name),
CenterAlignedTopAppBar( navigationButton = {
title = { StyledIconButton(
Text( onClick = { navController.popBackStack() },
text = stringResource(R.string.name), icon = "􀯶",
fontFamily = FontFamily(Font(R.font.sf_pro)), darkMode = isDarkTheme
)
},
navigationIcon = {
TextButton(
onClick = {
navController.popBackStack()
},
shape = RoundedCornerShape(8.dp),
) {
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
contentDescription = "Back",
tint = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
modifier = Modifier.scale(1.5f)
)
Text(
text = name.value.text,
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
) )
}, },
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) ) { spacerHeight ->
else Color(0xFFF2F2F7), Column(
) { paddingValues ->
Column (
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues = paddingValues)
.padding(horizontal = 16.dp)
.padding(top = 8.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight))
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black

View File

@@ -20,10 +20,7 @@ package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log import android.util.Log
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -39,61 +36,38 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
import dev.chrisbanes.haze.HazeEffectScope
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.ATTHandles import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.ATTManager
import me.kavishdevar.librepods.utils.RadareOffsetFinder import me.kavishdevar.librepods.utils.RadareOffsetFinder
import me.kavishdevar.librepods.utils.TransparencySettings import me.kavishdevar.librepods.utils.TransparencySettings
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse
@@ -111,8 +85,6 @@ fun TransparencySettingsScreen(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() }
val snackbarHostState = remember { SnackbarHostState() }
val attManager = ServiceManager.getService()?.attManager ?: return val attManager = ServiceManager.getService()?.attManager ?: return
val aacpManager = remember { ServiceManager.getService()?.aacpManager } val aacpManager = remember { ServiceManager.getService()?.aacpManager }
val isSdpOffsetAvailable = val isSdpOffsetAvailable =
@@ -122,65 +94,24 @@ fun TransparencySettingsScreen(navController: NavController) {
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF) val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
Scaffold( StyledScaffold(
containerColor = if (isSystemInDarkTheme()) Color( title = stringResource(R.string.customize_transparency_mode),
0xFF000000 navigationButton = {
) else Color( StyledIconButton(
0xFFF2F2F7 onClick = { navController.popBackStack() },
), icon = "􀯶",
topBar = { darkMode = isDarkTheme
val darkMode = isSystemInDarkTheme()
val mDensity = remember { mutableFloatStateOf(1f) }
CenterAlignedTopAppBar(
title = {
Text(
text = stringResource(R.string.customize_transparency_mode),
style = TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Medium,
color = if (darkMode) Color.White else Color.Black,
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
},
modifier = Modifier
.hazeEffect(
state = hazeState,
style = CupertinoMaterials.thick(),
block = fun HazeEffectScope.() {
alpha =
if (verticalScrollState.value > 60.dp.value * mDensity.floatValue) 1f else 0f
})
.drawBehind {
mDensity.floatValue = density
val strokeWidth = 0.7.dp.value * density
val y = size.height - strokeWidth / 2
if (verticalScrollState.value > 60.dp.value * density) {
drawLine(
if (darkMode) Color.DarkGray else Color.LightGray,
Offset(0f, y),
Offset(size.width, y),
strokeWidth
)
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent
)
) )
}, }
snackbarHost = { SnackbarHost(snackbarHostState) } ){ spacerHeight, hazeState ->
) { paddingValues ->
Column( Column(
modifier = Modifier modifier = Modifier
.hazeSource(hazeState) .hazeSource(hazeState)
.fillMaxSize() .fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp)
.verticalScroll(verticalScrollState), .verticalScroll(verticalScrollState),
verticalArrangement = Arrangement.spacedBy(16.dp) verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
Spacer(modifier = Modifier.height(spacerHeight))
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val enabled = remember { mutableStateOf(false) } val enabled = remember { mutableStateOf(false) }
@@ -523,4 +454,4 @@ fun TransparencySettingsScreen(navController: NavController) {
} }
} }
} }
} }

View File

@@ -1,5 +1,5 @@
/* /*
* LibrePods - AirPods liberated from Apple's ecosystem * LibrePods - AirPods liberated from Apples ecosystem
* *
* Copyright (C) 2025 LibrePods contributors * Copyright (C) 2025 LibrePods contributors
* *
@@ -23,9 +23,7 @@ import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@@ -49,29 +47,23 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -80,11 +72,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -97,17 +85,15 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.navigation.NavController import androidx.navigation.NavController
import dev.chrisbanes.haze.HazeEffectScope
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.StyledIconButton
import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.utils.LogCollector import me.kavishdevar.librepods.utils.LogCollector
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -134,8 +120,6 @@ fun CustomIconButton(
fun TroubleshootingScreen(navController: NavController) { fun TroubleshootingScreen(navController: NavController) {
val context = LocalContext.current val context = LocalContext.current
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val hazeState = remember { HazeState() }
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val logCollector = remember { LogCollector(context) } val logCollector = remember { LogCollector(context) }
@@ -161,27 +145,6 @@ fun TroubleshootingScreen(navController: NavController) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
val sheetProgress by remember {
derivedStateOf {
if (!showBottomSheet) 0f else sheetState.targetValue.ordinal.toFloat() / 2f
}
}
val contentScaleFactor by remember {
derivedStateOf {
1.0f - (0.12f * sheetProgress)
}
}
val contentScale by animateFloatAsState(
targetValue = contentScaleFactor,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
),
label = "contentScale"
)
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
val accentColor = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5) val accentColor = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5)
@@ -189,7 +152,6 @@ fun TroubleshootingScreen(navController: NavController) {
var instructionText by remember { mutableStateOf("") } var instructionText by remember { mutableStateOf("") }
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
var mDensity by remember { mutableFloatStateOf(0f) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -249,75 +211,23 @@ fun TroubleshootingScreen(navController: NavController) {
Box( Box(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
Scaffold( StyledScaffold(
modifier = Modifier title = stringResource(R.string.troubleshooting),
.fillMaxSize() navigationButton = {
.graphicsLayer { StyledIconButton(
scaleX = contentScale onClick = { navController.popBackStack() },
scaleY = contentScale icon = "􀯶",
transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0.3f) darkMode = isDarkTheme
},
topBar = {
CenterAlignedTopAppBar(
modifier = Modifier.hazeEffect(
state = hazeState,
style = CupertinoMaterials.thick(),
block = fun HazeEffectScope.() {
alpha = if (scrollState.value > 60.dp.value * mDensity) 1f else 0f
})
.drawBehind {
mDensity = density
val strokeWidth = 0.7.dp.value * density
val y = size.height - strokeWidth / 2
if (scrollState.value > 60.dp.value * density) {
drawLine(
if (isDarkTheme) Color.DarkGray else Color.LightGray,
Offset(0f, y),
Offset(size.width, y),
strokeWidth
)
}
},
title = {
Text(
text = stringResource(R.string.troubleshooting),
fontFamily = FontFamily(Font(R.font.sf_pro)),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
navigationIcon = {
TextButton(
onClick = {
navController.popBackStack()
},
shape = RoundedCornerShape(8.dp),
) {
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowLeft,
contentDescription = "Back",
tint = accentColor,
modifier = Modifier.scale(1.5f)
)
}
},
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent
),
scrollBehavior = scrollBehavior
) )
}, }
containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7), ){ spacerHeight, hazeState ->
) { paddingValues ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp)
.verticalScroll(scrollState) .verticalScroll(scrollState)
.hazeSource(state = hazeState) .hazeSource(state = hazeState)
) { ) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(spacerHeight))
Text( Text(
text = stringResource(R.string.saved_logs).uppercase(), text = stringResource(R.string.saved_logs).uppercase(),
@@ -706,7 +616,9 @@ fun TroubleshootingScreen(navController: NavController) {
Button( Button(
onClick = { onClick = {
selectedLogFile?.let { file -> selectedLogFile?.let { file ->
saveLauncher.launch("airpods_log_${System.currentTimeMillis()}.txt") saveLauncher.launch(
file.absolutePath
)
} }
}, },
shape = RoundedCornerShape(10.dp), shape = RoundedCornerShape(10.dp),
@@ -977,7 +889,7 @@ fun TroubleshootingScreen(navController: NavController) {
Button( Button(
onClick = { onClick = {
selectedLogFile?.let { file -> selectedLogFile?.let { file ->
saveLauncher.launch("airpods_log_${System.currentTimeMillis()}.txt") saveLauncher.launch(file.absolutePath)
} }
}, },
shape = RoundedCornerShape(10.dp), shape = RoundedCornerShape(10.dp),

View File

@@ -1,5 +1,5 @@
/* /*
* LibrePods - AirPods liberated from Apple's ecosystem * LibrePods - AirPods liberated from Apples ecosystem
* *
* Copyright (C) 2025 LibrePods contributors * Copyright (C) 2025 LibrePods contributors
* *
@@ -202,10 +202,10 @@ class AACPManager {
var eqData = FloatArray(8) { 0.0f } var eqData = FloatArray(8) { 0.0f }
private set private set
var eqOnPhone: Boolean = false var eqOnPhone: Boolean = false
private set private set
var eqOnMedia: Boolean = false var eqOnMedia: Boolean = false
private set private set
@@ -528,12 +528,23 @@ class AACPManager {
val packetString = packet.decodeToString() val packetString = packet.decodeToString()
val sender = packet.sliceArray(6..11).reversedArray().joinToString(":") { "%02X".format(it) } val sender = packet.sliceArray(6..11).reversedArray().joinToString(":") { "%02X".format(it) }
if (connectedDevices.find { it.mac == sender }?.type == null && packetString.contains("btName")) { // if (connectedDevices.find { it.mac == sender }?.type == null && packetString.contains("btName")) {
val nameStartIndex = packetString.indexOf("btName") + 7 // val nameStartIndex = packetString.indexOf("btName") + 8
val nameEndIndex = if (packetString.contains("other")) (packetString.indexOf("otherDevice") - 2) else (packetString.indexOf("nearbyAudio") - 2) // val nameEndIndex = if (packetString.contains("other")) (packetString.indexOf("otherDevice") - 1) else (packetString.indexOf("nearbyAudio") - 1)
val name = packet.sliceArray(nameStartIndex..nameEndIndex).decodeToString() // val name = packet.sliceArray(nameStartIndex..nameEndIndex).decodeToString()
connectedDevices.find { it.mac == sender }?.type = name // connectedDevices.find { it.mac == sender }?.type = name
Log.d(TAG, "Device $sender is named $name") // Log.d(TAG, "Device $sender is named $name")
// } // doesn't work, it's different for Mac and iPad. just hardcoding for now
if ("iPad" in packetString) {
connectedDevices.find { it.mac == sender }?.type = "iPad"
} else if ("Mac" in packetString) {
connectedDevices.find { it.mac == sender }?.type = "Mac"
} else if ("iPhone" in packetString) { // not sure if this is it - don't have an iphone
connectedDevices.find { it.mac == sender }?.type = "iPhone"
} else if ("Linux" in packetString) {
connectedDevices.find { it.mac == sender }?.type = "Linux"
} else if ("Android" in packetString) {
connectedDevices.find { it.mac == sender }?.type = "Android"
} }
Log.d(TAG, "Smart Routing Response from $sender: $packetString, type: ${connectedDevices.find { it.mac == sender }?.type}") Log.d(TAG, "Smart Routing Response from $sender: $packetString, type: ${connectedDevices.find { it.mac == sender }?.type}")
if (packetString.contains("SetOwnershipToFalse")) { if (packetString.contains("SetOwnershipToFalse")) {
@@ -568,7 +579,7 @@ class AACPManager {
val eq2 = ByteBuffer.wrap(packet, 44, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() val eq2 = ByteBuffer.wrap(packet, 44, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
val eq3 = ByteBuffer.wrap(packet, 76, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() val eq3 = ByteBuffer.wrap(packet, 76, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
val eq4 = ByteBuffer.wrap(packet, 108, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer() val eq4 = ByteBuffer.wrap(packet, 108, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
// for now, taking just the first EQ // for now, taking just the first EQ
eqData = FloatArray(8) { i -> eq1.get(i) } eqData = FloatArray(8) { i -> eq1.get(i) }
Log.d(TAG, "EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia") Log.d(TAG, "EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia")
@@ -580,17 +591,6 @@ class AACPManager {
} }
} }
fun createEqualizerDataPacket(eqData: FloatArray, eqOnPhone: Boolean, eqOnMedia: Boolean): ByteArray {
val opcode = byteArrayOf(Opcodes.EQ_DATA, 0x00)
val identifier = byteArrayOf(0x84.toByte(), 0x00)
val something = byteArrayOf(0x02, 0x02)
val phoneFlag = if (eqOnPhone) 0x01.toByte() else 0x00.toByte()
val mediaFlag = if (eqOnMedia) 0x01.toByte() else 0x00.toByte()
val buffer = ByteBuffer.allocate(32).order(ByteOrder.LITTLE_ENDIAN)
eqData.forEach { buffer.putFloat(it) }
return opcode + identifier + something + byteArrayOf(phoneFlag, mediaFlag) + buffer.array() + buffer.array() + buffer.array() + buffer.array()
}
fun sendNotificationRequest(): Boolean { fun sendNotificationRequest(): Boolean {
return sendDataPacket(createRequestNotificationPacket()) return sendDataPacket(createRequestNotificationPacket())
} }
@@ -853,11 +853,11 @@ class AACPManager {
Log.w(TAG, "Cannot send Media Information packet: No connected device found") Log.w(TAG, "Cannot send Media Information packet: No connected device found")
return false return false
} }
Log.d(TAG, "Sending Media Information packet to ${targetMac ?: "unknown device"}") Log.d(TAG, "Sending Media Information packet to $targetMac")
return sendDataPacket( return sendDataPacket(
createMediaInformationPacket( createMediaInformationPacket(
selfMacAddress, selfMacAddress,
targetMac ?: return false, targetMac,
streamingState streamingState
) )
) )

View File

@@ -186,9 +186,7 @@ class ATTManager(private val device: BluetoothDevice) {
private fun readResponse(timeoutMs: Long = 2000): ByteArray { private fun readResponse(timeoutMs: Long = 2000): ByteArray {
try { try {
val resp = responses.poll(timeoutMs, TimeUnit.MILLISECONDS) val resp = responses.poll(timeoutMs, TimeUnit.MILLISECONDS)
if (resp == null) { ?: throw IllegalStateException("No response read from ATT socket within $timeoutMs ms")
throw IllegalStateException("No response read from ATT socket within $timeoutMs ms")
}
Log.d(TAG, "readResponse: ${resp.joinToString(" ") { String.format("%02X", it) }}") Log.d(TAG, "readResponse: ${resp.joinToString(" ") { String.format("%02X", it) }}")
return resp.copyOfRange(1, resp.size) return resp.copyOfRange(1, resp.size)
} catch (e: InterruptedException) { } catch (e: InterruptedException) {

View File

@@ -1,7 +1,7 @@
/* /*
* LibrePods - AirPods liberated from Apple's ecosystem * LibrePods - AirPods liberated from Apples ecosystem
* *
* Copyright (C) 2025 LibrePods Contributors * Copyright (C) 2025 LibrePods contributors
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published * it under the terms of the GNU Affero General Public License as published

View File

@@ -0,0 +1,102 @@
/*
* 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 Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.utils
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerId
import androidx.compose.ui.input.pointer.PointerInputChange
import androidx.compose.ui.input.pointer.PointerInputScope
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.util.fastFirstOrNull
suspend fun PointerInputScope.inspectDragGestures(
onDragStart: (down: PointerInputChange) -> Unit = {},
onDragEnd: (change: PointerInputChange) -> Unit = {},
onDragCancel: () -> Unit = {},
onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit
) {
awaitEachGesture {
val initialDown = awaitFirstDown(false, PointerEventPass.Initial)
val down = awaitFirstDown(false)
onDragStart(down)
onDrag(initialDown, Offset.Zero)
val upEvent =
drag(
pointerId = initialDown.id,
onDrag = { onDrag(it, it.positionChange()) }
)
if (upEvent == null) {
onDragCancel()
} else {
onDragEnd(upEvent)
}
}
}
private suspend inline fun AwaitPointerEventScope.drag(
pointerId: PointerId,
onDrag: (PointerInputChange) -> Unit
): PointerInputChange? {
val isPointerUp = currentEvent.changes.fastFirstOrNull { it.id == pointerId }?.pressed != true
if (isPointerUp) {
return null
}
var pointer = pointerId
while (true) {
val change = awaitDragOrUp(pointer) ?: return null
if (change.isConsumed) {
return null
}
if (change.changedToUpIgnoreConsumed()) {
return change
}
onDrag(change)
pointer = change.id
}
}
private suspend inline fun AwaitPointerEventScope.awaitDragOrUp(
pointerId: PointerId
): PointerInputChange? {
var pointer = pointerId
while (true) {
val event = awaitPointerEvent()
val dragEvent = event.changes.fastFirstOrNull { it.id == pointer } ?: return null
if (dragEvent.changedToUpIgnoreConsumed()) {
val otherDown = event.changes.fastFirstOrNull { it.pressed }
if (otherDown == null) {
return dragEvent
} else {
pointer = otherDown.id
}
} else {
val hasDragged = dragEvent.previousPosition != dragEvent.position
if (hasDragged) {
return dragEvent
}
}
}
}

View File

@@ -1,3 +1,21 @@
/*
* 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 Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:OptIn(ExperimentalEncodingApi::class) @file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.utils

View File

@@ -1,3 +1,21 @@
/*
* 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 Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("PrivatePropertyName") @file:Suppress("PrivatePropertyName")
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.utils

View File

@@ -1,3 +1,21 @@
/*
* 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 Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.utils
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow

View File

@@ -53,7 +53,6 @@ import android.widget.LinearLayout
import android.widget.ProgressBar import android.widget.ProgressBar
import android.widget.TextView import android.widget.TextView
import android.widget.VideoView import android.widget.VideoView
import androidx.core.content.ContextCompat.getString
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.dynamicanimation.animation.DynamicAnimation import androidx.dynamicanimation.animation.DynamicAnimation
import androidx.dynamicanimation.animation.SpringAnimation import androidx.dynamicanimation.animation.SpringAnimation

View File

@@ -1,5 +1,5 @@
/* /*
* LibrePods - AirPods liberated from Apple's ecosystem * LibrePods - AirPods liberated from Apples ecosystem
* *
* Copyright (C) 2025 LibrePods contributors * Copyright (C) 2025 LibrePods contributors
* *

View File

@@ -462,7 +462,8 @@ class RadareOffsetFinder(context: Context) {
// findAndSaveL2cuProcessCfgReqOffset(libraryPath, envSetup) // findAndSaveL2cuProcessCfgReqOffset(libraryPath, envSetup)
// findAndSaveL2cCsmConfigOffset(libraryPath, envSetup) // findAndSaveL2cCsmConfigOffset(libraryPath, envSetup)
// findAndSaveL2cuSendPeerInfoReqOffset(libraryPath, envSetup) // findAndSaveL2cuSendPeerInfoReqOffset(libraryPath, envSetup)
findAndSaveSdpOffset(libraryPath, envSetup)
// findAndSaveSdpOffset(libraryPath, envSetup) Should not be run by default, only when user asks for it.
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to find function offset", e) Log.e(TAG, "Failed to find function offset", e)

View File

@@ -1,6 +1,23 @@
/*
* 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 Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License.
*
* 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 Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package me.kavishdevar.librepods.utils package me.kavishdevar.librepods.utils
import android.util.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -10,8 +27,6 @@ import java.io.IOException
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
private const val TAG = "TransparencyUtils"
data class TransparencySettings( data class TransparencySettings(
val enabled: Boolean, val enabled: Boolean,
val leftEQ: FloatArray, val leftEQ: FloatArray,
@@ -67,9 +82,8 @@ data class TransparencySettings(
} }
} }
fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings? { fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings {
val settingsData = data val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
val buffer = ByteBuffer.wrap(settingsData).order(ByteOrder.LITTLE_ENDIAN)
val enabled = buffer.float val enabled = buffer.float

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -23,8 +23,6 @@
<string name="conversational_awareness_description">当你开始与他人交谈时,会降低媒体音量并减少背景噪音。</string> <string name="conversational_awareness_description">当你开始与他人交谈时,会降低媒体音量并减少背景噪音。</string>
<string name="personalized_volume">个性化音量</string> <string name="personalized_volume">个性化音量</string>
<string name="personalized_volume_description">根据环境自动调整媒体音量。</string> <string name="personalized_volume_description">根据环境自动调整媒体音量。</string>
<string name="less_noise">减少噪音</string>
<string name="more_noise">增加噪音</string>
<string name="noise_cancellation_single_airpod">单只 AirPod 主动降噪</string> <string name="noise_cancellation_single_airpod">单只 AirPod 主动降噪</string>
<string name="noise_cancellation_single_airpod_description">仅佩戴一只 AirPod 时也能开启主动降噪。</string> <string name="noise_cancellation_single_airpod_description">仅佩戴一只 AirPod 时也能开启主动降噪。</string>
<string name="volume_control">音量控制</string> <string name="volume_control">音量控制</string>

View File

@@ -8,6 +8,7 @@
<string name="tone_volume">Tone Volume</string> <string name="tone_volume">Tone Volume</string>
<string name="audio">Audio</string> <string name="audio">Audio</string>
<string name="adaptive_audio">Adaptive Audio</string> <string name="adaptive_audio">Adaptive Audio</string>
<string name="customize_adaptive_audio">Customize Adaptive Audio</string>
<string name="adaptive_audio_description">Adaptive audio dynamically responds to your environment and cancels or allows external noise. You can customize Adaptive Audio to allow more or less noise.</string> <string name="adaptive_audio_description">Adaptive audio dynamically responds to your environment and cancels or allows external noise. You can customize Adaptive Audio to allow more or less noise.</string>
<string name="buds">Buds</string> <string name="buds">Buds</string>
<string name="case_alt">Case</string> <string name="case_alt">Case</string>
@@ -26,14 +27,12 @@
<string name="conversational_awareness_description">Lowers media volume and reduces background noise when you start speaking to other people.</string> <string name="conversational_awareness_description">Lowers media volume and reduces background noise when you start speaking to other people.</string>
<string name="personalized_volume">Personalized Volume</string> <string name="personalized_volume">Personalized Volume</string>
<string name="personalized_volume_description">Adjusts the volume of media in response to your environment.</string> <string name="personalized_volume_description">Adjusts the volume of media in response to your environment.</string>
<string name="less_noise">Less Noise</string>
<string name="more_noise">More Noise</string>
<string name="noise_cancellation_single_airpod">Noise Cancellation with Single AirPod</string> <string name="noise_cancellation_single_airpod">Noise Cancellation with Single AirPod</string>
<string name="noise_cancellation_single_airpod_description">Allow AirPods to be put in noise cancellation mode when only one AirPods is in your ear.</string> <string name="noise_cancellation_single_airpod_description">Allow AirPods to be put in noise cancellation mode when only one AirPods is in your ear.</string>
<string name="volume_control">Volume Control</string> <string name="volume_control">Volume Control</string>
<string name="volume_control_description">Adjust the volume by swiping up or down on the sensor located on the AirPods Pro stem.</string> <string name="volume_control_description">Adjust the volume by swiping up or down on the sensor located on the AirPods Pro stem.</string>
<string name="airpods_not_connected">AirPods not connected</string> <string name="airpods_not_connected">AirPods not connected</string>
<string name="airpods_not_connected_description">Please connect your AirPods to access settings. If you\'re stuck here, then try reopening the app again after closing it from the recents.\n(DO NOT KILL THE APP!)</string> <string name="airpods_not_connected_description">Please connect your AirPods to access settings.</string>
<string name="back">Back</string> <string name="back">Back</string>
<string name="app_settings">Customizations</string> <string name="app_settings">Customizations</string>
<string name="relative_conversational_awareness_volume">Relative volume</string> <string name="relative_conversational_awareness_volume">Relative volume</string>
@@ -135,4 +134,40 @@
<string name="media_assist_description">AirPods Pro can use the results of a hearing test to make adjustments that improve the clarity of music, video, and calls.</string> <string name="media_assist_description">AirPods Pro can use the results of a hearing test to make adjustments that improve the clarity of music, video, and calls.</string>
<string name="adjust_media">Adjust Music and Video</string> <string name="adjust_media">Adjust Music and Video</string>
<string name="adjust_calls">Adjust Calls</string> <string name="adjust_calls">Adjust Calls</string>
<string name="widget">Widget</string>
<string name="show_phone_battery_in_widget">Show phone battery in widget</string>
<string name="show_phone_battery_in_widget_description">Display your phone\'s battery level in the widget alongside AirPods battery</string>
<string name="connection_mode">Connection Mode</string>
<string name="ble_only_mode">BLE Only Mode</string>
<string name="ble_only_mode_description">Only use Bluetooth Low Energy for battery data and ear detection. Disables advanced features requiring L2CAP connection.</string>
<string name="conversational_awareness_volume">Conversational Awareness Volume</string>
<string name="quick_settings_tile">Quick Settings Tile</string>
<string name="open_dialog_for_controlling">Open dialog for controlling</string>
<string name="open_dialog_for_controlling_description">If disabled, clicking on the QS will cycle through modes. If enabled, it will show a dialog for controlling noise control mode and conversational awareness</string>
<string name="disconnect_when_not_wearing">Disconnect AirPods when not wearing</string>
<string name="disconnect_when_not_wearing_description">You will still be able to control them with the app - this just disconnects the audio.</string>
<string name="advanced_options">Advanced Options</string>
<string name="set_identity_resolving_key">Set Identity Resolving Key (IRK)</string>
<string name="set_identity_resolving_key_description">Manually set the IRK value used for resolving BLE random addresses</string>
<string name="set_encryption_key">Set Encryption Key</string>
<string name="set_encryption_key_description">Manually set the ENC_KEY value used for decrypting BLE advertisements</string>
<string name="use_alternate_head_tracking_packets">Use alternate head tracking packets</string>
<string name="use_alternate_head_tracking_packets_description">Enable this if head tracking doesn\'t work for you. This sends different data to AirPods for requesting/stopping head tracking data.</string>
<string name="act_as_an_apple_device">Act as an Apple device</string>
<string name="act_as_an_apple_device_description">Enables multi-device connectivity and Accessibility features like customizing transparency mode (amplification, tone, ambient noise reduction, conversation boost, and EQ)</string>
<string name="act_as_an_apple_device_warning">Might be unstable!! A maximum of two devices can be connected to your AirPods. If you are using with an Apple device like an iPad or Mac, then please connect that device first and then your Android.</string>
<string name="reset_hook_offset">Reset Hook Offset</string>
<string name="reset_hook_offset_description">This will clear the current hook offset and require you to go through the setup process again. Are you sure you want to continue?</string>
<string name="reset">Reset</string>
<string name="hook_offset_reset_success">Hook offset has been reset. Redirecting to setup...</string>
<string name="hook_offset_reset_failure">Failed to reset hook offset</string>
<string name="irk_set_success">IRK has been set successfully</string>
<string name="encryption_key_set_success">Encryption key has been set successfully</string>
<string name="irk_hex_value">IRK Hex Value</string>
<string name="enc_key_hex_value">ENC_KEY Hex Value</string>
<string name="enter_irk_hex">Enter 16-byte IRK as hex string (32 characters):</string>
<string name="enter_enc_key_hex">Enter 16-byte ENC_KEY as hex string (32 characters):</string>
<string name="must_be_32_hex_chars">Must be exactly 32 hex characters</string>
<string name="error_converting_hex">Error converting hex:</string>
<string name="found_offset_restart_bluetooth">Found offset please restart the Bluetooth process</string>
</resources> </resources>