diff --git a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt
index 9ffc793..9696eb9 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt
@@ -109,16 +109,17 @@ import com.google.accompanist.permissions.rememberMultiplePermissionsState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.constants.AirPodsNotifications
import me.kavishdevar.librepods.screens.AccessibilitySettingsScreen
+import me.kavishdevar.librepods.screens.AdaptiveStrengthScreen
import me.kavishdevar.librepods.screens.AirPodsSettingsScreen
import me.kavishdevar.librepods.screens.AppSettingsScreen
import me.kavishdevar.librepods.screens.DebugScreen
import me.kavishdevar.librepods.screens.HeadTrackingScreen
-import me.kavishdevar.librepods.screens.HearingAidScreen
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.Onboarding
import me.kavishdevar.librepods.screens.RenameScreen
+import me.kavishdevar.librepods.screens.TransparencySettingsScreen
import me.kavishdevar.librepods.screens.TroubleshootingScreen
import me.kavishdevar.librepods.services.AirPodsService
import me.kavishdevar.librepods.ui.theme.LibrePodsTheme
@@ -201,15 +202,12 @@ class MainActivity : ComponentActivity() {
if (data != null && data.scheme == "librepods") {
when (data.host) {
"add-magic-keys" -> {
- // Extract query parameters
val queryParams = data.queryParameterNames
queryParams.forEach { param ->
val value = data.getQueryParameter(param)
- // Handle your parameters here
Log.d("LibrePods", "Parameter: $param = $value")
}
- // Process the magic keys addition
handleAddMagicKeys(data)
}
}
@@ -369,7 +367,7 @@ fun Main() {
name = navBackStackEntry.arguments?.getString("bud")!!
)
}
- composable("rename") { navBackStackEntry ->
+ composable("rename") {
RenameScreen(navController)
}
composable("app_settings") {
@@ -396,6 +394,9 @@ fun Main() {
composable("hearing_aid_adjustments") {
HearingAidAdjustmentsScreen(navController)
}
+ composable("adaptive_strength") {
+ AdaptiveStrengthScreen(navController)
+ }
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt b/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt
index b30c3ed..bd46412 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt
@@ -1,3 +1,21 @@
+/*
+ * LibrePods - AirPods liberated from Apple’s ecosystem
+ *
+ * Copyright (C) 2025 LibrePods contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU 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 .
+ */
+
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt
index 059dc10..0a355d9 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/AudioSettings.kt
@@ -20,19 +20,17 @@
package me.kavishdevar.librepods.composables
-import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Text
import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
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.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.navigation.NavController
+import androidx.navigation.compose.rememberNavController
import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.services.ServiceManager
+import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
-fun AudioSettings() {
+fun AudioSettings(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
@@ -63,12 +63,18 @@ fun AudioSettings() {
Column(
modifier = Modifier
+ .clip(RoundedCornerShape(14.dp))
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.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(
thickness = 1.5.dp,
color = Color(0x40888888),
@@ -76,7 +82,12 @@ fun AudioSettings() {
.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(
thickness = 1.5.dp,
color = Color(0x40888888),
@@ -92,39 +103,17 @@ fun AudioSettings() {
.padding(start = 12.dp, end = 0.dp)
)
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 8.dp, vertical = 10.dp)
- ) {
- 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()
- }
+ NavigationButton(
+ to = "adaptive_strength",
+ name = stringResource(R.string.adaptive_audio),
+ navController = navController,
+ independent = false
+ )
}
}
@Preview
@Composable
fun AudioSettingsPreview() {
- AudioSettings()
+ AudioSettings(rememberNavController())
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AutomaticConnectionSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/AutomaticConnectionSwitch.kt
index 994da45..04a9adb 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/AutomaticConnectionSwitch.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/AutomaticConnectionSwitch.kt
@@ -20,6 +20,7 @@
package me.kavishdevar.librepods.composables
+import android.content.Context.MODE_PRIVATE
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
@@ -35,8 +36,8 @@ 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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -45,7 +46,6 @@ 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 android.content.Context.MODE_PRIVATE
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -59,9 +59,9 @@ import kotlin.io.encoding.ExperimentalEncodingApi
fun AutomaticConnectionSwitch() {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
val service = ServiceManager.getService()!!
-
- val shared_preference_key = "automatic_connection_ctrl_cmd"
-
+
+ val sharedPreferenceKey = "automatic_connection_ctrl_cmd"
+
val automaticConnectionEnabledValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
@@ -71,7 +71,7 @@ fun AutomaticConnectionSwitch() {
if (automaticConnectionEnabledValue != null) {
automaticConnectionEnabledValue == 1.toByte()
} else {
- sharedPreferences.getBoolean(shared_preference_key, false)
+ sharedPreferences.getBoolean(sharedPreferenceKey, false)
}
)
}
@@ -83,9 +83,9 @@ fun AutomaticConnectionSwitch() {
enabled
)
// todo: send other connected devices smartAudioRoutingDisabled or something, check packets again.
-
+
sharedPreferences.edit()
- .putBoolean(shared_preference_key, enabled)
+ .putBoolean(sharedPreferenceKey, enabled)
.apply()
}
@@ -95,14 +95,14 @@ fun AutomaticConnectionSwitch() {
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
val enabled = newValue == 1.toByte()
automaticConnectionEnabled = enabled
-
+
sharedPreferences.edit()
- .putBoolean(shared_preference_key, enabled)
+ .putBoolean(sharedPreferenceKey, enabled)
.apply()
}
}
}
-
+
LaunchedEffect(Unit) {
service.aacpManager.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG,
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryIndicator.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryIndicator.kt
index 130f71a..8aba9a0 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryIndicator.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryIndicator.kt
@@ -1,17 +1,17 @@
/*
* LibrePods - AirPods liberated from Apple’s ecosystem
- *
+ *
* Copyright (C) 2025 LibrePods contributors
- *
+ *
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU 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 .
*/
@@ -19,31 +19,28 @@
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.border
-import androidx.compose.foundation.layout.Arrangement
+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.fillMaxHeight
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.MaterialTheme
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
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.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.StrokeCap
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -51,85 +48,78 @@ import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
@Composable
-fun BatteryIndicator(batteryPercentage: Int, charging: Boolean = false) {
- val batteryOutlineColor = Color(0xFFBFBFBF)
- val batteryFillColor = if (batteryPercentage > 30) Color(0xFF30D158) else Color(0xFFFC3C3C)
- val batteryTextColor = MaterialTheme.colorScheme.onSurface
+fun BatteryIndicator(
+ batteryPercentage: Int,
+ charging: Boolean = false,
+ 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 batteryHeight = 15.dp
- val batteryCornerRadius = 4.dp
- val tipWidth = 5.dp
- val tipHeight = batteryHeight * 0.375f
+ val initialScale = if (previousCharging) 1f else 0f
+ val scaleAnim = remember { Animatable(initialScale) }
+ val targetScale = if (charging) 1f else 0f
- val animatedFillWidth by animateFloatAsState(targetValue = batteryPercentage / 100f)
- val animatedScale by animateFloatAsState(targetValue = if (charging) 1.2f else 1f)
+ LaunchedEffect(previousCharging, charging) {
+ scaleAnim.animateTo(targetScale, animationSpec = tween(durationMillis = 250))
+ }
Column(
+ modifier = Modifier
+ .padding(12.dp)
+ .background(backgroundColor), // just for haze to work
horizontalAlignment = Alignment.CenterHorizontally
) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(0.dp),
- modifier = Modifier.padding(bottom = 4.dp)
+ Box(
+ modifier = Modifier.padding(bottom = 4.dp),
+ contentAlignment = Alignment.Center
) {
- Box(
- modifier = Modifier
- .width(batteryWidth)
- .height(batteryHeight)
- ) {
- Box (
- modifier = Modifier
- .fillMaxSize()
- .border(1.dp, batteryOutlineColor, RoundedCornerShape(batteryCornerRadius))
- )
- Box(
- modifier = Modifier
- .fillMaxHeight()
- .padding(2.dp)
- .width(batteryWidth * animatedFillWidth)
- .background(batteryFillColor, RoundedCornerShape(2.dp))
- )
- if (charging) {
- Text(
- 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
- )
- )
+ CircularProgressIndicator(
+ progress = { batteryPercentage / 100f },
+ modifier = Modifier.size(40.dp),
+ color = batteryFillColor,
+ gapSize = 0.dp,
+ strokeCap = StrokeCap.Round,
+ strokeWidth = 2.dp,
+ trackColor = if (isDarkTheme) Color(0xFF0E0E0F) else Color(0xFFE3E3E8)
+ )
+
+ Text(
+ text = "\uDBC0\uDEE6",
+ style = TextStyle(
+ fontSize = 12.sp,
+ fontFamily = FontFamily(Font(R.font.sf_pro)),
+ color = batteryFillColor,
+ textAlign = TextAlign.Center
+ ),
+ modifier = Modifier.scale(scaleAnim.value)
)
}
Text(
- text = "$batteryPercentage%",
+ text = "$prefix $batteryPercentage%",
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
fun BatteryIndicatorPreview() {
- BatteryIndicator(batteryPercentage = 48, charging = true)
-}
\ No newline at end of file
+ val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
+ Box(
+ modifier = Modifier.background(bg)
+ ) {
+ BatteryIndicator(batteryPercentage = 24, charging = true, prefix = "\uDBC6\uDCE5", previousCharging = false)
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt
index 4f90662..a993c17 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/BatteryView.kt
@@ -24,14 +24,19 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
+import android.content.res.Configuration
import android.os.Build
import android.util.Log
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.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.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -39,7 +44,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
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.platform.LocalContext
import androidx.compose.ui.res.imageResource
@@ -57,6 +62,9 @@ import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun BatteryView(service: AirPodsService, preview: Boolean = false) {
val batteryStatus = remember { mutableStateOf>(listOf()) }
+
+ val previousBatteryStatus = remember { mutableStateOf>(listOf()) }
+
@Suppress("DEPRECATION") val batteryReceiver = remember {
object : BroadcastReceiver() {
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()
if (preview) {
batteryStatus.value = listOf(
- Battery(BatteryComponent.LEFT, 100, BatteryStatus.CHARGING),
- Battery(BatteryComponent.RIGHT, 50, BatteryStatus.NOT_CHARGING),
- Battery(BatteryComponent.CASE, 5, BatteryStatus.CHARGING)
+ Battery(BatteryComponent.LEFT, 100, BatteryStatus.NOT_CHARGING),
+ Battery(BatteryComponent.RIGHT, 94, BatteryStatus.NOT_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 {
Column (
modifier = Modifier
@@ -117,43 +146,48 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) {
contentDescription = stringResource(R.string.buds),
modifier = Modifier
.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 {
+ singleDisplayed.value = false
Row (
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
-// if (left?.status != BatteryStatus.DISCONNECTED) {
- if (left?.level != null) {
+ if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) {
BatteryIndicator(
- left.level,
- left.status == BatteryStatus.CHARGING
+ leftLevel,
+ leftCharging,
+ "\uDBC6\uDCE5",
+ previousCharging = prevLeftCharging
)
}
-// }
-// if (left?.status != BatteryStatus.DISCONNECTED && right?.status != BatteryStatus.DISCONNECTED) {
- if (left?.level != null && right?.level != null)
+ if (leftLevel > 0 && rightLevel > 0)
{
- Spacer(modifier = Modifier.width(16.dp))
+ Spacer(modifier = Modifier.width(4.dp))
}
-// }
-// if (right?.status != BatteryStatus.DISCONNECTED) {
- if (right?.level != null)
+ if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED)
{
BatteryIndicator(
- right.level,
- right.status == BatteryStatus.CHARGING
+ rightLevel,
+ rightCharging,
+ "\uDBC6\uDCE8",
+ previousCharging = prevRightCharging
)
}
-// }
}
}
}
@@ -163,26 +197,32 @@ fun BatteryView(service: AirPodsService, preview: Boolean = false) {
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
- val case = batteryStatus.value.find { it.component == BatteryComponent.CASE }
-
Image(
bitmap = ImageBitmap.imageResource(R.drawable.pro_2_case),
contentDescription = stringResource(R.string.case_alt),
modifier = Modifier
.fillMaxWidth()
- .scale(1.25f)
+ .padding(12.dp)
)
-// if (case?.status != BatteryStatus.DISCONNECTED) {
- if (case?.level != null) {
- BatteryIndicator(case.level, case.status == BatteryStatus.CHARGING)
+ if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) {
+ BatteryIndicator(
+ caseLevel,
+ caseCharging,
+ prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else "",
+ previousCharging = prevCaseCharging
+ )
}
-// }
}
}
}
-@Preview
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun BatteryViewPreview() {
- BatteryView(AirPodsService(), preview = true)
+ val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
+ Box(
+ modifier = Modifier.background(bg)
+ ) {
+ BatteryView(AirPodsService(), preview = true)
+ }
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConfirmationDialog.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConfirmationDialog.kt
index c3310c5..18139ec 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConfirmationDialog.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConfirmationDialog.kt
@@ -1,7 +1,36 @@
+/*
+ * LibrePods - AirPods liberated from Apple’s ecosystem
+ *
+ * Copyright (C) 2025 LibrePods contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU 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 .
+ */
+
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.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -17,72 +46,207 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.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.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.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.Backdrop
import com.kyant.backdrop.drawBackdrop
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.highlight.Highlight
+import com.kyant.backdrop.highlight.HighlightStyle
+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 ConfirmationDialog(
showDialog: MutableState,
title: String,
message: String,
- confirmText: String = "Enable",
+ confirmText: String = "Ok",
dismissText: String = "Cancel",
onConfirm: () -> Unit,
onDismiss: () -> Unit = { showDialog.value = false },
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 contentColor = if (isLightTheme) Color.Black else Color.White
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 dimColor = if (isLightTheme) Color(0xFF29293A).copy(0.23f) else Color(0xFF121212).copy(0.56f)
+ val containerColor = if (isLightTheme) Color(0xFFFFFFFF).copy(0.6f) else Color(0xFF101010).copy(0.6f)
Box(
Modifier
- .background(dimColor)
.fillMaxSize()
- .clickable(onClick = onDismiss)
+ .clickable(onClick = onDismiss, indication = null, interactionSource = remember { MutableInteractionSource() } )
) {
Box(
Modifier
.align(Alignment.Center)
+ .clickable(onClick = {}, indication = null, interactionSource = remember { MutableInteractionSource() } )
.drawBackdrop(
backdrop,
{ RoundedCornerShape(48f.dp) },
-// highlight = { Highlight { HighlightStyle.Solid } },
- onDrawSurface = { drawRect(containerColor) }
- ) {
- colorFilter(
- brightness = if (isLightTheme) 0.2f else 0.1f,
- saturation = 1.5f
- )
- blur(if (isLightTheme) 16f.dp.toPx() else 8f.dp.toPx())
- refraction(24f.dp.toPx(), 48f.dp.toPx(), true)
- }
+ highlight = { Highlight { HighlightStyle.Solid } },
+ onDrawSurface = { drawRect(containerColor) },
+ effects = {
+ colorControls(
+ brightness = if (isLightTheme) 0.4f else 0.2f,
+ saturation = 1.5f
+ )
+ blur(if (isLightTheme) 16f.dp.toPx() else 8f.dp.toPx())
+ 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)
.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) {
Spacer(modifier = Modifier.height(28.dp))
Text(
title,
style = TextStyle(
- fontSize = 16.sp,
+ fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = contentColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
@@ -103,35 +267,50 @@ fun ConfirmationDialog(
Row(
Modifier
- .padding(24.dp, 12.dp, 24.dp, 24.dp)
+ .padding(horizontal = 12.dp)
+ .padding(top = 12.dp, bottom = 24.dp)
.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(16.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
- Box(
- Modifier
- .clip(RoundedCornerShape(50.dp))
- .background(containerColor.copy(0.2f))
- .clickable(onClick = onDismiss)
- .height(48.dp)
- .weight(1f)
- .padding(horizontal = 16.dp),
- contentAlignment = Alignment.Center
+ // Box(
+ // Modifier
+ // .clip(RoundedCornerShape(50.dp))
+ // .background(containerColor.copy(0.2f))
+ // .clickable(onClick = onDismiss)
+ // .height(48.dp)
+ // .weight(1f)
+ // .padding(horizontal = 16.dp),
+ // 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(
dismissText,
style = TextStyle(contentColor, 16.sp)
)
}
- Box(
- Modifier
- .clip(RoundedCornerShape(50.dp))
- .background(accentColor)
- .clickable(onClick = onConfirm)
- .height(48.dp)
- .weight(1f)
- .padding(horizontal = 16.dp),
- contentAlignment = Alignment.Center
+ // Box(
+ // Modifier
+ // .clip(RoundedCornerShape(50.dp))
+ // .background(accentColor)
+ // .clickable(onClick = onConfirm)
+ // .height(48.dp)
+ // .weight(1f)
+ // .padding(horizontal = 16.dp),
+ // contentAlignment = Alignment.Center
+ // ) {
+ StyledButton(
+ onClick = onConfirm,
+ backdrop = backdrop,
+ surfaceColor = accentColor,
+ modifier = Modifier.weight(1f),
+ isInteractive = false
) {
Text(
confirmText,
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConnectionSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConnectionSettings.kt
index 28f796e..d0e386c 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConnectionSettings.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConnectionSettings.kt
@@ -20,34 +20,23 @@
package me.kavishdevar.librepods.composables
-import android.util.Log
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Text
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
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.unit.dp
-import androidx.compose.ui.unit.sp
-import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
@Composable
fun ConnectionSettings() {
val isDarkTheme = isSystemInDarkTheme()
- val textColor = if (isDarkTheme) Color.White else Color.Black
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Column(
@@ -72,4 +61,4 @@ fun ConnectionSettings() {
@Composable
fun ConnectionSettingsPreview() {
ConnectionSettings()
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt
deleted file mode 100644
index 7492a62..0000000
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt
+++ /dev/null
@@ -1,161 +0,0 @@
-/*
- * LibrePods - AirPods liberated from Apple’s ecosystem
- *
- * Copyright (C) 2025 LibrePods contributors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU 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 .
- */
-
-@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()
-}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/EarDetectionSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/EarDetectionSwitch.kt
index 37ef7c8..52eafc1 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/EarDetectionSwitch.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/EarDetectionSwitch.kt
@@ -20,6 +20,7 @@
package me.kavishdevar.librepods.composables
+import android.content.Context.MODE_PRIVATE
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.unit.dp
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.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
@@ -60,8 +57,8 @@ import kotlin.io.encoding.ExperimentalEncodingApi
fun EarDetectionSwitch() {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
val service = ServiceManager.getService()!!
-
- val shared_preference_key = "automatic_ear_detection"
+
+ val sharedPreferenceKey = "automatic_ear_detection"
val earDetectionEnabledValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG
@@ -72,7 +69,7 @@ fun EarDetectionSwitch() {
if (earDetectionEnabledValue != null) {
earDetectionEnabledValue == 1.toByte()
} else {
- sharedPreferences.getBoolean(shared_preference_key, false)
+ sharedPreferences.getBoolean(sharedPreferenceKey, false)
}
)
}
@@ -84,9 +81,9 @@ fun EarDetectionSwitch() {
enabled
)
service.setEarDetection(enabled)
-
+
sharedPreferences.edit()
- .putBoolean(shared_preference_key, enabled)
+ .putBoolean(sharedPreferenceKey, enabled)
.apply()
}
@@ -96,14 +93,14 @@ fun EarDetectionSwitch() {
val newValue = controlCommand.value.takeIf { it.isNotEmpty() }?.get(0)
val enabled = newValue == 1.toByte()
earDetectionEnabled = enabled
-
+
sharedPreferences.edit()
- .putBoolean(shared_preference_key, enabled)
+ .putBoolean(sharedPreferenceKey, enabled)
.apply()
}
}
}
-
+
LaunchedEffect(Unit) {
service.aacpManager.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.EAR_DETECTION_CONFIG,
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt
deleted file mode 100644
index 25e142c..0000000
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt
+++ /dev/null
@@ -1,189 +0,0 @@
-/*
- * LibrePods - AirPods liberated from Apple’s ecosystem
- *
- * Copyright (C) 2025 LibrePods contributors
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU 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 .
- */
-
-@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)
-}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt
index ba5f6fd..5d31963 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/LoudSoundReductionSwitch.kt
@@ -51,7 +51,6 @@ import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
-import me.kavishdevar.librepods.utils.ATTManager
import me.kavishdevar.librepods.utils.ATTHandles
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -62,7 +61,7 @@ fun LoudSoundReductionSwitch() {
false
)
}
- val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
+ val attManager = ServiceManager.getService()?.attManager ?: return
LaunchedEffect(Unit) {
attManager.enableNotifications(ATTHandles.LOUD_SOUND_REDUCTION)
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/MicrophoneSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/MicrophoneSettings.kt
index b87d74f..4f88af0 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/MicrophoneSettings.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/MicrophoneSettings.kt
@@ -20,19 +20,10 @@
package me.kavishdevar.librepods.composables
-import android.annotation.SuppressLint
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.clickable
-import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures
-import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
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.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
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.Text
import androidx.compose.runtime.Composable
@@ -63,7 +48,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
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.platform.LocalDensity
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.unit.IntOffset
-import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
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.HazeTint
-import dev.chrisbanes.haze.hazeEffect
-import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.services.ServiceManager
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/NavigationButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/NavigationButton.kt
index 3b4bbf3..0baa894 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/NavigationButton.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/NavigationButton.kt
@@ -50,14 +50,14 @@ import androidx.navigation.NavController
@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()
var backgroundColor by remember { mutableStateOf(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)) }
val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500))
Row(
modifier = Modifier
- .background(animatedBackgroundColor, RoundedCornerShape(14.dp))
+ .background(animatedBackgroundColor, RoundedCornerShape(if (independent) 14.dp else 0.dp))
.height(55.dp)
.pointerInput(Unit) {
detectTapGestures(
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt
index afb1796..648e610 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/NoiseControlSettings.kt
@@ -189,6 +189,7 @@ fun NoiseControlSettings(
),
modifier = Modifier.padding(8.dp, bottom = 2.dp)
)
+ @Suppress("COMPOSE_APPLIER_CALL_MISMATCH")
BoxWithConstraints(
modifier = Modifier
.fillMaxWidth()
@@ -437,7 +438,7 @@ fun NoiseControlSettings(
}
}
-@Preview()
+@Preview
@Composable
fun NoiseControlSettingsPreview() {
NoiseControlSettings(AirPodsService())
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/PersonalizedVolumeSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/PersonalizedVolumeSwitch.kt
index 14bb876..ef72c92 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/PersonalizedVolumeSwitch.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/PersonalizedVolumeSwitch.kt
@@ -1,4 +1,4 @@
-/*
+ /*
* LibrePods - AirPods liberated from Apple’s ecosystem
*
* Copyright (C) 2025 LibrePods contributors
@@ -35,8 +35,8 @@ 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.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -53,10 +53,10 @@ import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
-@Composable
+ @Composable
fun PersonalizedVolumeSwitch() {
val service = ServiceManager.getService()!!
-
+
val adaptiveVolumeEnabledValue = service.aacpManager.controlCommandStatusList.find {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
@@ -83,7 +83,7 @@ fun PersonalizedVolumeSwitch() {
}
}
}
-
+
LaunchedEffect(Unit) {
service.aacpManager.registerControlCommandListener(
AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledButton.kt
new file mode 100644
index 0000000..ce68372
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledButton.kt
@@ -0,0 +1,284 @@
+/*
+ * LibrePods - AirPods liberated from Apple’s ecosystem
+ *
+ * Copyright (C) 2025 LibrePods contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU 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 .
+ */
+
+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
+ )
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledIconButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledIconButton.kt
new file mode 100644
index 0000000..4e94193
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledIconButton.kt
@@ -0,0 +1,258 @@
+/*
+ * LibrePods - AirPods liberated from Apple’s ecosystem
+ *
+ * Copyright (C) 2025 LibrePods contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU 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 .
+ */
+
+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))
+ )
+ )
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledScaffold.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledScaffold.kt
new file mode 100644
index 0000000..acf7085
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledScaffold.kt
@@ -0,0 +1,166 @@
+/*
+ * LibrePods - AirPods liberated from Apple’s ecosystem
+ *
+ * Copyright (C) 2025 LibrePods contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU 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 .
+ */
+
+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)
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSlider.kt
index 523eb2c..b0544cd 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSlider.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledSlider.kt
@@ -75,12 +75,12 @@ import androidx.compose.ui.util.fastCoerceIn
import androidx.compose.ui.util.fastRoundToInt
import androidx.compose.ui.util.lerp
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.effects.refractionWithDispersion
import com.kyant.backdrop.highlight.Highlight
-import com.kyant.backdrop.rememberBackdrop
-import com.kyant.backdrop.rememberCombinedBackdropDrawer
import com.kyant.backdrop.shadow.Shadow
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
@@ -88,18 +88,19 @@ import kotlin.math.roundToInt
@Composable
fun StyledSlider(
- label: String? = null, // New optional parameter for the label
+ label: String? = null,
mutableFloatState: MutableFloatState,
onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange,
- backdrop: Backdrop = rememberBackdrop(),
+ backdrop: Backdrop = rememberLayerBackdrop(),
snapPoints: List = emptyList(),
snapThreshold: Float = 0.05f,
startIcon: String? = null,
endIcon: String? = null,
startLabel: String? = null,
endLabel: String? = null,
- independent: Boolean = false
+ independent: Boolean = false,
+ description: String? = null
) {
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val isLightTheme = !isSystemInDarkTheme()
@@ -126,7 +127,7 @@ fun StyledSlider(
compositingStrategy = CompositingStrategy.Offscreen
}
- val sliderBackdrop = rememberBackdrop()
+ val sliderBackdrop = rememberLayerBackdrop()
val trackWidthState = remember { mutableFloatStateOf(0f) }
val trackPositionState = remember { mutableFloatStateOf(0f) }
val startIconWidthState = remember { mutableFloatStateOf(0f) }
@@ -137,8 +138,9 @@ fun StyledSlider(
Box(
Modifier.fillMaxWidth(if (startIcon == null && endIcon == null) 0.95f else 1f)
) {
- Box(Modifier
- .backdrop(sliderBackdrop)
+ Box(
+ Modifier
+ .layerBackdrop(sliderBackdrop)
.fillMaxWidth()) {
Column(
modifier = Modifier
@@ -188,7 +190,7 @@ fun StyledSlider(
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Normal,
- color = labelTextColor,
+ color = accentColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier
@@ -237,7 +239,7 @@ fun StyledSlider(
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Normal,
- color = labelTextColor,
+ color = accentColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier
@@ -291,7 +293,7 @@ fun StyledSlider(
}
)
.drawBackdrop(
- rememberCombinedBackdropDrawer(backdrop, sliderBackdrop),
+ rememberCombinedBackdrop(backdrop, sliderBackdrop),
{ RoundedCornerShape(28.dp) },
highlight = {
val progress = progressAnimation.value
@@ -299,8 +301,8 @@ fun StyledSlider(
},
shadow = {
Shadow(
- elevation = 4f.dp,
- color = Color.Black.copy(0.08f)
+ radius = 4f.dp,
+ color = Color.Black.copy(0.05f)
)
},
layer = {
@@ -337,10 +339,11 @@ fun StyledSlider(
drawLayer(innerShadowLayer)
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)
)
}
@@ -365,7 +368,7 @@ fun StyledSlider(
modifier = Modifier.padding(8.dp)
)
}
-
+
Box(
modifier = Modifier
.fillMaxWidth()
@@ -376,9 +379,24 @@ fun StyledSlider(
) {
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 {
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()
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledToggle.kt b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledToggle.kt
new file mode 100644
index 0000000..896c4a3
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/composables/StyledToggle.kt
@@ -0,0 +1,416 @@
+/*
+ * LibrePods - AirPods liberated from Apple’s ecosystem
+ *
+ * Copyright (C) 2025 LibrePods contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU 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 .
+ */
+
+@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 = 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
+ )
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/constants/Packets.kt b/android/app/src/main/java/me/kavishdevar/librepods/constants/Packets.kt
index 91f79f4..943f52b 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/constants/Packets.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/constants/Packets.kt
@@ -182,21 +182,31 @@ class AirPodsNotifications {
if (data.size != 22) {
return
}
- first = if (data[10].toInt() == BatteryStatus.DISCONNECTED) {
- Battery(first.component, first.level, data[10].toInt())
- } else {
- Battery(data[7].toInt(), data[9].toInt(), data[10].toInt())
- }
- second = if (data[15].toInt() == BatteryStatus.DISCONNECTED) {
- Battery(second.component, second.level, data[15].toInt())
- } else {
- Battery(data[12].toInt(), data[14].toInt(), data[15].toInt())
- }
- case = if (data[20].toInt() == BatteryStatus.DISCONNECTED && case.status != BatteryStatus.DISCONNECTED) {
- Battery(case.component, case.level, data[20].toInt())
- } else {
- Battery(data[17].toInt(), data[19].toInt(), data[20].toInt())
- }
+// first = if (data[10].toInt() == BatteryStatus.DISCONNECTED) {
+// Battery(first.component, first.level, data[10].toInt())
+// } else {
+// Battery(data[7].toInt(), data[9].toInt(), data[10].toInt())
+// }
+// second = if (data[15].toInt() == BatteryStatus.DISCONNECTED) {
+// Battery(second.component, second.level, data[15].toInt())
+// } else {
+// Battery(data[12].toInt(), data[14].toInt(), data[15].toInt())
+// }
+// case = if (data[20].toInt() == BatteryStatus.DISCONNECTED && case.status != BatteryStatus.DISCONNECTED) {
+// Battery(case.component, case.level, data[20].toInt())
+// } else {
+// 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 {
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt
index 95df836..e39798c 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AccessibilitySettingsScreen.kt
@@ -19,12 +19,10 @@
package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint
-import android.bluetooth.BluetoothDevice
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.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
@@ -44,35 +42,26 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
-import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
-import androidx.compose.material3.DropdownMenu
-import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
-import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
-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.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
-import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
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.draw.drawBehind
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
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.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.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.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -105,18 +90,15 @@ import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.LoudSoundReductionSwitch
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.SinglePodANCSwitch
-import me.kavishdevar.librepods.composables.StyledSlider
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.VolumeControlSwitch
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
-import me.kavishdevar.librepods.utils.ATTHandles
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
private var phoneMediaDebounceJob: Job? = null
@@ -130,9 +112,6 @@ private const val TAG = "AccessibilitySettings"
fun AccessibilitySettingsScreen(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
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 isSdpOffsetAvailable =
remember { mutableStateOf(RadareOffsetFinder.isSdpOffsetAvailable()) }
@@ -143,7 +122,7 @@ fun AccessibilitySettingsScreen(navController: NavController) {
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_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 {
@@ -171,125 +150,30 @@ fun AccessibilitySettingsScreen(navController: NavController) {
}
}
- Scaffold(
- containerColor = if (isSystemInDarkTheme()) Color(
- 0xFF000000
- ) else Color(
- 0xFFF2F2F7
- ),
- topBar = {
- 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
- )
+ StyledScaffold(
+ title = stringResource(R.string.accessibility),
+ navigationButton = {
+ StyledIconButton(
+ onClick = { navController.popBackStack() },
+ icon = "",
+ darkMode = isDarkTheme
)
},
- snackbarHost = { SnackbarHost(snackbarHostState) }
- ) { paddingValues ->
+ ) { spacerHeight, hazeState ->
Column(
modifier = Modifier
- .hazeSource(hazeState)
.fillMaxSize()
- .padding(paddingValues)
- .padding(horizontal = 16.dp)
- .verticalScroll(verticalScrollState),
+ .hazeSource(hazeState)
+ .verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
+ Spacer(modifier = Modifier.height(spacerHeight))
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 phoneEQEnabled = 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(
0.toByte() to "Default",
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) {
phoneMediaDebounceJob?.cancel()
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
@@ -541,7 +424,7 @@ fun AccessibilitySettingsScreen(navController: NavController) {
color = Color(0x40888888),
modifier = Modifier.padding(start = 12.dp, end = 0.dp)
)
-
+
DropdownMenuComponent(
label = stringResource(R.string.volume_swipe_speed),
options = listOf(
@@ -563,7 +446,7 @@ fun AccessibilitySettingsScreen(navController: NavController) {
)
}
- if (!hearingAidEnabled.value) {
+ if (!hearingAidEnabled.value&& isSdpOffsetAvailable.value) {
NavigationButton(
to = "transparency_customization",
name = stringResource(R.string.customize_transparency_mode),
@@ -881,22 +764,29 @@ fun AccessibilityToggle(
}
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))
- ),
+ Box ( // for some reason, haze and backdrop don't work for uncontained text
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
private fun DropdownMenuComponent(
label: String,
@@ -904,7 +794,8 @@ private fun DropdownMenuComponent(
selectedOption: String,
onOptionSelected: (String) -> Unit,
textColor: Color,
- hazeState: HazeState
+ hazeState: HazeState,
+ description: String? = null,
) {
val density = LocalDensity.current
val itemHeightPx = with(density) { 48.dp.toPx() }
@@ -1020,5 +911,21 @@ private fun DropdownMenuComponent(
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))
+ )
+ )
+ }
}
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/composables/AdaptiveStrengthSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AdaptiveStrengthScreen.kt
similarity index 53%
rename from android/app/src/main/java/me/kavishdevar/librepods/composables/AdaptiveStrengthSlider.kt
rename to android/app/src/main/java/me/kavishdevar/librepods/screens/AdaptiveStrengthScreen.kt
index 7e5c8a4..bd0bbcf 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/composables/AdaptiveStrengthSlider.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AdaptiveStrengthScreen.kt
@@ -16,40 +16,51 @@
* along with this program. If not, see .
*/
-@file:OptIn(ExperimentalEncodingApi::class)
-
-package me.kavishdevar.librepods.composables
+package me.kavishdevar.librepods.screens
+import android.annotation.SuppressLint
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.Spacer
+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.LaunchedEffect
import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableFloatStateOf
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.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.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.composables.StyledIconButton
+import me.kavishdevar.librepods.composables.StyledScaffold
+import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.io.encoding.ExperimentalEncodingApi
-import kotlin.math.roundToInt
+private var debounceJob: Job? = null
+
+@SuppressLint("DefaultLocale")
+@ExperimentalHazeMaterialsApi
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
-fun AdaptiveStrengthSlider() {
+fun AdaptiveStrengthScreen(navController: NavController) {
+ val isDarkTheme = isSystemInDarkTheme()
+
val sliderValue = remember { mutableFloatStateOf(0f) }
val service = ServiceManager.getService()!!
+
LaunchedEffect(sliderValue) {
val sliderValueFromAACP = service.aacpManager.controlCommandStatusList.find {
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)
- val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
- val labelTextColor = if (isDarkTheme) Color.White else Color.Black
-
- Column(
- modifier = Modifier
- .fillMaxWidth(),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- StyledSlider(
- mutableFloatState = sliderValue,
- onValueChange = {
- sliderValue.floatValue = snapIfClose(it, listOf(0f, 50f, 100f))
- },
- valueRange = 0f..100f,
- snapPoints = listOf(0f, 50f, 100f),
- startLabel = stringResource(R.string.less_noise),
- endLabel = stringResource(R.string.more_noise),
- independent = false
- )
+ StyledScaffold(
+ title = stringResource(R.string.customize_adaptive_audio),
+ navigationButton = {
+ StyledIconButton(
+ onClick = { navController.popBackStack() },
+ icon = "",
+ darkMode = isDarkTheme
+ )
+ }
+ ) { spacerHeight ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Spacer(modifier = Modifier.height(spacerHeight))
+ StyledSlider(
+ label = stringResource(R.string.customize_adaptive_audio).uppercase(),
+ 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, 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
-}
\ No newline at end of file
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt
index 56ec24f..5dd112d 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AirPodsSettingsScreen.kt
@@ -41,34 +41,19 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.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.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.Text
-import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableFloatStateOf
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.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
@@ -83,24 +68,26 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import androidx.navigation.compose.rememberNavController
-import dev.chrisbanes.haze.HazeEffectScope
-import dev.chrisbanes.haze.hazeEffect
+import com.kyant.backdrop.backdrops.rememberLayerBackdrop
+import com.kyant.backdrop.drawBackdrop
+import com.kyant.backdrop.highlight.Highlight
import dev.chrisbanes.haze.hazeSource
-import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
-import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.composables.AudioSettings
import me.kavishdevar.librepods.composables.BatteryView
import me.kavishdevar.librepods.composables.CallControlSettings
import me.kavishdevar.librepods.composables.ConnectionSettings
-import me.kavishdevar.librepods.composables.IndependentToggle
import me.kavishdevar.librepods.composables.MicrophoneSettings
import me.kavishdevar.librepods.composables.NameField
import me.kavishdevar.librepods.composables.NavigationButton
import me.kavishdevar.librepods.composables.NoiseControlSettings
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.services.AirPodsService
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 coroutineScope = rememberCoroutineScope()
@@ -153,12 +138,6 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
isRemotelyConnected = connected
}
- fun showSnackbar(message: String) {
- coroutineScope.launch {
- snackbarHostState.showSnackbar(message)
- }
- }
-
val context = LocalContext.current
val connectionReceiver = remember {
@@ -219,118 +198,38 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
}
}
}
-
- @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
- Scaffold(
- containerColor = if (isSystemInDarkTheme()) Color(
- 0xFF000000
- ) else Color(
- 0xFFF2F2F7
- ),
- 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",
- )
- }
- }
+ val darkMode = isSystemInDarkTheme()
+ StyledScaffold(
+ title = deviceName.text,
+ actionButtons = listOf {
+ StyledIconButton(
+ onClick = { navController.navigate("app_settings") },
+ icon = "",
+ darkMode = darkMode
)
},
- snackbarHost = { SnackbarHost(snackbarHostState) }
- ) { paddingValues ->
+ snackbarHostState = snackbarHostState
+ ) { spacerHeight, hazeState ->
if (isLocallyConnected || isRemotelyConnected) {
Column(
modifier = Modifier
- .hazeSource(hazeState)
.fillMaxSize()
- .padding(horizontal = 16.dp)
- .verticalScroll(
- state = verticalScrollState,
- enabled = true,
- )
+ .hazeSource(hazeState)
+ .verticalScroll(rememberScrollState())
) {
- Spacer(Modifier.height(75.dp))
+ Spacer(modifier = Modifier.height(spacerHeight))
LaunchedEffect(service) {
service.let {
- it.sendBroadcast(Intent(AirPodsNotifications.Companion.BATTERY_DATA).apply {
+ it.sendBroadcast(Intent(AirPodsNotifications.BATTERY_DATA).apply {
putParcelableArrayListExtra("data", ArrayList(it.getBattery()))
})
- it.sendBroadcast(Intent(AirPodsNotifications.Companion.ANC_DATA).apply {
+ it.sendBroadcast(Intent(AirPodsNotifications.ANC_DATA).apply {
putExtra("data", it.getANC())
})
}
}
- val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
-
- Spacer(modifier = Modifier.height(64.dp))
BatteryView(service = service)
-
Spacer(modifier = Modifier.height(32.dp))
// Show BLE-only mode indicator
@@ -372,7 +271,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
PressAndHoldSettings(navController = navController)
Spacer(modifier = Modifier.height(16.dp))
- AudioSettings()
+ AudioSettings(navController = navController)
Spacer(modifier = Modifier.height(16.dp))
ConnectionSettings()
@@ -381,11 +280,8 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
MicrophoneSettings(hazeState)
Spacer(modifier = Modifier.height(16.dp))
- IndependentToggle(
- name = stringResource(R.string.sleep_detection),
- service = service,
- sharedPreferences = sharedPreferences,
- default = false,
+ StyledToggle(
+ label = stringResource(R.string.sleep_detection),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG
)
@@ -396,11 +292,8 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
NavigationButton(to = "accessibility", "Accessibility", navController = navController)
Spacer(modifier = Modifier.height(16.dp))
- IndependentToggle(
- name = stringResource(R.string.off_listening_mode),
- service = service,
- sharedPreferences = sharedPreferences,
- default = false,
+ StyledToggle(
+ label = stringResource(R.string.off_listening_mode).uppercase(),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION,
description = stringResource(R.string.off_listening_mode_description)
)
@@ -415,19 +308,24 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
}
}
else {
+ val backdrop = rememberLayerBackdrop()
Column(
modifier = Modifier
.fillMaxSize()
- .padding(horizontal = 8.dp)
- .verticalScroll(
- state = verticalScrollState,
- enabled = true,
- ),
+ .drawBackdrop(
+ backdrop = rememberLayerBackdrop(),
+ exportedBackdrop = backdrop,
+ shape = { RoundedCornerShape(0.dp) },
+ highlight = {
+ Highlight.AmbientDefault.copy(alpha = 0f)
+ }
+ )
+ .padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
- text = "AirPods not connected",
+ text = stringResource(R.string.airpods_not_connected),
style = TextStyle(
fontSize = 24.sp,
fontWeight = FontWeight.Medium,
@@ -439,7 +337,7 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
)
Spacer(Modifier.height(24.dp))
Text(
- text = "Please connect your AirPods to access settings.",
+ text = stringResource(R.string.airpods_not_connected_description),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Light,
@@ -450,13 +348,9 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(32.dp))
- Button(
+ StyledButton(
onClick = { navController.navigate("troubleshooting") },
- shape = RoundedCornerShape(10.dp),
- colors = ButtonDefaults.buttonColors(
- containerColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFF2F2F7),
- contentColor = if (isSystemInDarkTheme()) Color.White else Color.Black,
- )
+ backdrop = backdrop
) {
Text(
text = "Troubleshoot Connection",
@@ -472,7 +366,6 @@ fun AirPodsSettingsScreen(dev: BluetoothDevice?, service: AirPodsService,
}
}
-
@Preview
@Composable
fun AirPodsSettingsScreenPreview() {
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt
index bbe332f..de81d46 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/AppSettingsScreen.kt
@@ -42,38 +42,31 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
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.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
-import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
-import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
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.draw.drawBehind
-import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
-import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
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.input.KeyboardCapitalization
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.sp
import androidx.core.content.edit
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.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
+import kotlinx.coroutines.launch
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.utils.AACPManager
import me.kavishdevar.librepods.utils.RadareOffsetFinder
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.roundToInt
-import androidx.compose.runtime.rememberCoroutineScope
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
@Composable
fun AppSettingsScreen(navController: NavController) {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
- val name = remember { mutableStateOf(sharedPreferences.getString("name", "") ?: "") }
+
val isDarkTheme = isSystemInDarkTheme()
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val scrollState = rememberScrollState()
- val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
- val hazeState = remember { HazeState() }
var showResetDialog by remember { mutableStateOf(false) }
var showIrkDialog by remember { mutableStateOf(false) }
@@ -134,6 +120,7 @@ fun AppSettingsScreen(navController: NavController) {
irkValue = decoded.joinToString("") { "%02x".format(it) }
} catch (e: Exception) {
irkValue = ""
+ e.printStackTrace()
}
}
@@ -143,6 +130,7 @@ fun AppSettingsScreen(navController: NavController) {
encKeyValue = decoded.joinToString("") { "%02x".format(it) }
} catch (e: Exception) {
encKeyValue = ""
+ e.printStackTrace()
}
}
}
@@ -198,8 +186,6 @@ fun AppSettingsScreen(navController: NavController) {
}
}
- var mDensity by remember { mutableFloatStateOf(0f) }
-
fun validateHexInput(input: String): Boolean {
val hexPattern = Regex("^[0-9a-fA-F]{32}$")
return hexPattern.matches(input)
@@ -210,84 +196,24 @@ fun AppSettingsScreen(navController: NavController) {
BackHandler(enabled = isProcessingSdp) {}
- Scaffold(
- modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
- 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.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
+ StyledScaffold(
+ title = stringResource(R.string.app_settings),
+ navigationButton = {
+ StyledIconButton(
+ onClick = { navController.popBackStack() },
+ icon = "",
+ darkMode = isDarkTheme
)
- },
- containerColor = if (isSystemInDarkTheme()) Color(0xFF000000)
- else Color(0xFFF2F2F7),
- ) { paddingValues ->
- Column (
+ }
+ ) { spacerHeight, hazeState ->
+ Column(
modifier = Modifier
.fillMaxSize()
- .padding(paddingValues)
- .padding(horizontal = 16.dp)
.verticalScroll(scrollState)
.hazeSource(state = hazeState)
) {
+ Spacer(modifier = Modifier.height(spacerHeight))
+
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
@@ -295,7 +221,7 @@ fun AppSettingsScreen(navController: NavController) {
Spacer(modifier = Modifier.height(8.dp))
Text(
- text = "Widget".uppercase(),
+ text = stringResource(R.string.widget).uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
@@ -335,13 +261,13 @@ fun AppSettingsScreen(navController: NavController) {
.padding(end = 4.dp)
) {
Text(
- text = "Show phone battery in widget",
+ text = stringResource(R.string.show_phone_battery_in_widget),
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
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,
color = textColor.copy(0.6f),
lineHeight = 16.sp,
@@ -359,7 +285,7 @@ fun AppSettingsScreen(navController: NavController) {
}
Text(
- text = "Connection Mode".uppercase(),
+ text = stringResource(R.string.connection_mode).uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
@@ -399,12 +325,12 @@ fun AppSettingsScreen(navController: NavController) {
.padding(end = 4.dp)
) {
Text(
- text = "BLE Only Mode",
+ text = stringResource(R.string.ble_only_mode),
fontSize = 16.sp,
color = textColor
)
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,
color = textColor.copy(0.6f),
lineHeight = 16.sp,
@@ -422,7 +348,7 @@ fun AppSettingsScreen(navController: NavController) {
}
Text(
- text = "Conversational Awareness".uppercase(),
+ text = stringResource(R.string.conversational_awareness).uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
@@ -539,7 +465,7 @@ fun AppSettingsScreen(navController: NavController) {
}
Text(
- text = "Conversational Awareness Volume",
+ text = stringResource(R.string.conversational_awareness_volume),
fontSize = 16.sp,
color = textColor,
modifier = Modifier.padding(top = 8.dp, bottom = 4.dp)
@@ -628,7 +554,7 @@ fun AppSettingsScreen(navController: NavController) {
}
Text(
- text = "Quick Settings Tile".uppercase(),
+ text = stringResource(R.string.quick_settings_tile).uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
@@ -672,15 +598,13 @@ fun AppSettingsScreen(navController: NavController) {
.padding(end = 4.dp)
) {
Text(
- text = "Open dialog for controlling",
+ text = stringResource(R.string.open_dialog_for_controlling),
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
- text = if (openDialogForControlling)
- "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",
+ text = stringResource(R.string.open_dialog_for_controlling_description),
fontSize = 14.sp,
color = textColor.copy(0.6f),
lineHeight = 16.sp,
@@ -697,7 +621,7 @@ fun AppSettingsScreen(navController: NavController) {
}
Text(
- text = "Ear Detection".uppercase(),
+ text = stringResource(R.string.ear_detection).uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
@@ -741,13 +665,13 @@ fun AppSettingsScreen(navController: NavController) {
.padding(end = 4.dp)
) {
Text(
- text = "Disconnect AirPods when not wearing",
+ text = stringResource(R.string.disconnect_when_not_wearing),
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
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,
color = textColor.copy(0.6f),
lineHeight = 16.sp,
@@ -1051,7 +975,7 @@ fun AppSettingsScreen(navController: NavController) {
}
Text(
- text = "Advanced Options".uppercase(),
+ text = stringResource(R.string.advanced_options).uppercase(),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Light,
@@ -1087,13 +1011,13 @@ fun AppSettingsScreen(navController: NavController) {
.padding(end = 4.dp)
) {
Text(
- text = "Set Identity Resolving Key (IRK)",
+ text = stringResource(R.string.set_identity_resolving_key),
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
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,
color = textColor.copy(0.6f),
lineHeight = 16.sp,
@@ -1116,13 +1040,13 @@ fun AppSettingsScreen(navController: NavController) {
.padding(end = 4.dp)
) {
Text(
- text = "Set Encryption Key",
+ text = stringResource(R.string.set_encryption_key),
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
Text(
- text = "Manually set the ENC_KEY value used for decrypting BLE advertisements",
+ text = stringResource(R.string.set_encryption_key_description),
fontSize = 14.sp,
color = textColor.copy(0.6f),
lineHeight = 16.sp,
@@ -1152,13 +1076,13 @@ fun AppSettingsScreen(navController: NavController) {
.padding(end = 4.dp)
) {
Text(
- text = "Use alternate head tracking packets",
+ text = stringResource(R.string.use_alternate_head_tracking_packets),
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
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,
color = textColor.copy(0.6f),
lineHeight = 16.sp,
@@ -1206,6 +1130,7 @@ fun AppSettingsScreen(navController: NavController) {
LaunchedEffect(Unit) {
actAsAppleDevice = RadareOffsetFinder.isSdpOffsetAvailable()
}
+ val restartBluetoothText = stringResource(R.string.found_offset_restart_bluetooth)
Row(
modifier = Modifier
@@ -1222,9 +1147,9 @@ fun AppSettingsScreen(navController: NavController) {
coroutineScope.launch {
if (newValue) {
val radareOffsetFinder = RadareOffsetFinder(context)
- val success = radareOffsetFinder.findSdpOffset() ?: false
+ val success = radareOffsetFinder.findSdpOffset()
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 {
RadareOffsetFinder.clearSdpOffset()
@@ -1242,13 +1167,13 @@ fun AppSettingsScreen(navController: NavController) {
.padding(end = 4.dp)
) {
Text(
- text = "Act as an Apple device",
+ text = stringResource(R.string.act_as_an_apple_device),
fontSize = 16.sp,
color = textColor
)
Spacer(modifier = Modifier.height(4.dp))
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,
color = textColor.copy(0.6f),
lineHeight = 16.sp,
@@ -1256,14 +1181,13 @@ fun AppSettingsScreen(navController: NavController) {
if (actAsAppleDevice) {
Spacer(modifier = Modifier.height(8.dp))
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,
color = MaterialTheme.colorScheme.error,
lineHeight = 14.sp,
)
}
}
-
StyledSwitch(
checked = actAsAppleDevice,
onCheckedChange = {
@@ -1273,9 +1197,9 @@ fun AppSettingsScreen(navController: NavController) {
coroutineScope.launch {
if (it) {
val radareOffsetFinder = RadareOffsetFinder(context)
- val success = radareOffsetFinder.findSdpOffset() ?: false
+ val success = radareOffsetFinder.findSdpOffset()
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 {
RadareOffsetFinder.clearSdpOffset()
@@ -1313,7 +1237,7 @@ fun AppSettingsScreen(navController: NavController) {
)
Spacer(modifier = Modifier.width(8.dp))
Text(
- text = "Reset Hook Offset",
+ text = stringResource(R.string.reset_hook_offset),
color = MaterialTheme.colorScheme.onErrorContainer,
style = TextStyle(
fontSize = 16.sp,
@@ -1338,17 +1262,19 @@ fun AppSettingsScreen(navController: NavController) {
},
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))
)
},
confirmButton = {
+ val successText = stringResource(R.string.hook_offset_reset_success)
+ val failureText = stringResource(R.string.hook_offset_reset_failure)
TextButton(
onClick = {
if (RadareOffsetFinder.clearHookOffsets()) {
Toast.makeText(
context,
- "Hook offset has been reset. Redirecting to setup...",
+ successText,
Toast.LENGTH_LONG
).show()
@@ -1358,7 +1284,7 @@ fun AppSettingsScreen(navController: NavController) {
} else {
Toast.makeText(
context,
- "Failed to reset hook offset",
+ failureText,
Toast.LENGTH_SHORT
).show()
}
@@ -1369,7 +1295,7 @@ fun AppSettingsScreen(navController: NavController) {
)
) {
Text(
- "Reset",
+ stringResource(R.string.reset),
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium
)
@@ -1394,7 +1320,7 @@ fun AppSettingsScreen(navController: NavController) {
onDismissRequest = { showIrkDialog = false },
title = {
Text(
- "Set Identity Resolving Key (IRK)",
+ stringResource(R.string.set_identity_resolving_key),
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium
)
@@ -1402,7 +1328,7 @@ fun AppSettingsScreen(navController: NavController) {
text = {
Column {
Text(
- "Enter 16-byte IRK as hex string (32 characters):",
+ stringResource(R.string.enter_irk_hex),
fontFamily = FontFamily(Font(R.font.sf_pro)),
modifier = Modifier.padding(bottom = 8.dp)
)
@@ -1425,14 +1351,16 @@ fun AppSettingsScreen(navController: NavController) {
),
supportingText = {
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 = {
+ val successText = stringResource(R.string.irk_set_success)
+ val errorText = stringResource(R.string.error_converting_hex)
TextButton(
onClick = {
if (!validateHexInput(irkValue)) {
@@ -1450,10 +1378,10 @@ fun AppSettingsScreen(navController: NavController) {
val base64Value = Base64.encode(hexBytes)
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
} 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 },
title = {
Text(
- "Set Encryption Key",
+ stringResource(R.string.set_encryption_key),
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Medium
)
@@ -1491,7 +1419,7 @@ fun AppSettingsScreen(navController: NavController) {
text = {
Column {
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)),
modifier = Modifier.padding(bottom = 8.dp)
)
@@ -1514,14 +1442,16 @@ fun AppSettingsScreen(navController: NavController) {
),
supportingText = {
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 = {
+ val successText = stringResource(R.string.encryption_key_set_success)
+ val errorText = stringResource(R.string.error_converting_hex)
TextButton(
onClick = {
if (!validateHexInput(encKeyValue)) {
@@ -1539,10 +1469,10 @@ fun AppSettingsScreen(navController: NavController) {
val base64Value = Base64.encode(hexBytes)
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
} catch (e: Exception) {
- encKeyError = "Error converting hex: ${e.message}"
+ encKeyError = errorText + " " + (e.message ?: "Unknown error")
}
}
) {
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt
index 94fff3c..cdacf70 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/DebugScreen.kt
@@ -29,10 +29,8 @@ import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
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.automirrored.filled.KeyboardArrowLeft
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.material3.Card
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.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
-import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
-import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
-import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
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.sp
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.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
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.isHeadTrackingData
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)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, ExperimentalFoundationApi::class)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter", "UnspecifiedRegisterReceiverFlag")
@Composable
fun DebugScreen(navController: NavController) {
- val hazeState = remember { HazeState() }
val context = LocalContext.current
val listState = rememberLazyListState()
- val scrollOffset by remember { derivedStateOf { listState.firstVisibleItemScrollOffset } }
val focusManager = LocalFocusManager.current
val coroutineScope = rememberCoroutineScope()
- val showMenu = remember { mutableStateOf(false) }
-
val airPodsService = remember { ServiceManager.getService() }
val packetLogs = airPodsService?.packetLogsFlow?.collectAsState(emptySet())?.value ?: emptySet()
- val shouldScrollToBottom = remember { mutableStateOf(true) }
val refreshTrigger = remember { mutableIntStateOf(0) }
LaunchedEffect(refreshTrigger.intValue) {
while(true) {
delay(1000)
- refreshTrigger.intValue = refreshTrigger.intValue + 1
+ refreshTrigger.intValue += 1
}
}
@@ -363,137 +319,42 @@ fun DebugScreen(navController: NavController) {
}
LaunchedEffect(packetLogs.size, refreshTrigger.intValue) {
- if (shouldScrollToBottom.value && packetLogs.isNotEmpty()) {
+ if (packetLogs.isNotEmpty()) {
listState.animateScrollToItem(packetLogs.size - 1)
}
}
- Scaffold(
- 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
- )
- }
+ val isDarkTheme = isSystemInDarkTheme()
- DropdownMenu(
- expanded = showMenu.value,
- onDismissRequest = { showMenu.value = false },
- modifier = Modifier
- .width(250.dp)
- .background(
- if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7)
- )
- .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),
+ StyledScaffold(
+ title = "Debug",
+ navigationButton = {
+ StyledIconButton(
+ onClick = { navController.popBackStack() },
+ icon = "",
+ darkMode = isDarkTheme
)
},
- containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7),
- ) { paddingValues ->
+ actionButtons = listOf(
+ {
+ StyledIconButton(
+ onClick = {
+ airPodsService?.clearLogs()
+ expandedItems.value = emptySet()
+ },
+ icon = "",
+ darkMode = isDarkTheme,
+ )
+ }
+ ),
+ ) { spacerHeight, hazeState ->
Column(
modifier = Modifier
.fillMaxSize()
.hazeSource(hazeState)
- .padding(top = paddingValues.calculateTopPadding())
.navigationBarsPadding()
) {
+ Spacer(modifier = Modifier.height(spacerHeight))
LazyColumn(
state = listState,
modifier = Modifier
@@ -509,7 +370,7 @@ fun DebugScreen(navController: NavController) {
Card(
modifier = Modifier
.fillMaxWidth()
- .padding(vertical = 2.dp, horizontal = 4.dp)
+ .padding(vertical = 2.dp)
.combinedClickable(
onClick = {
expandedItems.value = if (isExpanded) {
@@ -528,67 +389,65 @@ fun DebugScreen(navController: NavController) {
containerColor = if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7),
)
) {
- Column(modifier = Modifier.padding(8.dp)) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Icon(
- imageVector = if (isSent) Icons.AutoMirrored.Filled.KeyboardArrowLeft else Icons.AutoMirrored.Filled.KeyboardArrowRight,
- contentDescription = null,
- tint = if (isSent) Color.Green else Color.Red,
- modifier = Modifier.size(24.dp)
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = if (isSent) Icons.AutoMirrored.Filled.KeyboardArrowLeft else Icons.AutoMirrored.Filled.KeyboardArrowRight,
+ contentDescription = null,
+ tint = if (isSent) Color.Green else Color.Red,
+ 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))
- Column {
+ 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 = if (packetInfo.isUnknown) {
- val shortenedData = packetInfo.rawData.take(60) +
- (if (packetInfo.rawData.length > 60) "..." else "")
- shortenedData
- } else {
- "${packetInfo.type}: ${packetInfo.description}"
- },
+ text = "Raw: ${packetInfo.rawData}",
style = TextStyle(
fontSize = 12.sp,
- fontWeight = FontWeight.Medium,
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("")
focusManager.clearFocus()
- if (shouldScrollToBottom.value && packetLogs.isNotEmpty()) {
+ if (packetLogs.isNotEmpty()) {
coroutineScope.launch {
try {
delay(100)
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt
index e7039de..3ec0ac3 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/HeadTrackingScreen.kt
@@ -41,25 +41,15 @@ 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.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
-import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.CenterAlignedTopAppBar
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.TextButton
-import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -74,22 +64,16 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
-import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.asAndroidPath
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
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.res.stringResource
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.rememberTextMeasurer
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.unit.dp
import androidx.compose.ui.unit.sp
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.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
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.utils.HeadTracking
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -134,147 +115,59 @@ fun HeadTrackingScreen(navController: NavController) {
ServiceManager.getService()?.stopHeadTracking()
}
}
- val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
val scrollState = rememberScrollState()
- val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
- val hazeState = remember { HazeState() }
- var mDensity by remember { mutableFloatStateOf(0f) }
- Scaffold(
- modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
- 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(
- 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
+ StyledScaffold (
+ title = stringResource(R.string.head_tracking),
+ navigationButton = {
+ StyledIconButton(
+ onClick = { navController.popBackStack() },
+ icon = "",
+ darkMode = isDarkTheme
)
},
- containerColor = if (isSystemInDarkTheme()) Color(0xFF000000)
- else Color(0xFFF2F2F7),
- ) { paddingValues ->
+ actionButtons = listOf(
+ {
+ 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 (
modifier = Modifier
.fillMaxSize()
- .padding(paddingValues = paddingValues)
- .padding(horizontal = 16.dp)
.padding(top = 8.dp)
.verticalScroll(scrollState)
.hazeSource(state = hazeState)
) {
+ Spacer(modifier = Modifier.height(spacerHeight))
val sharedPreferences =
LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
var gestureText by remember { mutableStateOf("") }
val coroutineScope = rememberCoroutineScope()
- IndependentToggle(name = "Head Gestures", sharedPreferences = sharedPreferences)
+ StyledToggle(
+ label = "Head Gestures",
+ sharedPreferences = sharedPreferences,
+ sharedPreferenceKey = "head_gestures",
+ )
Spacer(modifier = Modifier.height(2.dp))
Text(
stringResource(R.string.head_gestures_details),
@@ -302,7 +195,7 @@ fun HeadTrackingScreen(navController: NavController) {
Spacer(modifier = Modifier.height(16.dp))
Text(
- "Acceleration",
+ "Velocity",
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
@@ -441,14 +334,13 @@ private fun ParticleText(
if (particles.isEmpty()) {
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 y = centerY - textBounds.height / 2 + random.nextFloat() * textBounds.height
val vx = (random.nextFloat() - 0.5f) * 20
val vy = (random.nextFloat() - 0.5f) * 20
particles.add(Particle(Offset(x, y), Offset(vx, vy)))
}
- textVisible = false
}
particles.forEach { particle ->
@@ -518,14 +410,12 @@ private fun HeadVisualization() {
fun rotate3D(point: Triple): Triple {
val (x, y, z) = point
val x1 = x * cosY - z * sinY
- val y1 = y
val z1 = x * sinY + z * cosY
- val x2 = x1
- val y2 = y1 * cosP - z1 * sinP
- val z2 = y1 * sinP + z1 * cosP
+ val y2 = y * cosP - z1 * sinP
+ val z2 = y * sinP + z1 * cosP
- return Triple(x2, y2, z2)
+ return Triple(x1, y2, z2)
}
fun project(point: Triple): Pair {
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt
index 1dacdf6..122b364 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidAdjustmentsScreen.kt
@@ -19,22 +19,16 @@
package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint
-import android.content.Context
import android.util.Log
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
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.verticalScroll
-import androidx.compose.material3.CenterAlignedTopAppBar
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.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -43,23 +37,11 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.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.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.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -67,13 +49,14 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
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.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles
import me.kavishdevar.librepods.utils.ATTManager
-import me.kavishdevar.librepods.utils.RadareOffsetFinder
import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
@@ -88,78 +71,31 @@ private const val TAG = "HearingAidAdjustments"
@Composable
fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
- val textColor = if (isDarkTheme) Color.White else Color.Black
val verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() }
- val snackbarHostState = remember { SnackbarHostState() }
val attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
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)
- if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
- if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
- if (isDarkTheme) Color.White else Color.Black
-
- Scaffold(
- containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7),
- 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)
+ StyledScaffold(
+ title = stringResource(R.string.adjustments),
+ navigationButton = {
+ StyledIconButton(
+ onClick = { navController.popBackStack() },
+ icon = "",
+ darkMode = isDarkTheme
)
- },
- snackbarHost = { SnackbarHost(snackbarHostState) }
- ) { paddingValues ->
+ }
+ ) { spacerHeight ->
Column(
modifier = Modifier
.hazeSource(hazeState)
.fillMaxSize()
- .padding(paddingValues)
- .padding(horizontal = 16.dp)
.verticalScroll(verticalScrollState),
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 balanceSliderValue = remember { mutableFloatStateOf(0.5f) }
val toneSliderValue = remember { mutableFloatStateOf(0.5f) }
@@ -355,12 +291,9 @@ fun HearingAidAdjustmentsScreen(@Suppress("unused") navController: NavController
independent = true,
)
- val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
- IndependentToggle(
- name = stringResource(R.string.swipe_to_control_amplification),
- service = service,
- sharedPreferences = sharedPreferences,
+ StyledToggle(
+ label = stringResource(R.string.swipe_to_control_amplification),
controlCommandIdentifier = AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE,
description = stringResource(R.string.swipe_amplification_description)
)
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt
index cf022a4..7153af4 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/HearingAidScreen.kt
@@ -39,27 +39,21 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
-import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
-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.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableFloatStateOf
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.draw.drawBehind
-import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
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.sp
import androidx.navigation.NavController
-import com.kyant.backdrop.backdrop
-import com.kyant.backdrop.rememberBackdrop
-import dev.chrisbanes.haze.HazeEffectScope
+import com.kyant.backdrop.backdrops.layerBackdrop
+import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeState
-import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
-import dev.chrisbanes.haze.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
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.StyledToggle
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import me.kavishdevar.librepods.utils.ATTHandles
@@ -108,7 +102,8 @@ fun HearingAidScreen(navController: NavController) {
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
val showDialog = remember { mutableStateOf(false) }
- val backdrop = rememberBackdrop()
+ val backdrop = rememberLayerBackdrop()
+ val initialLoad = remember { mutableStateOf(true) }
val hearingAidEnabled = remember {
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()))
}
- Scaffold(
- containerColor = if (isSystemInDarkTheme()) Color(
- 0xFF000000
- ) else Color(
- 0xFFF2F2F7
- ),
- topBar = {
- 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
- )
+ StyledScaffold(
+ title = stringResource(R.string.hearing_aid),
+ navigationButton = {
+ StyledIconButton(
+ onClick = { navController.popBackStack() },
+ icon = "",
+ darkMode = isDarkTheme
)
},
- snackbarHost = { SnackbarHost(snackbarHostState) },
- modifier = Modifier
- .backdrop(backdrop)
- ) { paddingValues ->
+ actionButtons = emptyList(),
+ snackbarHostState = snackbarHostState,
+ ) { spacerHeight ->
Column(
modifier = Modifier
+ .layerBackdrop(backdrop)
.hazeSource(hazeState)
.fillMaxSize()
- .padding(paddingValues)
- .padding(horizontal = 16.dp)
.verticalScroll(verticalScrollState),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
+ Spacer(modifier = Modifier.height(spacerHeight))
+
val hearingAidListener = remember {
object : AACPManager.ControlCommandListener {
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
@@ -206,14 +162,15 @@ fun HearingAidScreen(navController: NavController) {
}
}
- fun onChange(value: Boolean) {
- if (value) {
+ LaunchedEffect(hearingAidEnabled.value) {
+ if (hearingAidEnabled.value && !initialLoad.value) {
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_ASSIST_CONFIG.value, 0x02.toByte())
hearingAidEnabled.value = false
}
+ initialLoad.value = false
}
fun onAdjustPhoneChange(value: Boolean) {
@@ -241,37 +198,15 @@ fun HearingAidScreen(navController: NavController) {
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(14.dp))
- ) {
- val isDarkThemeLocal = isSystemInDarkTheme()
- 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)
- },
+ .clip(
+ RoundedCornerShape(14.dp)
)
- }
-
+ ) {
+ StyledToggle(
+ label = stringResource(R.string.hearing_aid),
+ checkedState = hearingAidEnabled,
+ independent = false
+ )
HorizontalDivider(
thickness = 1.5.dp,
color = Color(0x40888888),
@@ -299,7 +234,6 @@ fun HearingAidScreen(navController: NavController) {
)
}
}
-
Text(
text = stringResource(R.string.hearing_aid_description),
style = TextStyle(
@@ -310,7 +244,6 @@ fun HearingAidScreen(navController: NavController) {
),
modifier = Modifier.padding(horizontal = 8.dp)
)
-
Spacer(modifier = Modifier.height(16.dp))
AccessibilityToggle(
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/Onboarding.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/Onboarding.kt
index 3459266..95879e3 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/Onboarding.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/Onboarding.kt
@@ -39,25 +39,18 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Clear
-import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
-import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.DropdownMenu
-import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
-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.LaunchedEffect
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.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.core.content.edit
import androidx.navigation.NavController
+import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.kavishdevar.librepods.R
+import me.kavishdevar.librepods.composables.StyledIconButton
+import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.utils.RadareOffsetFinder
-import androidx.core.content.edit
+@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Onboarding(navController: NavController, activityContext: Context) {
@@ -104,7 +101,6 @@ fun Onboarding(navController: NavController, activityContext: Context) {
var moduleEnabled by remember { mutableStateOf(false) }
var bluetoothToggled by remember { mutableStateOf(false) }
- var showMenu by remember { mutableStateOf(false) }
var showSkipDialog by remember { mutableStateOf(false) }
fun checkRootAccess() {
@@ -155,55 +151,27 @@ fun Onboarding(navController: NavController, activityContext: Context) {
isComplete = true
}
}
-
- Scaffold(
- topBar = {
- CenterAlignedTopAppBar(
- title = {
- Text(
- "Setting Up",
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- fontWeight = FontWeight.Medium
- )
- },
- colors = TopAppBarDefaults.topAppBarColors(
- containerColor = Color.Transparent
- ),
- 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 ->
+ StyledScaffold(
+ title = "Setting Up",
+ actionButtons = listOf(
+ {
+ StyledIconButton(
+ onClick = {
+ showSkipDialog = true
+ },
+ icon = "",
+ darkMode = isDarkTheme
+ )
+ }
+ )
+ ) { spacerHeight ->
Column(
modifier = Modifier
- .fillMaxSize()
- .padding(paddingValues)
- .padding(16.dp),
+ .fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
- Spacer(modifier = Modifier.height(16.dp))
+ Spacer(modifier = Modifier.height(spacerHeight))
Card(
modifier = Modifier.fillMaxWidth(),
@@ -300,7 +268,8 @@ fun Onboarding(navController: NavController, activityContext: Context) {
Spacer(modifier = Modifier.height(24.dp))
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() }
) { text ->
Text(
@@ -319,7 +288,7 @@ fun Onboarding(navController: NavController, activityContext: Context) {
AnimatedContent(
targetState = if (hasStarted)
- getStatusDescription(progressState, isComplete, moduleEnabled, bluetoothToggled)
+ getStatusDescription(progressState, moduleEnabled, bluetoothToggled)
else
"AirPods functionality requires one-time setup for hooking into Bluetooth library",
transitionSpec = { fadeIn() togetherWith fadeOut() }
@@ -608,7 +577,6 @@ private fun StatusIcon(
private fun getStatusTitle(
state: RadareOffsetFinder.ProgressState,
- isComplete: Boolean,
moduleEnabled: Boolean,
bluetoothToggled: Boolean
): String {
@@ -635,7 +603,6 @@ private fun getStatusTitle(
private fun getStatusDescription(
state: RadareOffsetFinder.ProgressState,
- isComplete: Boolean,
moduleEnabled: Boolean,
bluetoothToggled: Boolean
): String {
@@ -660,6 +627,7 @@ private fun getStatusDescription(
}
}
+@ExperimentalHazeMaterialsApi
@Preview
@Composable
fun OnboardingPreview() {
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt
index 457a596..37cff81 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/PressAndHoldSettingsScreen.kt
@@ -30,24 +30,19 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
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.foundation.layout.wrapContentWidth
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.CheckboxDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
-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.getValue
import androidx.compose.runtime.mutableStateOf
@@ -68,14 +63,17 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.edit
import androidx.navigation.NavController
+import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
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.services.ServiceManager
import me.kavishdevar.librepods.utils.AACPManager
import kotlin.experimental.and
import kotlin.io.encoding.ExperimentalEncodingApi
-@Composable()
+@Composable
fun RightDivider() {
HorizontalDivider(
thickness = 1.5.dp,
@@ -85,7 +83,7 @@ fun RightDivider() {
)
}
-@Composable()
+@Composable
fun RightDividerNoIcon() {
HorizontalDivider(
thickness = 1.5.dp,
@@ -95,6 +93,7 @@ fun RightDividerNoIcon() {
)
}
+@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LongPress(navController: NavController, name: String) {
@@ -114,60 +113,27 @@ fun LongPress(navController: NavController, name: String) {
}
val context = LocalContext.current
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 longPressActionPref = sharedPreferences.getString(prefKey, StemAction.CYCLE_NOISE_CONTROL_MODES.name)
Log.d("PressAndHoldSettingsScreen", "Long press action preference ($prefKey): $longPressActionPref")
var longPressAction by remember { mutableStateOf(StemAction.valueOf(longPressActionPref ?: StemAction.CYCLE_NOISE_CONTROL_MODES.name)) }
- Scaffold(
- topBar = {
- CenterAlignedTopAppBar(
- title = {
- Text(
- name,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- )
- },
- 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
- )
+ StyledScaffold(
+ title = name,
+ navigationButton = {
+ StyledIconButton(
+ onClick = { navController.popBackStack() },
+ icon = "",
+ darkMode = isDarkTheme
)
- },
- containerColor = if (isSystemInDarkTheme()) Color(0xFF000000)
- else Color(0xFFF2F2F7),
- ) { paddingValues ->
+ }
+ ) { spacerHeight ->
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Column (
modifier = Modifier
.fillMaxSize()
- .padding(paddingValues = paddingValues)
- .padding(horizontal = 16.dp)
.padding(top = 8.dp)
) {
+ Spacer(modifier = Modifier.height(spacerHeight))
Column(
modifier = Modifier
.fillMaxWidth()
@@ -221,33 +187,37 @@ fun LongPress(navController: NavController, name: String) {
it.identifier == AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
}?.value?.takeIf { it.isNotEmpty() }?.get(0)
val offListeningMode = offListeningModeValue == 1.toByte()
- LongPressElement(
+ ListeningModeElement(
name = "Off",
enabled = offListeningMode,
resourceId = R.drawable.noise_cancellation,
isFirst = true)
if (offListeningMode) RightDivider()
- LongPressElement(
+ ListeningModeElement(
name = "Transparency",
resourceId = R.drawable.transparency,
isFirst = !offListeningMode)
RightDivider()
- LongPressElement(
+ ListeningModeElement(
name = "Adaptive",
resourceId = R.drawable.adaptive)
RightDivider()
- LongPressElement(
+ ListeningModeElement(
name = "Noise Cancellation",
resourceId = R.drawable.noise_cancellation,
isLast = true)
}
+ Spacer(modifier = Modifier.height(4.dp))
Text(
- "Press and hold the stem to cycle between the selected noise control modes.",
- fontSize = 16.sp,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- color = textColor.copy(alpha = 0.6f),
+ text = "Press and hold the stem to cycle between the selected noise control modes.",
+ style = TextStyle(
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Light,
+ color = textColor.copy(alpha = 0.6f),
+ fontFamily = FontFamily(Font(R.font.sf_pro))
+ ),
modifier = Modifier
- .padding(start = 16.dp, top = 4.dp)
+ .padding(horizontal = 8.dp)
)
}
}
@@ -258,7 +228,7 @@ fun LongPress(navController: NavController, name: String) {
}
@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) {
"Off" -> 0x01
"Transparency" -> 0x02
@@ -280,7 +250,7 @@ fun LongPressElement(name: String, enabled: Boolean = true, resourceId: Int, isF
val isChecked = (byteValue.toInt() and bit) != 0
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 textColor = if (darkMode) Color.White else Color.Black
val desc = when (name) {
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/RenameScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/RenameScreen.kt
index bcb4dc5..8d47612 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/RenameScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/RenameScreen.kt
@@ -25,6 +25,7 @@ import androidx.compose.foundation.background
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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.text.BasicTextField
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.filled.Clear
-import androidx.compose.material3.CenterAlignedTopAppBar
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.TextButton
-import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
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.graphics.Color
@@ -58,21 +52,21 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.core.content.edit
import androidx.navigation.NavController
+import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
+import me.kavishdevar.librepods.composables.StyledIconButton
+import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
-import androidx.core.content.edit
-@OptIn(ExperimentalMaterial3Api::class)
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable
fun RenameScreen(navController: NavController) {
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))
}
- Scaffold(
- topBar = {
- CenterAlignedTopAppBar(
- title = {
- Text(
- text = stringResource(R.string.name),
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- )
- },
- 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
- )
+ StyledScaffold(
+ title = stringResource(R.string.name),
+ navigationButton = {
+ StyledIconButton(
+ onClick = { navController.popBackStack() },
+ icon = "",
+ darkMode = isDarkTheme
)
},
- containerColor = if (isSystemInDarkTheme()) Color(0xFF000000)
- else Color(0xFFF2F2F7),
- ) { paddingValues ->
- Column (
+ ) { spacerHeight ->
+ Column(
modifier = Modifier
.fillMaxSize()
- .padding(paddingValues = paddingValues)
- .padding(horizontal = 16.dp)
- .padding(top = 8.dp)
) {
+ Spacer(modifier = Modifier.height(spacerHeight))
val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/TransparencySettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/TransparencySettingsScreen.kt
index e47e417..25ef6cd 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/TransparencySettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/TransparencySettingsScreen.kt
@@ -20,10 +20,7 @@ package me.kavishdevar.librepods.screens
import android.annotation.SuppressLint
import android.util.Log
-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.Arrangement
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.RoundedCornerShape
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.HorizontalDivider
-import androidx.compose.material3.Scaffold
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
-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.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.MutableState
-import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
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.draw.drawBehind
-import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
-import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.input.pointer.pointerInput
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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.materials.CupertinoMaterials
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.composables.StyledIconButton
+import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.composables.StyledSlider
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.ATTHandles
-import me.kavishdevar.librepods.utils.ATTManager
import me.kavishdevar.librepods.utils.RadareOffsetFinder
import me.kavishdevar.librepods.utils.TransparencySettings
import me.kavishdevar.librepods.utils.parseTransparencySettingsResponse
@@ -111,8 +85,6 @@ fun TransparencySettingsScreen(navController: NavController) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val verticalScrollState = rememberScrollState()
- val hazeState = remember { HazeState() }
- val snackbarHostState = remember { SnackbarHostState() }
val attManager = ServiceManager.getService()?.attManager ?: return
val aacpManager = remember { ServiceManager.getService()?.aacpManager }
val isSdpOffsetAvailable =
@@ -122,65 +94,24 @@ fun TransparencySettingsScreen(navController: NavController) {
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
- Scaffold(
- containerColor = if (isSystemInDarkTheme()) Color(
- 0xFF000000
- ) else Color(
- 0xFFF2F2F7
- ),
- topBar = {
- 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
- )
+ StyledScaffold(
+ title = stringResource(R.string.customize_transparency_mode),
+ navigationButton = {
+ StyledIconButton(
+ onClick = { navController.popBackStack() },
+ icon = "",
+ darkMode = isDarkTheme
)
- },
- snackbarHost = { SnackbarHost(snackbarHostState) }
- ) { paddingValues ->
+ }
+ ){ spacerHeight, hazeState ->
Column(
modifier = Modifier
.hazeSource(hazeState)
.fillMaxSize()
- .padding(paddingValues)
- .padding(horizontal = 16.dp)
.verticalScroll(verticalScrollState),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
+ Spacer(modifier = Modifier.height(spacerHeight))
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val enabled = remember { mutableStateOf(false) }
@@ -523,4 +454,4 @@ fun TransparencySettingsScreen(navController: NavController) {
}
}
}
-}
\ No newline at end of file
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/screens/TroubleshootingScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/screens/TroubleshootingScreen.kt
index 64bf6ff..25485b9 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/screens/TroubleshootingScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/screens/TroubleshootingScreen.kt
@@ -1,5 +1,5 @@
/*
- * LibrePods - AirPods liberated from Apple's ecosystem
+ * LibrePods - AirPods liberated from Apple’s ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
@@ -23,9 +23,7 @@ import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloatAsState
-import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -49,29 +47,23 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
-import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
-import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
@@ -80,11 +72,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -97,17 +85,15 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
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.materials.CupertinoMaterials
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.kavishdevar.librepods.R
+import me.kavishdevar.librepods.composables.StyledIconButton
+import me.kavishdevar.librepods.composables.StyledScaffold
import me.kavishdevar.librepods.utils.LogCollector
import java.io.File
import java.text.SimpleDateFormat
@@ -134,8 +120,6 @@ fun CustomIconButton(
fun TroubleshootingScreen(navController: NavController) {
val context = LocalContext.current
val scrollState = rememberScrollState()
- val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
- val hazeState = remember { HazeState() }
val coroutineScope = rememberCoroutineScope()
val logCollector = remember { LogCollector(context) }
@@ -161,27 +145,6 @@ fun TroubleshootingScreen(navController: NavController) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = 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 textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
val accentColor = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5)
@@ -189,7 +152,6 @@ fun TroubleshootingScreen(navController: NavController) {
var instructionText by remember { mutableStateOf("") }
val isDarkTheme = isSystemInDarkTheme()
- var mDensity by remember { mutableFloatStateOf(0f) }
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
@@ -249,75 +211,23 @@ fun TroubleshootingScreen(navController: NavController) {
Box(
modifier = Modifier.fillMaxSize()
) {
- Scaffold(
- modifier = Modifier
- .fillMaxSize()
- .graphicsLayer {
- scaleX = contentScale
- scaleY = contentScale
- transformOrigin = androidx.compose.ui.graphics.TransformOrigin(0.5f, 0.3f)
- },
- 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
+ StyledScaffold(
+ title = stringResource(R.string.troubleshooting),
+ navigationButton = {
+ StyledIconButton(
+ onClick = { navController.popBackStack() },
+ icon = "",
+ darkMode = isDarkTheme
)
- },
- containerColor = if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7),
- ) { paddingValues ->
+ }
+ ){ spacerHeight, hazeState ->
Column(
modifier = Modifier
.fillMaxSize()
- .padding(paddingValues)
- .padding(horizontal = 16.dp)
.verticalScroll(scrollState)
.hazeSource(state = hazeState)
) {
- Spacer(modifier = Modifier.height(8.dp))
+ Spacer(modifier = Modifier.height(spacerHeight))
Text(
text = stringResource(R.string.saved_logs).uppercase(),
@@ -706,7 +616,9 @@ fun TroubleshootingScreen(navController: NavController) {
Button(
onClick = {
selectedLogFile?.let { file ->
- saveLauncher.launch("airpods_log_${System.currentTimeMillis()}.txt")
+ saveLauncher.launch(
+ file.absolutePath
+ )
}
},
shape = RoundedCornerShape(10.dp),
@@ -977,7 +889,7 @@ fun TroubleshootingScreen(navController: NavController) {
Button(
onClick = {
selectedLogFile?.let { file ->
- saveLauncher.launch("airpods_log_${System.currentTimeMillis()}.txt")
+ saveLauncher.launch(file.absolutePath)
}
},
shape = RoundedCornerShape(10.dp),
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt
index 6200348..73613fb 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/AACPManager.kt
@@ -1,5 +1,5 @@
/*
- * LibrePods - AirPods liberated from Apple's ecosystem
+ * LibrePods - AirPods liberated from Apple’s ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
@@ -202,10 +202,10 @@ class AACPManager {
var eqData = FloatArray(8) { 0.0f }
private set
-
+
var eqOnPhone: Boolean = false
private set
-
+
var eqOnMedia: Boolean = false
private set
@@ -528,12 +528,23 @@ class AACPManager {
val packetString = packet.decodeToString()
val sender = packet.sliceArray(6..11).reversedArray().joinToString(":") { "%02X".format(it) }
- if (connectedDevices.find { it.mac == sender }?.type == null && packetString.contains("btName")) {
- val nameStartIndex = packetString.indexOf("btName") + 7
- val nameEndIndex = if (packetString.contains("other")) (packetString.indexOf("otherDevice") - 2) else (packetString.indexOf("nearbyAudio") - 2)
- val name = packet.sliceArray(nameStartIndex..nameEndIndex).decodeToString()
- connectedDevices.find { it.mac == sender }?.type = name
- Log.d(TAG, "Device $sender is named $name")
+ // if (connectedDevices.find { it.mac == sender }?.type == null && packetString.contains("btName")) {
+ // val nameStartIndex = packetString.indexOf("btName") + 8
+ // val nameEndIndex = if (packetString.contains("other")) (packetString.indexOf("otherDevice") - 1) else (packetString.indexOf("nearbyAudio") - 1)
+ // val name = packet.sliceArray(nameStartIndex..nameEndIndex).decodeToString()
+ // connectedDevices.find { it.mac == sender }?.type = 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}")
if (packetString.contains("SetOwnershipToFalse")) {
@@ -568,7 +579,7 @@ class AACPManager {
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 eq4 = ByteBuffer.wrap(packet, 108, 32).order(ByteOrder.LITTLE_ENDIAN).asFloatBuffer()
-
+
// for now, taking just the first EQ
eqData = FloatArray(8) { i -> eq1.get(i) }
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 {
return sendDataPacket(createRequestNotificationPacket())
}
@@ -853,11 +853,11 @@ class AACPManager {
Log.w(TAG, "Cannot send Media Information packet: No connected device found")
return false
}
- Log.d(TAG, "Sending Media Information packet to ${targetMac ?: "unknown device"}")
+ Log.d(TAG, "Sending Media Information packet to $targetMac")
return sendDataPacket(
createMediaInformationPacket(
selfMacAddress,
- targetMac ?: return false,
+ targetMac,
streamingState
)
)
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/ATTManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/ATTManager.kt
index 15b6144..41c6116 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/utils/ATTManager.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/ATTManager.kt
@@ -186,9 +186,7 @@ class ATTManager(private val device: BluetoothDevice) {
private fun readResponse(timeoutMs: Long = 2000): ByteArray {
try {
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) }}")
return resp.copyOfRange(1, resp.size)
} catch (e: InterruptedException) {
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/BluetoothCryptography.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/BluetoothCryptography.kt
index 4f0ed98..633ee46 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/utils/BluetoothCryptography.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/BluetoothCryptography.kt
@@ -1,7 +1,7 @@
/*
- * LibrePods - AirPods liberated from Apple's ecosystem
+ * LibrePods - AirPods liberated from Apple’s ecosystem
*
- * Copyright (C) 2025 LibrePods Contributors
+ * 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
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/DragUtils.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/DragUtils.kt
new file mode 100644
index 0000000..55b1eef
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/DragUtils.kt
@@ -0,0 +1,102 @@
+/*
+ * LibrePods - AirPods liberated from Apple’s ecosystem
+ *
+ * Copyright (C) 2025 LibrePods contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU 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 .
+ */
+
+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
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt
index b0f2e24..804d4cb 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureDetector.kt
@@ -1,3 +1,21 @@
+/*
+ * LibrePods - AirPods liberated from Apple’s ecosystem
+ *
+ * Copyright (C) 2025 LibrePods contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU 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 .
+ */
+
@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.utils
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt
index 6d2d0b2..b7d4068 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/GestureFeedback.kt
@@ -1,3 +1,21 @@
+/*
+ * LibrePods - AirPods liberated from Apple’s ecosystem
+ *
+ * Copyright (C) 2025 LibrePods contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU 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 .
+ */
+
@file:Suppress("PrivatePropertyName")
package me.kavishdevar.librepods.utils
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/HeadOrientation.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/HeadOrientation.kt
index 859f49b..ad2d418 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/utils/HeadOrientation.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/HeadOrientation.kt
@@ -1,3 +1,21 @@
+/*
+ * LibrePods - AirPods liberated from Apple’s ecosystem
+ *
+ * Copyright (C) 2025 LibrePods contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU 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 .
+ */
+
package me.kavishdevar.librepods.utils
import kotlinx.coroutines.flow.MutableStateFlow
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt
index 0e57c4b..bd6df5d 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/IslandWindow.kt
@@ -53,7 +53,6 @@ import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.VideoView
-import androidx.core.content.ContextCompat.getString
import androidx.core.net.toUri
import androidx.dynamicanimation.animation.DynamicAnimation
import androidx.dynamicanimation.animation.SpringAnimation
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/LogCollector.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/LogCollector.kt
index cc59214..02c9235 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/utils/LogCollector.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/LogCollector.kt
@@ -1,5 +1,5 @@
/*
- * LibrePods - AirPods liberated from Apple's ecosystem
+ * LibrePods - AirPods liberated from Apple’s ecosystem
*
* Copyright (C) 2025 LibrePods contributors
*
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt
index 6cc06b5..afc33af 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/RadareOffsetFinder.kt
@@ -462,7 +462,8 @@ class RadareOffsetFinder(context: Context) {
// findAndSaveL2cuProcessCfgReqOffset(libraryPath, envSetup)
// findAndSaveL2cCsmConfigOffset(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) {
Log.e(TAG, "Failed to find function offset", e)
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/TransparencyUtils.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/TransparencyUtils.kt
index fdf84bc..0ceaa9e 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/utils/TransparencyUtils.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/TransparencyUtils.kt
@@ -1,6 +1,23 @@
+/*
+ * LibrePods - AirPods liberated from Apple’s ecosystem
+ *
+ * Copyright (C) 2025 LibrePods contributors
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU 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 .
+ */
+
package me.kavishdevar.librepods.utils
-import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -10,8 +27,6 @@ import java.io.IOException
import java.nio.ByteBuffer
import java.nio.ByteOrder
-private const val TAG = "TransparencyUtils"
-
data class TransparencySettings(
val enabled: Boolean,
val leftEQ: FloatArray,
@@ -67,9 +82,8 @@ data class TransparencySettings(
}
}
-fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings? {
- val settingsData = data
- val buffer = ByteBuffer.wrap(settingsData).order(ByteOrder.LITTLE_ENDIAN)
+fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings {
+ val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
val enabled = buffer.float
diff --git a/android/app/src/main/res/drawable/pro_2_case.png b/android/app/src/main/res/drawable/pro_2_case.png
index f104605..d904a2f 100644
Binary files a/android/app/src/main/res/drawable/pro_2_case.png and b/android/app/src/main/res/drawable/pro_2_case.png differ
diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml
index 3af7d48..99720ca 100644
--- a/android/app/src/main/res/values-zh-rCN/strings.xml
+++ b/android/app/src/main/res/values-zh-rCN/strings.xml
@@ -23,8 +23,6 @@
当你开始与他人交谈时,会降低媒体音量并减少背景噪音。
个性化音量
根据环境自动调整媒体音量。
- 减少噪音
- 增加噪音
单只 AirPod 主动降噪
仅佩戴一只 AirPod 时也能开启主动降噪。
音量控制
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 8634953..b89fbf9 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -8,6 +8,7 @@
Tone Volume
Audio
Adaptive Audio
+ Customize Adaptive Audio
Adaptive audio dynamically responds to your environment and cancels or allows external noise. You can customize Adaptive Audio to allow more or less noise.
Buds
Case
@@ -26,14 +27,12 @@
Lowers media volume and reduces background noise when you start speaking to other people.
Personalized Volume
Adjusts the volume of media in response to your environment.
- Less Noise
- More Noise
Noise Cancellation with Single AirPod
Allow AirPods to be put in noise cancellation mode when only one AirPods is in your ear.
Volume Control
Adjust the volume by swiping up or down on the sensor located on the AirPods Pro stem.
AirPods not connected
- 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!)
+ Please connect your AirPods to access settings.
Back
Customizations
Relative volume
@@ -135,4 +134,40 @@
AirPods Pro can use the results of a hearing test to make adjustments that improve the clarity of music, video, and calls.
Adjust Music and Video
Adjust Calls
+ Widget
+ Show phone battery in widget
+ Display your phone\'s battery level in the widget alongside AirPods battery
+ Connection Mode
+ BLE Only Mode
+ Only use Bluetooth Low Energy for battery data and ear detection. Disables advanced features requiring L2CAP connection.
+ Conversational Awareness Volume
+ Quick Settings Tile
+ Open dialog for controlling
+ 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
+ Disconnect AirPods when not wearing
+ You will still be able to control them with the app - this just disconnects the audio.
+ Advanced Options
+ Set Identity Resolving Key (IRK)
+ Manually set the IRK value used for resolving BLE random addresses
+ Set Encryption Key
+ Manually set the ENC_KEY value used for decrypting BLE advertisements
+ Use alternate head tracking packets
+ Enable this if head tracking doesn\'t work for you. This sends different data to AirPods for requesting/stopping head tracking data.
+ Act as an Apple device
+ Enables multi-device connectivity and Accessibility features like customizing transparency mode (amplification, tone, ambient noise reduction, conversation boost, and EQ)
+ 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.
+ Reset Hook Offset
+ This will clear the current hook offset and require you to go through the setup process again. Are you sure you want to continue?
+ Reset
+ Hook offset has been reset. Redirecting to setup...
+ Failed to reset hook offset
+ IRK has been set successfully
+ Encryption key has been set successfully
+ IRK Hex Value
+ ENC_KEY Hex Value
+ Enter 16-byte IRK as hex string (32 characters):
+ Enter 16-byte ENC_KEY as hex string (32 characters):
+ Must be exactly 32 hex characters
+ Error converting hex:
+ Found offset please restart the Bluetooth process