From 86a6a28dc13ef0e4c628146389f1cd6b6b40c3e6 Mon Sep 17 00:00:00 2001 From: Kavish Devar Date: Fri, 26 Sep 2025 03:22:01 +0530 Subject: [PATCH] android: a very big commit refactoring ui, mostly --- .../me/kavishdevar/librepods/MainActivity.kt | 13 +- .../librepods/QuickSettingsDialogActivity.kt | 18 + .../librepods/composables/AudioSettings.kt | 63 ++- .../composables/AutomaticConnectionSwitch.kt | 22 +- .../librepods/composables/BatteryIndicator.kt | 150 +++---- .../librepods/composables/BatteryView.kt | 104 +++-- .../composables/ConfirmationDialog.kt | 259 +++++++++-- .../composables/ConnectionSettings.kt | 13 +- .../ConversationalAwarenessSwitch.kt | 161 ------- .../composables/EarDetectionSwitch.kt | 23 +- .../composables/IndependentToggle.kt | 189 -------- .../composables/LoudSoundReductionSwitch.kt | 3 +- .../composables/MicrophoneSettings.kt | 26 -- .../librepods/composables/NavigationButton.kt | 4 +- .../composables/NoiseControlSettings.kt | 3 +- .../composables/PersonalizedVolumeSwitch.kt | 10 +- .../librepods/composables/StyledButton.kt | 284 ++++++++++++ .../librepods/composables/StyledIconButton.kt | 258 +++++++++++ .../librepods/composables/StyledScaffold.kt | 166 +++++++ .../librepods/composables/StyledSlider.kt | 54 ++- .../librepods/composables/StyledToggle.kt | 416 ++++++++++++++++++ .../librepods/constants/Packets.kt | 40 +- .../screens/AccessibilitySettingsScreen.kt | 199 +++------ .../AdaptiveStrengthScreen.kt} | 111 +++-- .../screens/AirPodsSettingsScreen.kt | 189 ++------ .../librepods/screens/AppSettingsScreen.kt | 212 +++------ .../librepods/screens/DebugScreen.kt | 303 ++++--------- .../librepods/screens/HeadTrackingScreen.kt | 190 ++------ .../screens/HearingAidAdjustmentsScreen.kt | 101 +---- .../librepods/screens/HearingAidScreen.kt | 133 ++---- .../librepods/screens/Onboarding.kt | 82 ++-- .../screens/PressAndHoldSettingsScreen.kt | 94 ++-- .../librepods/screens/RenameScreen.kt | 71 +-- .../screens/TransparencySettingsScreen.kt | 95 +--- .../screens/TroubleshootingScreen.kt | 122 +---- .../librepods/utils/AACPManager.kt | 46 +- .../kavishdevar/librepods/utils/ATTManager.kt | 4 +- .../librepods/utils/BluetoothCryptography.kt | 4 +- .../kavishdevar/librepods/utils/DragUtils.kt | 102 +++++ .../librepods/utils/GestureDetector.kt | 18 + .../librepods/utils/GestureFeedback.kt | 18 + .../librepods/utils/HeadOrientation.kt | 18 + .../librepods/utils/IslandWindow.kt | 1 - .../librepods/utils/LogCollector.kt | 2 +- .../librepods/utils/RadareOffsetFinder.kt | 3 +- .../librepods/utils/TransparencyUtils.kt | 26 +- .../app/src/main/res/drawable/pro_2_case.png | Bin 56178 -> 53180 bytes .../src/main/res/values-zh-rCN/strings.xml | 2 - android/app/src/main/res/values/strings.xml | 41 +- 49 files changed, 2375 insertions(+), 2091 deletions(-) delete mode 100644 android/app/src/main/java/me/kavishdevar/librepods/composables/ConversationalAwarenessSwitch.kt delete mode 100644 android/app/src/main/java/me/kavishdevar/librepods/composables/IndependentToggle.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/composables/StyledButton.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/composables/StyledIconButton.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/composables/StyledScaffold.kt create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/composables/StyledToggle.kt rename android/app/src/main/java/me/kavishdevar/librepods/{composables/AdaptiveStrengthSlider.kt => screens/AdaptiveStrengthScreen.kt} (53%) create mode 100644 android/app/src/main/java/me/kavishdevar/librepods/utils/DragUtils.kt 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 f104605c43453fa5551523f7dbd2ed799377e74b..d904a2fede6ff666a389719067b4094c4b351275 100644 GIT binary patch literal 53180 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%0DH38UI3U242 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 RoaoyVamnan+4<}D{s*PWd6WPE 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