diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index 8b1fd3b4b..c1da71cb2 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -134,6 +134,8 @@ dependencies {
implementation(libs.androidx.dynamicanimation)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.material.icons.core)
+ implementation(libs.androidx.compose.material.icons.extended)
+ implementation(libs.androidx.graphics.shapes)
debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.compose.foundation.layout)
implementation(libs.aboutlibraries)
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index d87434108..1a17c63ec 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -13,7 +13,6 @@
-
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 49f28615e..028893abf 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt
@@ -41,9 +41,11 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -68,6 +70,9 @@ 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.CheckCircle
+import androidx.compose.material.icons.filled.Error
+import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Phone
import androidx.compose.material3.Button
@@ -75,8 +80,12 @@ import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -124,7 +133,6 @@ import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
import me.kavishdevar.librepods.presentation.components.StyledIconButton
import me.kavishdevar.librepods.presentation.screens.AccessibilitySettingsScreen
import me.kavishdevar.librepods.presentation.screens.AdaptiveStrengthScreen
-import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsScreen
import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen
import me.kavishdevar.librepods.presentation.screens.CameraControlScreen
import me.kavishdevar.librepods.presentation.screens.DebugScreen
@@ -139,6 +147,7 @@ import me.kavishdevar.librepods.presentation.screens.TransparencySettingsScreen
import me.kavishdevar.librepods.presentation.screens.TroubleshootingScreen
import me.kavishdevar.librepods.presentation.screens.UpdateHearingTestScreen
import me.kavishdevar.librepods.presentation.screens.VersionScreen
+import me.kavishdevar.librepods.presentation.m3.AppShell
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
@@ -216,83 +225,53 @@ fun Main() {
val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
if (!isSupported(sharedPreferences) && !XposedState.bluetoothScopeEnabled) {
- val hazeState = rememberHazeState()
- val backdrop = rememberLayerBackdrop()
- val isDarkTheme = isSystemInDarkTheme()
- val textColor = if (isDarkTheme) Color.White else Color.Black
- val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
-
val scrollState = rememberScrollState()
Box(
modifier = Modifier
.fillMaxSize()
- .hazeSource(hazeState)
- .layerBackdrop(backdrop)
- .background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)),
+ .background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.verticalScroll(scrollState),
- verticalArrangement = Arrangement
- .spacedBy(16.dp)
+ verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(48.dp))
- Column(
- modifier = Modifier,
- verticalArrangement = Arrangement
- .spacedBy(16.dp)
- ) {
- Spacer(modifier = Modifier.height(16.dp))
- Text(
- text = stringResource(R.string.not_supported),
- style = TextStyle(
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- fontWeight = FontWeight.SemiBold,
- color = textColor,
- fontSize = 28.sp,
- textAlign = TextAlign.Center
- ),
- modifier = Modifier.fillMaxWidth()
- )
+ Text(
+ text = stringResource(R.string.not_supported),
+ style = MaterialTheme.typography.headlineMedium,
+ color = MaterialTheme.colorScheme.onBackground,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth()
+ )
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .background(backgroundColor, RoundedCornerShape(28.dp))
- .clip(RoundedCornerShape(28.dp))
- ) {
- Text(
- text = stringResource(R.string.check_the_repository_for_more_info),
- style = TextStyle(
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- fontWeight = FontWeight.Medium,
- color = if (isDarkTheme) Color.White else Color.Black,
- fontSize = 16.sp
- ),
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 12.dp, vertical = 16.dp)
- )
- }
- Spacer(modifier = Modifier.height(4.dp))
+ Surface(
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ shape = MaterialTheme.shapes.large,
+ modifier = Modifier.fillMaxWidth()
+ ) {
Text(
- text = stringResource(R.string.enable_app_in_xposed_or_update_device),
- style = TextStyle(
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- fontWeight = FontWeight.Light,
- color = if (isDarkTheme) Color.White else Color.Black,
- fontSize = 14.sp
- ),
+ text = stringResource(R.string.check_the_repository_for_more_info),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp)
)
- DeviceInfoCard()
- AppInfoCard()
}
+ Text(
+ text = stringResource(R.string.enable_app_in_xposed_or_update_device),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp)
+ )
+ DeviceInfoCard()
+ AppInfoCard()
Spacer(modifier = Modifier.height(48.dp))
}
}
@@ -301,14 +280,6 @@ fun Main() {
val isConnected = remember { mutableStateOf(false) }
- var canDrawOverlays by remember { mutableStateOf(Settings.canDrawOverlays(context)) }
- val overlaySkipped = remember {
- mutableStateOf(
- context.getSharedPreferences("settings", MODE_PRIVATE)
- .getBoolean("overlay_permission_skipped", false)
- )
- }
-
val bluetoothPermissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf(
"android.permission.BLUETOOTH_CONNECT",
@@ -348,138 +319,10 @@ fun Main() {
}
}
- LaunchedEffect(Unit) {
- canDrawOverlays = Settings.canDrawOverlays(context)
- }
-
- if (permissionState.allPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) {
-
- val navController = rememberNavController()
-
- Box(
- modifier = Modifier.fillMaxSize()
- ) {
- val backButtonBackdrop = rememberLayerBackdrop()
- Box(
- modifier = Modifier
- .fillMaxSize()
- .background(if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7))
- .layerBackdrop(backButtonBackdrop)
- ) {
- NavHost(
- navController = navController,
- startDestination = "settings",
- enterTransition = {
- slideInHorizontally(
- initialOffsetX = { it }, animationSpec = tween(durationMillis = 300)
- )
- },
- exitTransition = {
- slideOutHorizontally(
- targetOffsetX = { -it / 4 }, animationSpec = tween(durationMillis = 300)
- )
- },
- popEnterTransition = {
- slideInHorizontally(
- initialOffsetX = { -it / 4 },
- animationSpec = tween(durationMillis = 300)
- )
- },
- popExitTransition = {
- slideOutHorizontally(
- targetOffsetX = { it }, animationSpec = tween(durationMillis = 300)
- )
- }) {
- composable("settings") {
- if (airPodsViewModel != null) AirPodsSettingsScreen(airPodsViewModel, navController)
- }
- composable("debug") {
- DebugScreen(navController = navController)
- }
- composable("long_press/{bud}") { navBackStackEntry ->
- if (airPodsViewModel != null) LongPress(
- viewModel = airPodsViewModel,
- name = navBackStackEntry.arguments?.getString("bud")!!,
- navController = navController
- )
- }
- composable("rename") {
- if (airPodsViewModel != null) RenameScreen(airPodsViewModel)
- }
- composable("app_settings") {
- val appSettingsViewModel: AppSettingsViewModel = viewModel()
- AppSettingsScreen(navController, appSettingsViewModel)
- }
- composable("troubleshooting") {
- TroubleshootingScreen(navController)
- }
- composable("head_tracking") {
- if (airPodsViewModel != null) HeadTrackingScreen(airPodsViewModel, navController)
- }
- composable("accessibility") {
- if (airPodsViewModel != null) AccessibilitySettingsScreen(airPodsViewModel, navController)
- }
- composable("transparency_customization") {
- if (airPodsViewModel != null) TransparencySettingsScreen(airPodsViewModel)
- }
- composable("hearing_aid") {
- if (airPodsViewModel != null) HearingAidScreen(airPodsViewModel, navController)
- }
- composable("hearing_aid_adjustments") {
- if (airPodsViewModel != null) HearingAidAdjustmentsScreen(airPodsViewModel)
- }
- composable("adaptive_strength") {
- if (airPodsViewModel != null) AdaptiveStrengthScreen(airPodsViewModel, navController)
- }
- composable("camera_control") {
- if (airPodsViewModel != null) CameraControlScreen(airPodsViewModel)
- }
- composable("open_source_licenses") {
- OpenSourceLicensesScreen(navController)
- }
- composable("update_hearing_test") {
- if (airPodsViewModel != null) UpdateHearingTestScreen(airPodsViewModel)
- }
- composable("version_info") {
- if (airPodsViewModel != null) VersionScreen(airPodsViewModel)
- }
- composable("hearing_protection") {
- if (airPodsViewModel != null) HearingProtectionScreen(airPodsViewModel, navController)
- }
- }
- }
-
- val showBackButton = remember { mutableStateOf(false) }
+ if (permissionState.allPermissionsGranted) {
- LaunchedEffect(navController) {
- navController.addOnDestinationChangedListener { _, destination, _ ->
- showBackButton.value =
- destination.route != "settings" // && destination.route != "onboarding"
- }
- }
-
- AnimatedVisibility(
- visible = showBackButton.value,
- enter = fadeIn(animationSpec = tween()) + scaleIn(
- initialScale = 0f,
- animationSpec = tween()
- ),
- exit = fadeOut(animationSpec = tween()) + scaleOut(
- targetScale = 0.5f,
- animationSpec = tween(100)
- ),
- modifier = Modifier
- .align(Alignment.TopStart)
- .padding(
- start = 8.dp, top = (LocalWindowInfo.current.containerSize.width * 0.05f).dp
- )
- ) {
- StyledIconButton(
- onClick = { navController.popBackStack() },
- icon = "",
- backdrop = backButtonBackdrop
- )
- }
+ if (airPodsViewModel != null) {
+ AppShell(airPodsViewModel)
}
context.startForegroundService(Intent(context, AirPodsService::class.java))
@@ -514,24 +357,20 @@ fun Main() {
}
} else {
PermissionsScreen(
- permissionState = permissionState,
- canDrawOverlays = canDrawOverlays,
- onOverlaySettingsReturn = { canDrawOverlays = Settings.canDrawOverlays(context) })
+ permissionState = permissionState
+ )
}
}
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@Composable
fun PermissionsScreen(
- permissionState: MultiplePermissionsState,
- canDrawOverlays: Boolean,
- onOverlaySettingsReturn: () -> Unit
+ permissionState: MultiplePermissionsState
) {
val context = LocalContext.current
- val isDarkTheme = isSystemInDarkTheme()
- val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White
- val textColor = if (isDarkTheme) Color.White else Color.Black
- val accentColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
+ val backgroundColor = MaterialTheme.colorScheme.surfaceContainer
+ val textColor = MaterialTheme.colorScheme.onSurface
+ val accentColor = MaterialTheme.colorScheme.primary
val scrollState = rememberScrollState()
@@ -547,7 +386,7 @@ fun PermissionsScreen(
Column(
modifier = Modifier
.fillMaxSize()
- .background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
+ .background(MaterialTheme.colorScheme.background)
.padding(16.dp)
.verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally
) {
@@ -556,62 +395,41 @@ fun PermissionsScreen(
.fillMaxWidth()
.height(180.dp), contentAlignment = Alignment.Center
) {
- Text(
- text = "\uDBC2\uDEB7", style = TextStyle(
- fontSize = 48.sp,
- fontWeight = FontWeight.Bold,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- color = textColor,
- textAlign = TextAlign.Center
- )
- )
- Canvas(
+ Box(
modifier = Modifier
.size(120.dp)
.scale(pulseScale)
+ .clip(RoundedCornerShape(40.dp))
+ .background(accentColor.copy(alpha = 0.15f)),
+ contentAlignment = Alignment.Center
) {
- val radius = size.minDimension / 2.2f
- val centerX = size.width / 2
- val centerY = size.height / 2
-
- rotate(degrees = 45f) {
- drawCircle(
- color = accentColor.copy(alpha = 0.1f),
- radius = radius * 1.3f,
- center = Offset(centerX, centerY)
- )
-
- drawCircle(
- color = accentColor.copy(alpha = 0.2f),
- radius = radius * 1.1f,
- center = Offset(centerX, centerY)
- )
- }
+ Icon(
+ imageVector = Icons.Filled.Lock,
+ contentDescription = null,
+ tint = accentColor,
+ modifier = Modifier.size(56.dp)
+ )
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
- text = "Permission Required", style = TextStyle(
- fontSize = 24.sp,
- fontWeight = FontWeight.Bold,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- color = textColor,
- textAlign = TextAlign.Center
- ), modifier = Modifier.fillMaxWidth()
+ text = "Permission Required",
+ style = MaterialTheme.typography.headlineSmall,
+ color = textColor,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
Text(
- text = stringResource(R.string.permissions_required), style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Normal,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- color = textColor.copy(alpha = 0.7f),
- textAlign = TextAlign.Center
- ), modifier = Modifier.fillMaxWidth()
+ text = stringResource(R.string.permissions_required),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(32.dp))
@@ -652,102 +470,21 @@ fun PermissionsScreen(
accentColor = accentColor
)
- PermissionCard(
- title = "Display Over Other Apps",
- description = "For popup animations when AirPods connect",
- icon = ImageVector.vectorResource(id = R.drawable.ic_layers),
- isGranted = canDrawOverlays,
- backgroundColor = backgroundColor,
- textColor = textColor,
- accentColor = accentColor
- )
-
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = { permissionState.launchMultiplePermissionRequest() },
modifier = Modifier
.fillMaxWidth()
- .height(55.dp),
- colors = ButtonDefaults.buttonColors(
- containerColor = accentColor
- ),
- shape = RoundedCornerShape(8.dp)
+ .height(56.dp),
+ shape = MaterialTheme.shapes.large
) {
Text(
"Ask for regular permissions",
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Medium,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- color = Color.White
- ),
- )
- }
-
- Spacer(modifier = Modifier.height(12.dp))
-
- Button(
- onClick = {
- val intent = Intent(
- Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
- "package:${context.packageName}".toUri()
- )
- context.startActivity(intent)
- onOverlaySettingsReturn()
- },
- modifier = Modifier
- .fillMaxWidth()
- .height(55.dp),
- colors = ButtonDefaults.buttonColors(
- containerColor = if (canDrawOverlays) Color.Gray else accentColor
- ),
- enabled = !canDrawOverlays,
- shape = RoundedCornerShape(8.dp)
- ) {
- Text(
- if (canDrawOverlays) "Overlay Permission Granted" else "Grant Overlay Permission",
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Medium,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- color = Color.White
- ),
+ style = MaterialTheme.typography.labelLarge
)
}
- if (!canDrawOverlays && basicPermissionsGranted) {
- Spacer(modifier = Modifier.height(12.dp))
-
- Button(
- onClick = {
- context.getSharedPreferences("settings", MODE_PRIVATE).edit {
- putBoolean("overlay_permission_skipped", true)
- }
-
- val intent = Intent(context, MainActivity::class.java)
- intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
- context.startActivity(intent)
- },
- modifier = Modifier
- .fillMaxWidth()
- .height(55.dp),
- colors = ButtonDefaults.buttonColors(
- containerColor = Color(0xFF757575)
- ),
- shape = RoundedCornerShape(8.dp)
- ) {
- Text(
- "Continue without overlay",
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Medium,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- color = Color.White
- ),
- )
- }
- }
}
}
@@ -761,14 +498,12 @@ fun PermissionCard(
textColor: Color,
accentColor: Color
) {
- Card(
+ Surface(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
- colors = CardDefaults.cardColors(
- containerColor = backgroundColor
- ),
- shape = RoundedCornerShape(12.dp)
+ color = backgroundColor,
+ shape = MaterialTheme.shapes.large
) {
Row(
modifier = Modifier
@@ -778,18 +513,17 @@ fun PermissionCard(
) {
Box(
modifier = Modifier
- .size(40.dp)
- .clip(RoundedCornerShape(8.dp))
+ .size(44.dp)
+ .clip(RoundedCornerShape(14.dp))
.background(
- if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy(
- alpha = 0.15f
- )
+ if (isGranted) accentColor.copy(alpha = 0.15f)
+ else MaterialTheme.colorScheme.surfaceContainerHighest
), contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = title,
- tint = if (isGranted) accentColor else Color.Gray,
+ tint = if (isGranted) accentColor else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(24.dp)
)
}
@@ -800,37 +534,24 @@ fun PermissionCard(
.padding(start = 16.dp)
) {
Text(
- text = title, style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Medium,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- color = textColor
- )
+ text = title,
+ style = MaterialTheme.typography.bodyLarge,
+ color = textColor
)
Text(
- text = description, style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Normal,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- color = textColor.copy(alpha = 0.6f)
- )
+ text = description,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
- Box(
- modifier = Modifier
- .size(24.dp)
- .clip(RoundedCornerShape(12.dp))
- .background(if (isGranted) Color(0xFF4CAF50) else Color.Gray),
- contentAlignment = Alignment.Center
- ) {
- Text(
- text = if (isGranted) "✓" else "!", style = TextStyle(
- fontSize = 14.sp, fontWeight = FontWeight.Bold, color = Color.White
- )
- )
- }
+ Icon(
+ imageVector = if (isGranted) Icons.Filled.CheckCircle else Icons.Filled.Error,
+ contentDescription = null,
+ tint = if (isGranted) accentColor else MaterialTheme.colorScheme.outline,
+ modifier = Modifier.size(24.dp)
+ )
}
}
}
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 2aa9158c3..9db8b329b 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/QuickSettingsDialogActivity.kt
@@ -61,6 +61,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -241,7 +243,7 @@ fun DraggableDismissBox(
Box(
modifier = Modifier
.fillMaxSize()
- .background(Color.Black.copy(alpha = 0.5f * backgroundAlpha))
+ .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f * backgroundAlpha))
.pointerInput(Unit) {
detectVerticalDragGestures(
onDragStart = { isDragging = true },
@@ -305,7 +307,7 @@ fun NewControlCenterDialogContent(
) {
val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
- val textColor = Color.White
+ val colorScheme = MaterialTheme.colorScheme
var currentAncMode by remember { mutableStateOf(NoiseControlMode.TRANSPARENCY) }
var isConvAwarenessEnabled by remember { mutableStateOf(false) }
@@ -419,17 +421,26 @@ fun NewControlCenterDialogContent(
if (service != null) {
Spacer(modifier = Modifier.weight(1f))
- Column(
+ Surface(
modifier = Modifier
.weight(2f)
.fillMaxWidth(),
+ shape = MaterialTheme.shapes.extraLarge,
+ color = colorScheme.surfaceContainerHigh,
+ tonalElevation = 3.dp,
+ shadowElevation = 6.dp
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
painter = painterResource(id = R.drawable.airpods),
contentDescription = "Device Icon",
- tint = textColor.copy(alpha = 0.8f),
+ tint = colorScheme.onSurfaceVariant,
modifier = Modifier.size(48.dp)
)
@@ -437,8 +448,8 @@ fun NewControlCenterDialogContent(
Text(
text = deviceName,
- color = textColor,
- fontSize = 16.sp,
+ color = colorScheme.onSurface,
+ style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium
)
@@ -469,13 +480,23 @@ fun NewControlCenterDialogContent(
.padding(vertical = 8.dp)
)
}
+ }
Spacer(modifier = Modifier.weight(1f))
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 72.dp),
+ shape = MaterialTheme.shapes.extraLarge,
+ color = colorScheme.surfaceContainerHigh,
+ tonalElevation = 3.dp,
+ shadowElevation = 6.dp
+ ) {
Box(
modifier = Modifier
.fillMaxWidth()
- .padding(bottom = 72.dp)
+ .padding(vertical = 20.dp)
.animateContentSize(
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
@@ -525,7 +546,7 @@ fun NewControlCenterDialogContent(
.clip(CircleShape)
.background(
brush = noiseControlButtonBrush ?:
- Brush.linearGradient(colors = listOf(Color(0xFF0A84FF), Color(0xFF0A84FF)))
+ Brush.linearGradient(colors = listOf(colorScheme.primary, colorScheme.primary))
)
.clickable(
onClick = { onNoiseControlExpandedChange(true) },
@@ -537,7 +558,7 @@ fun NewControlCenterDialogContent(
Icon(
painter = painterResource(id = getModeIconRes(currentAncMode)),
contentDescription = getModeLabel(currentAncMode),
- tint = Color.White,
+ tint = colorScheme.onPrimary,
modifier = Modifier.size(32.dp)
)
}
@@ -546,8 +567,8 @@ fun NewControlCenterDialogContent(
Text(
text = getModeLabel(currentAncMode),
- color = Color.White,
- fontSize = 12.sp,
+ color = colorScheme.onSurface,
+ style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
@@ -567,8 +588,8 @@ fun NewControlCenterDialogContent(
.background(
Brush.linearGradient(
colors = listOf(
- if (isConvAwarenessEnabled) Color(0xFF0A84FF) else Color(0x593C3C3E),
- if (isConvAwarenessEnabled) Color(0xFF0A84FF) else Color(0x593C3C3E)
+ if (isConvAwarenessEnabled) colorScheme.primary else colorScheme.surfaceContainerHighest,
+ if (isConvAwarenessEnabled) colorScheme.primary else colorScheme.surfaceContainerHighest
)
)
)
@@ -589,7 +610,7 @@ fun NewControlCenterDialogContent(
Icon(
painter = painterResource(id = R.drawable.airpods),
contentDescription = "Conversational Awareness",
- tint = Color.White,
+ tint = if (isConvAwarenessEnabled) colorScheme.onPrimary else colorScheme.onSurfaceVariant,
modifier = Modifier.size(32.dp)
)
}
@@ -598,8 +619,8 @@ fun NewControlCenterDialogContent(
Text(
text = "Conversational\nAwareness",
- color = Color.White,
- fontSize = 12.sp,
+ color = colorScheme.onSurface,
+ style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
lineHeight = 14.sp
@@ -609,11 +630,16 @@ fun NewControlCenterDialogContent(
}
}
}
+ }
} else {
Spacer(modifier = Modifier.weight(1f))
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
- Text("Loading...", color = textColor)
+ Text(
+ "Loading...",
+ color = colorScheme.onSurfaceVariant,
+ style = MaterialTheme.typography.bodyLarge
+ )
}
Spacer(modifier = Modifier.weight(1f))
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AboutCard.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AboutCard.kt
index f0669bada..7c267bfe6 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AboutCard.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AboutCard.kt
@@ -23,32 +23,30 @@ package me.kavishdevar.librepods.presentation.components
import androidx.compose.foundation.background
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
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.shape.RoundedCornerShape
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableIntStateOf
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.clip
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import me.kavishdevar.librepods.R
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -61,146 +59,134 @@ fun AboutCard(
serialNumbers: List,
version: String?
) {
- val isDarkTheme = isSystemInDarkTheme()
- val textColor = if (isDarkTheme) Color.White else Color.Black
- val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
-
Box(
modifier = Modifier
- .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
- .padding(horizontal = 16.dp, vertical = 4.dp)
- ){
+ .background(MaterialTheme.colorScheme.background)
+ .padding(start = 20.dp, end = 16.dp, top = 8.dp, bottom = 6.dp)
+ ) {
Text(
text = stringResource(R.string.about),
- style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
val rowHeight = remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
- Column(
- modifier = Modifier
- .clip(RoundedCornerShape(28.dp))
- .fillMaxWidth()
- .background(backgroundColor, RoundedCornerShape(28.dp))
- .padding(top = 2.dp)
+ Surface(
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ modifier = Modifier.fillMaxWidth(),
) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp)
- .onGloballyPositioned { coordinates ->
- rowHeight.value = with(density) { coordinates.size.height.toDp() }
- },
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(
- text = stringResource(R.string.model_name),
- style = TextStyle(
- fontSize = 16.sp,
- color = textColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
+ Column {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp, vertical = 16.dp)
+ .onGloballyPositioned { coordinates ->
+ rowHeight.value = with(density) { coordinates.size.height.toDp() }
+ },
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(R.string.model_name),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
)
- )
- Text(
- text = modelName,
- style = TextStyle(
- fontSize = 16.sp,
- color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
+ Text(
+ text = modelName,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
)
+ }
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant,
+ modifier = Modifier.padding(horizontal = 16.dp)
)
- }
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier
- .padding(horizontal = 12.dp)
- )
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(
- text = stringResource(R.string.model_name),
- style = TextStyle(
- fontSize = 16.sp,
- color = textColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp, vertical = 16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(R.string.model_name),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
)
- )
- Text(
- text = actualModel,
- style = TextStyle(
- fontSize = 16.sp,
- color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
+ Text(
+ text = actualModel,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
)
+ }
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant,
+ modifier = Modifier.padding(horizontal = 16.dp)
)
- }
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier
- .padding(horizontal = 12.dp)
- )
- val serialNumbers = listOf(
- serialNumbers[0],
- " ${serialNumbers[1]}",
- " ${serialNumbers[2]}"
- )
- val serialNumber = remember { mutableIntStateOf(0) }
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(
- text = stringResource(R.string.serial_number),
- style = TextStyle(
- fontSize = 16.sp,
- color = textColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
+ val serials = listOf(
+ serialNumbers[0] to null,
+ serialNumbers[1] to "",
+ serialNumbers[2] to "",
)
- Text(
- text = serialNumbers[serialNumber.intValue],
- style = TextStyle(
- fontSize = 16.sp,
- color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
+ val serialNumber = remember { mutableIntStateOf(0) }
+ Row(
modifier = Modifier
- .clickable(
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp, vertical = 16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(R.string.serial_number),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
- serialNumber.intValue = (serialNumber.intValue + 1) % serialNumbers.size
+ serialNumber.intValue = (serialNumber.intValue + 1) % serials.size
}
+ ) {
+ val (serialText, glyph) = serials[serialNumber.intValue]
+ val icon = glyph?.let { sfSymbolToIcon(it) }
+ if (icon != null) {
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(18.dp),
+ )
+ Spacer(modifier = Modifier.width(6.dp))
+ }
+ Text(
+ text = serialText,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant,
+ modifier = Modifier.padding(horizontal = 16.dp)
+ )
+ NavigationButton(
+ to = "version_info",
+ navController = navController,
+ name = stringResource(R.string.version),
+ currentState = version,
+ independent = false,
+ height = rowHeight.value + 32.dp
)
}
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier
- .padding(horizontal = 12.dp)
- )
- NavigationButton(
- to = "version_info",
- navController = navController,
- name = stringResource(R.string.version),
- currentState = version,
- independent = false,
- height = rowHeight.value + 32.dp
- )
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AppInfoCard.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AppInfoCard.kt
index bc4d08712..b9114733f 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AppInfoCard.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AppInfoCard.kt
@@ -18,32 +18,23 @@
package me.kavishdevar.librepods.presentation.components
-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.fillMaxWidth
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
@@ -51,142 +42,108 @@ import me.kavishdevar.librepods.R
fun AppInfoCard() {
val rowHeight = remember { mutableStateOf(0.dp) }
val density = LocalDensity.current
- val isDarkTheme = isSystemInDarkTheme()
- val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
- val textColor = if (isDarkTheme) Color.White else Color.Black
Column {
- Box(
- modifier = Modifier
- .background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
- .padding(start = 16.dp, bottom = 8.dp, end = 4.dp)
- ) {
- Text(
- text = stringResource(R.string.about), style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- }
+ Text(
+ text = stringResource(R.string.about),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 20.dp, end = 16.dp, top = 8.dp, bottom = 6.dp)
+ )
- Column(
- modifier = Modifier
- .clip(RoundedCornerShape(28.dp))
- .fillMaxWidth()
- .background(backgroundColor, RoundedCornerShape(28.dp))
+ Surface(
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.fillMaxWidth()
) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp)
- .onGloballyPositioned { coordinates ->
- rowHeight.value = with(density) { coordinates.size.height.toDp() }
- },
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(
- text = stringResource(R.string.version), style = TextStyle(
- fontSize = 16.sp,
- color = textColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
+ Column {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp, vertical = 16.dp)
+ .onGloballyPositioned { coordinates ->
+ rowHeight.value = with(density) { coordinates.size.height.toDp() }
+ },
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ text = stringResource(R.string.version),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
)
- )
- Text(
- text = BuildConfig.VERSION_NAME, style = TextStyle(
- fontSize = 16.sp,
- color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
- alpha = 0.8f
- ),
- fontFamily = FontFamily(Font(R.font.sf_pro))
+ Text(
+ text = BuildConfig.VERSION_NAME,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
)
+ }
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant,
+ modifier = Modifier.padding(horizontal = 20.dp)
)
- }
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier.padding(horizontal = 12.dp)
- )
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(
- text = stringResource(R.string.version_code), style = TextStyle(
- fontSize = 16.sp,
- color = textColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp, vertical = 16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ text = stringResource(R.string.version_code),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
)
- )
- Text(
- text = BuildConfig.VERSION_CODE.toString(), style = TextStyle(
- fontSize = 16.sp,
- color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
- alpha = 0.8f
- ),
- fontFamily = FontFamily(Font(R.font.sf_pro))
+ Text(
+ text = BuildConfig.VERSION_CODE.toString(),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
)
+ }
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant,
+ modifier = Modifier.padding(horizontal = 20.dp)
)
- }
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier.padding(horizontal = 12.dp)
- )
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(
- text = stringResource(R.string.flavor), style = TextStyle(
- fontSize = 16.sp,
- color = textColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp, vertical = 16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ text = stringResource(R.string.flavor),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
)
- )
- Text(
- text = BuildConfig.FLAVOR, style = TextStyle(
- fontSize = 16.sp,
- color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
- alpha = 0.8f
- ),
- fontFamily = FontFamily(Font(R.font.sf_pro))
+ Text(
+ text = BuildConfig.FLAVOR,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
)
+ }
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant,
+ modifier = Modifier.padding(horizontal = 20.dp)
)
- }
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier.padding(horizontal = 12.dp)
- )
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(
- text = stringResource(R.string.build_type), style = TextStyle(
- fontSize = 16.sp,
- color = textColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp, vertical = 16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ text = stringResource(R.string.build_type),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
)
- )
- Text(
- text = BuildConfig.BUILD_TYPE,
- style = TextStyle(
- fontSize = 16.sp,
- color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
- alpha = 0.8f
- ),
- fontFamily = FontFamily(Font(R.font.sf_pro))
+ Text(
+ text = BuildConfig.BUILD_TYPE,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
)
- )
+ }
}
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AudioSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AudioSettings.kt
index c7836d05c..957491744 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AudioSettings.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/AudioSettings.kt
@@ -20,27 +20,18 @@
package me.kavishdevar.librepods.presentation.components
-import androidx.compose.foundation.background
-import androidx.compose.foundation.isSystemInDarkTheme
-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.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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
-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 androidx.navigation.compose.rememberNavController
import me.kavishdevar.librepods.R
@@ -66,97 +57,80 @@ fun AudioSettings(
vendorIdHook: Boolean,
isPremium: Boolean
) {
- val isDarkTheme = isSystemInDarkTheme()
- val textColor = if (isDarkTheme) Color.White else Color.Black
-
if (!adaptiveVolumeCapability && !conversationalAwarenessCapability && !loudSoundReductionCapability && !adaptiveAudioCapability) {
return
}
- Box(
- modifier = Modifier
- .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
- .padding(horizontal = 16.dp, vertical = 4.dp)
- ){
- Text(
- text = stringResource(R.string.audio),
- style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- }
- val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
+ Text(
+ text = stringResource(R.string.audio),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 20.dp, end = 16.dp, top = 8.dp, bottom = 6.dp)
+ )
- Column(
- modifier = Modifier
- .clip(RoundedCornerShape(28.dp))
- .fillMaxWidth()
- .background(backgroundColor, RoundedCornerShape(28.dp))
- .padding(top = 2.dp)
+ Surface(
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ modifier = Modifier.fillMaxWidth()
) {
-
- if (adaptiveVolumeCapability) {
- StyledToggle(
- label = stringResource(R.string.personalized_volume),
- description = stringResource(R.string.personalized_volume_description),
- independent = false,
- checked = adaptiveVolumeChecked,
- onCheckedChange = onAdaptiveVolumeCheckedChange,
- enabled = isPremium
- )
-
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier
- .padding(horizontal = 12.dp)
- )
- }
-
- if (conversationalAwarenessCapability) {
- StyledToggle(
- label = stringResource(R.string.conversational_awareness),
- description = stringResource(R.string.conversational_awareness_description),
- independent = false,
- checked = conversationalAwarenessChecked,
- onCheckedChange = onConversationalAwarenessCheckedChange,
- enabled = isPremium
- )
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier
- .padding(horizontal = 12.dp)
- )
- }
-
- if (loudSoundReductionCapability && vendorIdHook){
- StyledToggle(
- label = stringResource(R.string.loud_sound_reduction),
- description = stringResource(R.string.loud_sound_reduction_description),
- independent = false,
- checked = loudSoundReductionChecked,
- onCheckedChange = onLoudSoundReductionCheckedChange,
- enabled = isPremium
- )
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier
- .padding(horizontal = 12.dp)
- )
- }
-
- if (adaptiveAudioCapability) {
- NavigationButton(
- to = "adaptive_strength",
- name = stringResource(R.string.adaptive_audio),
- navController = navController,
- independent = false
- )
+ Column(
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp)
+ ) {
+
+ if (adaptiveVolumeCapability) {
+ StyledToggle(
+ label = stringResource(R.string.personalized_volume),
+ description = stringResource(R.string.personalized_volume_description),
+ independent = false,
+ checked = adaptiveVolumeChecked,
+ onCheckedChange = onAdaptiveVolumeCheckedChange,
+ enabled = isPremium
+ )
+
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+ }
+
+ if (conversationalAwarenessCapability) {
+ StyledToggle(
+ label = stringResource(R.string.conversational_awareness),
+ description = stringResource(R.string.conversational_awareness_description),
+ independent = false,
+ checked = conversationalAwarenessChecked,
+ onCheckedChange = onConversationalAwarenessCheckedChange,
+ enabled = isPremium
+ )
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+ }
+
+ if (loudSoundReductionCapability && vendorIdHook){
+ StyledToggle(
+ label = stringResource(R.string.loud_sound_reduction),
+ description = stringResource(R.string.loud_sound_reduction_description),
+ independent = false,
+ checked = loudSoundReductionChecked,
+ onCheckedChange = onLoudSoundReductionCheckedChange,
+ enabled = isPremium
+ )
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+ }
+
+ if (adaptiveAudioCapability) {
+ NavigationButton(
+ to = "adaptive_strength",
+ name = stringResource(R.string.adaptive_audio),
+ navController = navController,
+ independent = false
+ )
+ }
}
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryIndicator.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryIndicator.kt
index a2a5804e0..1b2eca773 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryIndicator.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryIndicator.kt
@@ -18,7 +18,6 @@
package me.kavishdevar.librepods.presentation.components
-
import android.content.res.Configuration
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
@@ -31,6 +30,10 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Bolt
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -39,25 +42,25 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
-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.style.TextAlign
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.data.BatteryStatus
import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin
import kotlin.math.sqrt
+/**
+ * Material 3 Expressive battery ring: a circular progress arc, a Material charging-bolt that springs
+ * in when charging, and a percentage label. Healthy levels use a green fill, low levels the theme
+ * error color; the track uses a tonal surface color.
+ */
@Composable
fun BatteryIndicator(
batteryPercentage: Int,
@@ -65,12 +68,10 @@ fun BatteryIndicator(
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 colorScheme = MaterialTheme.colorScheme
+ val healthy = if (isSystemInDarkTheme()) Color(0xFF2ED158) else Color(0xFF1FA94B)
+ val batteryFillColor = if (batteryPercentage > 25) healthy else colorScheme.error
+ val trackColor = colorScheme.surfaceContainerHighest
val initialScale = if (previousCharging) 1f else 0f
val scaleAnim = remember { Animatable(initialScale) }
@@ -82,7 +83,7 @@ fun BatteryIndicator(
}
Column(
- modifier = Modifier.background(backgroundColor).padding(4.dp), // just for haze to work
+ modifier = Modifier.padding(4.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
@@ -91,20 +92,12 @@ fun BatteryIndicator(
val strokeWidthPx = with(LocalDensity.current) { 4.dp.toPx() }
val gapFromCenterPx = with(LocalDensity.current) { 8.sp.toPx() }
- val trackColor = if (isDarkTheme) Color(0xFF272728) else Color(0xFFE3E3E8)
val optimizedLimit = 0.8f
val progress = batteryPercentage / 100f
- Canvas(modifier = Modifier.size(34.dp)) {
+ Canvas(modifier = Modifier.size(38.dp)) {
val startAngle = -90f
val stroke = Stroke(width = strokeWidthPx, cap = StrokeCap.Round)
- val inset = strokeWidthPx / 2
- Rect(
- left = inset,
- top = inset,
- right = size.width - inset,
- bottom = size.height - inset
- )
val radius = size.minDimension / 2
if (status == BatteryStatus.OPTIMIZED_CHARGING) {
@@ -125,7 +118,6 @@ fun BatteryIndicator(
style = stroke
)
- // ---- PILL MARKER AT 80% ----
val angleDeg = startAngle + 360f * optimizedLimit
val angleRad = Math.toRadians(angleDeg.toDouble())
@@ -173,26 +165,23 @@ fun BatteryIndicator(
}
}
- Text(
- text = "\uDBC0\uDEE6", style = TextStyle(
- fontSize = 14.sp,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- color = batteryFillColor,
- textAlign = TextAlign.Center
- ), modifier = Modifier.scale(scaleAnim.value)
+ Icon(
+ imageVector = Icons.Filled.Bolt,
+ contentDescription = null,
+ tint = batteryFillColor,
+ modifier = Modifier
+ .size(16.dp)
+ .scale(scaleAnim.value)
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
- text = "$prefix $batteryPercentage%",
- color = batteryTextColor,
- style = TextStyle(
- fontSize = 14.sp,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- textAlign = TextAlign.Center
- ),
+ text = if (prefix.isBlank()) "$batteryPercentage%" else "$prefix $batteryPercentage%",
+ color = colorScheme.onSurface,
+ style = MaterialTheme.typography.labelLarge,
+ textAlign = TextAlign.Center,
)
}
}
@@ -200,14 +189,15 @@ fun BatteryIndicator(
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
fun BatteryIndicatorPreview() {
- val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
Box(
- modifier = Modifier.background(bg)
+ modifier = Modifier
+ .background(MaterialTheme.colorScheme.background)
+ .padding(16.dp)
) {
BatteryIndicator(
batteryPercentage = 50,
status = BatteryStatus.OPTIMIZED_CHARGING,
- prefix = "\uDBC6\uDCE5",
+ prefix = "L",
previousCharging = false
)
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryView.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryView.kt
index 7accabee7..835c11e0f 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryView.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/BatteryView.kt
@@ -23,7 +23,6 @@ package me.kavishdevar.librepods.presentation.components
import android.content.res.Configuration
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
@@ -33,12 +32,12 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
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.graphics.ImageBitmap
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource
@@ -64,9 +63,6 @@ fun BatteryView(
val rightLevel = right?.level ?: 0
val caseLevel = case?.level ?: 0
- val caseCharging = case?.status == BatteryStatus.CHARGING ||
- case?.status == BatteryStatus.OPTIMIZED_CHARGING
-
val singleDisplayed = remember { mutableStateOf(false) }
Box(
@@ -109,7 +105,7 @@ fun BatteryView(
BatteryIndicator(
leftLevel,
left?.status ?: BatteryStatus.NOT_CHARGING,
- "\uDBC6\uDCE5"
+ "L"
)
}
@@ -121,7 +117,7 @@ fun BatteryView(
BatteryIndicator(
rightLevel,
right?.status ?: BatteryStatus.NOT_CHARGING,
- "\uDBC6\uDCE8"
+ "R"
)
}
}
@@ -144,7 +140,7 @@ fun BatteryView(
BatteryIndicator(
caseLevel,
case?.status ?: BatteryStatus.NOT_CHARGING,
- prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else ""
+ prefix = if (!singleDisplayed.value) stringResource(R.string.case_alt) else ""
)
}
}
@@ -161,11 +157,9 @@ fun BatteryViewPreview() {
Battery(BatteryComponent.CASE, 60, BatteryStatus.NOT_CHARGING)
)
- val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
-
Box(
modifier = Modifier
- .background(bg)
+ .background(MaterialTheme.colorScheme.background)
.padding(16.dp)
) {
BatteryView(
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/CallControlSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/CallControlSettings.kt
index 2b00c06ed..856db8429 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/CallControlSettings.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/CallControlSettings.kt
@@ -21,10 +21,8 @@
package me.kavishdevar.librepods.presentation.components
import android.util.Log
-import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures
-import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -32,8 +30,13 @@ import androidx.compose.foundation.layout.Row
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.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -46,7 +49,6 @@ 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.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
@@ -54,12 +56,7 @@ import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
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 dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch
@@ -73,31 +70,27 @@ fun CallControlSettings(
flipped: Boolean,
onCallControlValueChanged: (Boolean) -> Unit
) {
- val isDarkTheme = isSystemInDarkTheme()
- val textColor = if (isDarkTheme) Color.White else Color.Black
- val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Box(
modifier = Modifier
- .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
- .padding(horizontal = 16.dp, vertical = 4.dp)
+ .padding(start = 20.dp, end = 16.dp, top = 8.dp, bottom = 6.dp)
){
Text(
text = stringResource(R.string.call_controls),
- style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .background(backgroundColor, RoundedCornerShape(28.dp))
- .padding(top = 2.dp)
+ Surface(
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ modifier = Modifier.fillMaxWidth()
) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 2.dp)
+ ) {
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
@@ -138,34 +131,34 @@ fun CallControlSettings(
Row(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 16.dp)
+ .padding(horizontal = 20.dp)
.height(58.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = stringResource(R.string.answer_call),
- fontSize = 16.sp,
- color = textColor,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 4.dp)
)
Text(
text = stringResource(R.string.press_once),
- fontSize = 16.sp,
- color = textColor.copy(alpha = 0.6f)
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
HorizontalDivider(
thickness = 1.dp,
- color = Color(0x40888888),
+ color = MaterialTheme.colorScheme.outlineVariant,
modifier = Modifier
- .padding(horizontal = 12.dp)
+ .padding(horizontal = 16.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 16.dp)
+ .padding(horizontal = 20.dp)
.height(58.dp)
.pointerInput(Unit) {
detectTapGestures { offset ->
@@ -235,8 +228,8 @@ fun CallControlSettings(
) {
Text(
text = stringResource(R.string.mute_unmute),
- fontSize = 16.sp,
- color = textColor,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 4.dp)
)
Box(
@@ -249,21 +242,16 @@ fun CallControlSettings(
) {
Text(
text = singlePressAction,
- style = TextStyle(
- fontSize = 16.sp,
- color = textColor.copy(alpha = 0.8f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
)
- Text(
- text = "",
- style = TextStyle(
- fontSize = 16.sp,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
+ Icon(
+ imageVector = Icons.Filled.KeyboardArrowDown,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(start = 6.dp)
+ .size(20.dp)
)
}
@@ -293,15 +281,15 @@ fun CallControlSettings(
}
HorizontalDivider(
thickness = 1.dp,
- color = Color(0x40888888),
+ color = MaterialTheme.colorScheme.outlineVariant,
modifier = Modifier
- .padding(horizontal = 12.dp)
+ .padding(horizontal = 16.dp)
)
Row(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 16.dp)
+ .padding(horizontal = 20.dp)
.height(58.dp)
.pointerInput(Unit) {
detectTapGestures { offset ->
@@ -371,8 +359,8 @@ fun CallControlSettings(
) {
Text(
text = stringResource(R.string.hang_up),
- fontSize = 16.sp,
- color = textColor,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 4.dp)
)
Box(
@@ -385,21 +373,16 @@ fun CallControlSettings(
) {
Text(
text = doublePressAction,
- style = TextStyle(
- fontSize = 16.sp,
- color = textColor.copy(alpha = 0.8f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
)
- Text(
- text = "",
- style = TextStyle(
- fontSize = 16.sp,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
+ Icon(
+ imageVector = Icons.Filled.KeyboardArrowDown,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.padding(start = 6.dp)
+ .size(20.dp)
)
}
@@ -428,5 +411,6 @@ fun CallControlSettings(
}
}
}
+ }
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConfirmationDialog.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConfirmationDialog.kt
index 35e538ad9..243311bf6 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConfirmationDialog.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConfirmationDialog.kt
@@ -25,7 +25,6 @@ import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
-import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -36,30 +35,23 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredWidthIn
-import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Color
-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.sp
import com.kyant.backdrop.backdrops.LayerBackdrop
-import com.kyant.backdrop.backdrops.rememberLayerBackdrop
-import com.kyant.backdrop.drawBackdrop
-import com.kyant.backdrop.effects.blur
-import com.kyant.backdrop.effects.lens
-import com.kyant.backdrop.effects.vibrancy
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
-import me.kavishdevar.librepods.R
+/**
+ * Material 3 Expressive confirmation dialog. A scrim-backed [Surface] card (extra-large rounded
+ * corners, `surfaceContainerHigh` tonal fill) that scales in with the expressive motion scheme.
+ * Signature preserved: [backdrop] is retained for source compatibility but is no longer used.
+ */
@ExperimentalHazeMaterialsApi
@Composable
fun ConfirmationDialog(
@@ -70,108 +62,78 @@ fun ConfirmationDialog(
dismissText: String = "Cancel",
onConfirm: () -> Unit,
onDismiss: () -> Unit = { showDialog.value = false },
- backdrop: LayerBackdrop,
+ @Suppress("UNUSED_PARAMETER") backdrop: LayerBackdrop,
) {
- val isDarkTheme = isSystemInDarkTheme()
- val textColor = if (isDarkTheme) Color.White else Color.Black
- val accentColor = if (isDarkTheme) Color(0xFF0091FF) else Color(0xFF0088FF)
-
AnimatedVisibility(
visible = showDialog.value,
- enter = scaleIn(initialScale = 1.05f) + fadeIn(),
- exit = scaleOut(targetScale = 1.05f) + fadeOut()
+ enter = scaleIn(initialScale = 0.9f) + fadeIn(),
+ exit = scaleOut(targetScale = 0.9f) + fadeOut()
) {
Box(
- modifier = Modifier.fillMaxSize(),
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.4f))
+ .clickable(enabled = false, onClick = {}),
contentAlignment = Alignment.Center
) {
- val innerBackdrop = rememberLayerBackdrop()
- Box(
+ Surface(
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ contentColor = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
- .fillMaxSize()
- .background(Color.Black.copy(alpha = 0.4f))
- .clickable(enabled = false, onClick = {}),
- contentAlignment = Alignment.Center
+ .padding(24.dp)
+ .requiredWidthIn(min = 200.dp, max = 360.dp)
) {
- Box(
- modifier = Modifier
- .requiredWidthIn(min = 200.dp, max = 360.dp)
- .clip(RoundedCornerShape(48.dp))
- .drawBackdrop(
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Spacer(modifier = Modifier.height(24.dp))
+ Text(
+ title,
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(horizontal = 24.dp)
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ Text(
+ message,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(horizontal = 24.dp)
+ )
+ Spacer(modifier = Modifier.height(24.dp))
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ StyledButton(
+ onClick = onDismiss,
+ backdrop = backdrop,
+ modifier = Modifier.weight(1f),
+ surfaceColor = MaterialTheme.colorScheme.surfaceContainerHighest,
+ tint = MaterialTheme.colorScheme.onSurface,
+ ) {
+ Text(
+ text = dismissText,
+ style = MaterialTheme.typography.labelLarge,
+ )
+ }
+ StyledButton(
+ onClick = onConfirm,
backdrop = backdrop,
- exportedBackdrop = innerBackdrop,
- shape = { RoundedCornerShape(48.dp) },
- effects = {
- vibrancy()
- blur(4f.dp.toPx())
- lens(12f.dp.toPx(), 48f.dp.toPx(), true)
- },
- onDrawSurface = {
- drawRect(
- if (isDarkTheme) Color(0xFF1F1F1F).copy(alpha = 0.35f) else Color(0xFFE0E0E0).copy(alpha = 0.7f)
- )
- })) {
- Column(horizontalAlignment = Alignment.CenterHorizontally) {
- Spacer(modifier = Modifier.height(24.dp))
- Text(
- title,
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Bold,
- color = textColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- textAlign = TextAlign.Center,
- modifier = Modifier.padding(horizontal = 16.dp)
- )
- Spacer(modifier = Modifier.height(12.dp))
- Text(
- message,
- style = TextStyle(
- fontSize = 14.sp,
- color = textColor.copy(alpha = 0.8f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- textAlign = TextAlign.Center,
- modifier = Modifier.padding(horizontal = 16.dp)
- )
- Spacer(modifier = Modifier.height(16.dp))
- Row(
- modifier = Modifier.fillMaxWidth(0.9f),
- horizontalArrangement = Arrangement.spacedBy(24.dp)
+ modifier = Modifier.weight(1f),
+ surfaceColor = MaterialTheme.colorScheme.primary,
+ tint = MaterialTheme.colorScheme.onPrimary,
) {
- StyledButton(
- onClick = onDismiss,
- backdrop = innerBackdrop,
- modifier = Modifier.weight(1f),
- ) {
- Text(
- text = dismissText, style = TextStyle(
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- fontWeight = FontWeight.Medium,
- fontSize = 14.sp,
- color = textColor
- )
- )
- }
- StyledButton(
- onClick = onConfirm,
- backdrop = innerBackdrop,
- modifier = Modifier.weight(1f),
- surfaceColor = accentColor
- ) {
- Text(
- text = confirmText, style = TextStyle(
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- fontWeight = FontWeight.Medium,
- fontSize = 14.sp,
- color = Color.White
- )
- )
- }
+ Text(
+ text = confirmText,
+ style = MaterialTheme.typography.labelLarge,
+ )
}
- Spacer(modifier = Modifier.height(24.dp))
}
+ Spacer(modifier = Modifier.height(24.dp))
}
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConnectionSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConnectionSettings.kt
index ba5481852..617d8352f 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConnectionSettings.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ConnectionSettings.kt
@@ -20,16 +20,14 @@
package me.kavishdevar.librepods.presentation.components
-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.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R
@@ -42,34 +40,34 @@ fun ConnectionSettings(
automaticConnectionEnabled: Boolean,
onAutomaticConnectionChanged: (Boolean) -> Unit,
) {
- val isDarkTheme = isSystemInDarkTheme()
- val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
-
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .background(backgroundColor, RoundedCornerShape(28.dp))
- .padding(top = 2.dp)
+ Surface(
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ modifier = Modifier.fillMaxWidth()
) {
- StyledToggle(
- label = stringResource(R.string.ear_detection),
- independent = false,
- checked = automaticEarDetectionEnabled,
- onCheckedChange = onAutomaticEarDetectionChanged
- )
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
+ Column(
modifier = Modifier
- .padding(horizontal = 12.dp)
- )
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 4.dp)
+ ) {
+ StyledToggle(
+ label = stringResource(R.string.ear_detection),
+ independent = false,
+ checked = automaticEarDetectionEnabled,
+ onCheckedChange = onAutomaticEarDetectionChanged
+ )
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant,
+ )
- StyledToggle(
- label = stringResource(R.string.automatically_connect),
- description = stringResource(R.string.automatically_connect_description),
- independent = false,
- checked = automaticConnectionEnabled,
- onCheckedChange = onAutomaticConnectionChanged
- )
+ StyledToggle(
+ label = stringResource(R.string.automatically_connect),
+ description = stringResource(R.string.automatically_connect_description),
+ independent = false,
+ checked = automaticConnectionEnabled,
+ onCheckedChange = onAutomaticConnectionChanged
+ )
+ }
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ControlCenterButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ControlCenterButton.kt
deleted file mode 100644
index 5340b0cae..000000000
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ControlCenterButton.kt
+++ /dev/null
@@ -1,106 +0,0 @@
-/*
- LibrePods - AirPods liberated from Apple’s ecosystem
- Copyright (C) 2025 LibrePods contributors
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License
- along with this program. If not, see .
-*/
-
-@file:Suppress("unused")
-
-package me.kavishdevar.librepods.presentation.components
-
-import androidx.compose.animation.animateColorAsState
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.material3.Icon
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.remember
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Brush
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.painter.Painter
-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
-
-private val SelectedColorBlue = Color(0xFF0A84FF)
-private val UnselectedColor = Color(0x593C3C3E)
-private val TextColor = Color.White
-private val IconTint = Color.White
-
-@Composable
-fun ControlCenterButton(
- label: String,
- icon: Painter,
- onClick: () -> Unit,
- modifier: Modifier = Modifier,
- iconAreaSize: Dp,
- isSelected: Boolean,
- backgroundBrush: Brush? = null
-) {
- val targetBackgroundColor = if (isSelected) SelectedColorBlue else UnselectedColor
- val backgroundColor by animateColorAsState(
- targetValue = targetBackgroundColor,
- label = "ButtonBackground"
- )
-
- Column(
- modifier = modifier,
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center
- ) {
- Box(
- modifier = Modifier
- .size(iconAreaSize)
- .clip(CircleShape)
- .background(backgroundBrush ?: Brush.linearGradient(colors=listOf(backgroundColor, backgroundColor)))
- .clickable(
- onClick = onClick,
- indication = null,
- interactionSource = remember { MutableInteractionSource() }
- ),
- contentAlignment = Alignment.Center
- ) {
- Icon(
- painter = icon,
- contentDescription = null,
- tint = IconTint,
- modifier = Modifier.size(32.dp)
- )
- }
- Spacer(modifier = Modifier.height(8.dp))
- Text(
- text = label,
- color = TextColor,
- fontSize = 12.sp,
- fontWeight = FontWeight.Medium,
- textAlign = TextAlign.Center,
- maxLines = 2
- )
- }
-}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ControlCenterNoiseControlSegmentedButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ControlCenterNoiseControlSegmentedButton.kt
index ca0d1f4c8..c7f633c91 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ControlCenterNoiseControlSegmentedButton.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ControlCenterNoiseControlSegmentedButton.kt
@@ -36,6 +36,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -58,27 +59,25 @@ import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.NoiseControlMode
-private val ContainerColor = Color(0x593C3C3E)
-private val SelectedIndicatorColorGray = Color(0xFF6C6C6E)
-private val SelectedIndicatorColorBlue = Color(0xFF0A84FF)
-private val TextColor = Color.White
-private val IconTintUnselected = Color.White
-private val IconTintSelected = Color.White
-
-internal val AdaptiveRainbowBrush = Brush.sweepGradient(
- colors = listOf(
- Color(0xFFB03A2F), Color(0xFFB07A2F), Color(0xFFB0A22F), Color(0xFF6AB02F),
- Color(0xFF2FAAB0), Color(0xFF2F5EB0), Color(0xFF7D2FB0), Color(0xFFB02F7D),
- Color(0xFFB03A2F)
- )
-)
-
internal val IconAreaSize = 72.dp
private val IconSize = 42.dp
private val IconRowHeight = IconAreaSize + 12.dp
private val TextRowHeight = 24.dp
private val TextSize = 12.sp
+/** Vibrant rainbow sweep used to represent the "Adaptive" noise-control mode. */
+internal val AdaptiveRainbowBrush = Brush.sweepGradient(
+ colors = listOf(
+ Color(0xFFFF5252), Color(0xFFFFB300), Color(0xFF66BB6A), Color(0xFF26C6DA),
+ Color(0xFF5C6BC0), Color(0xFFAB47BC), Color(0xFFFF5252)
+ )
+)
+
+/**
+ * Material 3 Expressive segmented noise-control selector for the Quick-Settings control center.
+ * A vibrant primary "pill" slides between modes with a low-damping (bouncy) spring; the adaptive
+ * mode shows a playful theme-colored rainbow. Signature & behaviour preserved.
+ */
@Composable
fun ControlCenterNoiseControlSegmentedButton(
modifier: Modifier = Modifier,
@@ -86,28 +85,36 @@ fun ControlCenterNoiseControlSegmentedButton(
selectedMode: NoiseControlMode,
onModeSelected: (NoiseControlMode) -> Unit
) {
+ val colorScheme = MaterialTheme.colorScheme
+ val containerColor = colorScheme.surfaceContainerHighest
+ val selectedColor = colorScheme.primary
+ val onSelectedColor = colorScheme.onPrimary
+ val unselectedColor = colorScheme.onSurfaceVariant
+
+ val adaptiveBrush = remember(colorScheme) {
+ Brush.sweepGradient(
+ listOf(
+ colorScheme.primary, colorScheme.tertiary, colorScheme.secondary,
+ colorScheme.primary
+ )
+ )
+ }
+
val selectedIndex = availableModes.indexOf(selectedMode).coerceAtLeast(0)
val density = LocalDensity.current
var iconRowWidthPx by remember { mutableFloatStateOf(0f) }
val itemCount = availableModes.size
val itemSlotWidthPx = remember(iconRowWidthPx, itemCount) {
- if (itemCount > 0 && iconRowWidthPx > 0) {
- iconRowWidthPx / itemCount
- } else {
- 0f
- }
+ if (itemCount > 0 && iconRowWidthPx > 0) iconRowWidthPx / itemCount else 0f
}
val itemSlotWidthDp = remember(itemSlotWidthPx) { with(density) { itemSlotWidthPx.toDp() } }
val iconAreaSizePx = remember { with(density) { IconAreaSize.toPx() } }
val targetIndicatorStartPx = remember(selectedIndex, itemSlotWidthPx, iconAreaSizePx) {
if (itemSlotWidthPx > 0) {
- val slotCenterPx = (selectedIndex + 0.5f) * itemSlotWidthPx
- slotCenterPx - (iconAreaSizePx / 2f)
- } else {
- 0f
- }
+ (selectedIndex + 0.5f) * itemSlotWidthPx - (iconAreaSizePx / 2f)
+ } else 0f
}
val indicatorOffset: Dp by animateDpAsState(
@@ -119,15 +126,6 @@ fun ControlCenterNoiseControlSegmentedButton(
label = "IndicatorOffset"
)
- val indicatorBackground = remember(selectedMode) {
- when (selectedMode) {
- NoiseControlMode.ADAPTIVE -> AdaptiveRainbowBrush
- NoiseControlMode.OFF -> Brush.linearGradient(colors=listOf(SelectedIndicatorColorGray, SelectedIndicatorColorGray))
- NoiseControlMode.TRANSPARENCY,
- NoiseControlMode.NOISE_CANCELLATION -> Brush.linearGradient(colors=listOf(SelectedIndicatorColorBlue, SelectedIndicatorColorBlue))
- }
- }
-
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
@@ -137,7 +135,7 @@ fun ControlCenterNoiseControlSegmentedButton(
.fillMaxWidth()
.height(IconRowHeight)
.clip(CircleShape)
- .background(ContainerColor)
+ .background(containerColor)
.onSizeChanged { iconRowWidthPx = it.width.toFloat() },
contentAlignment = Alignment.Center
) {
@@ -147,11 +145,19 @@ fun ControlCenterNoiseControlSegmentedButton(
.offset(x = indicatorOffset)
.size(IconAreaSize)
.clip(CircleShape)
- .background(indicatorBackground)
+ .then(
+ if (selectedMode == NoiseControlMode.ADAPTIVE) {
+ Modifier.background(adaptiveBrush)
+ } else {
+ Modifier.background(selectedColor)
+ }
+ )
)
Row(
- modifier = Modifier.fillMaxWidth().align(Alignment.Center),
+ modifier = Modifier
+ .fillMaxWidth()
+ .align(Alignment.Center),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceAround
) {
@@ -160,7 +166,7 @@ fun ControlCenterNoiseControlSegmentedButton(
NoiseControlIconItem(
modifier = Modifier.size(IconAreaSize),
mode = mode,
- isSelected = isSelected,
+ tint = if (isSelected) onSelectedColor else unselectedColor,
onClick = { onModeSelected(mode) }
)
}
@@ -180,7 +186,7 @@ fun ControlCenterNoiseControlSegmentedButton(
val isSelected = selectedMode == mode
Text(
text = getModeLabel(mode),
- color = TextColor,
+ color = if (isSelected) colorScheme.onSurface else colorScheme.onSurfaceVariant,
fontSize = TextSize,
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
textAlign = TextAlign.Center,
@@ -195,13 +201,11 @@ fun ControlCenterNoiseControlSegmentedButton(
private fun NoiseControlIconItem(
modifier: Modifier = Modifier,
mode: NoiseControlMode,
- isSelected: Boolean,
+ tint: Color,
onClick: () -> Unit
) {
val iconRes = remember(mode) { getModeIconRes(mode) }
- val tint = IconTintUnselected
-
Box(
modifier = modifier
.clip(CircleShape)
@@ -215,13 +219,12 @@ private fun NoiseControlIconItem(
Icon(
painter = painterResource(id = iconRes),
contentDescription = getModeLabel(mode),
- tint = if (isSelected && mode == NoiseControlMode.ADAPTIVE) IconTintSelected else tint,
+ tint = tint,
modifier = Modifier.size(IconSize)
)
}
}
-
private fun getModeIconRes(mode: NoiseControlMode): Int {
return when (mode) {
NoiseControlMode.OFF -> R.drawable.noise_cancellation
@@ -239,4 +242,3 @@ private fun getModeLabel(mode: NoiseControlMode): String {
NoiseControlMode.NOISE_CANCELLATION -> "Noise Cancellation"
}
}
-
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/DeviceInfoCard.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/DeviceInfoCard.kt
index 18ab494d3..01747e32b 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/DeviceInfoCard.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/DeviceInfoCard.kt
@@ -1,235 +1,100 @@
package me.kavishdevar.librepods.presentation.components
import android.os.Build
-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.fillMaxWidth
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.layout.onGloballyPositioned
-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.text.font.FontWeight
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.utils.XposedState
@Composable
fun DeviceInfoCard() {
- val isDarkTheme = isSystemInDarkTheme()
-
- val textColor = if (isDarkTheme) Color.White else Color.Black
- val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
-
- val rowHeight = remember { mutableStateOf(0.dp) }
- val density = LocalDensity.current
-
- Column (
+ Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
- Box(
- modifier = Modifier
- .background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
- .padding(start = 16.dp, top = 24.dp, end = 4.dp)
+ Text(
+ text = stringResource(R.string.device_info),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 20.dp, end = 16.dp, top = 8.dp, bottom = 6.dp)
+ )
+ Surface(
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.fillMaxWidth()
) {
- Text(
- text = stringResource(R.string.device_info), style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
+ Column(modifier = Modifier.fillMaxWidth()) {
+ DeviceInfoRow(
+ label = stringResource(R.string.manufacturer),
+ value = Build.MANUFACTURER
)
- )
- }
- Column(
- modifier = Modifier
- .clip(RoundedCornerShape(28.dp))
- .fillMaxWidth()
- .background(backgroundColor, RoundedCornerShape(28.dp))
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp)
- .onGloballyPositioned { coordinates ->
- rowHeight.value = with(density) { coordinates.size.height.toDp() }
- },
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(
- text = stringResource(R.string.manufacturer), style = TextStyle(
- fontSize = 16.sp,
- color = textColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
+ DeviceInfoDivider()
+ DeviceInfoRow(
+ label = stringResource(R.string.model_number),
+ value = Build.MODEL
)
- Text(
- text = Build.MANUFACTURER, style = TextStyle(
- fontSize = 16.sp,
- color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
- alpha = 0.8f
- ),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
+ DeviceInfoDivider()
+ DeviceInfoRow(
+ label = stringResource(R.string.build_id),
+ value = Build.DISPLAY
)
- }
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier.padding(horizontal = 12.dp)
- )
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(
- text = stringResource(R.string.model_number), style = TextStyle(
- fontSize = 16.sp,
- color = textColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
+ DeviceInfoDivider()
+ DeviceInfoRow(
+ label = stringResource(R.string.version),
+ value = Build.ID + " (${Build.VERSION.SDK_INT_FULL})"
)
- Text(
- text = Build.MODEL, style = TextStyle(
- fontSize = 16.sp,
- color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
- alpha = 0.8f
- ),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
+ DeviceInfoDivider()
+ DeviceInfoRow(
+ label = stringResource(R.string.xposed_available),
+ value = if (XposedState.isAvailable) stringResource(R.string.yes) else stringResource(R.string.no)
)
- }
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier.padding(horizontal = 12.dp)
- )
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(
- text = stringResource(R.string.build_id), style = TextStyle(
- fontSize = 16.sp,
- color = textColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- Text(
- text = Build.DISPLAY, style = TextStyle(
- fontSize = 16.sp,
- color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
- alpha = 0.8f
- ),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- }
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier.padding(horizontal = 12.dp)
- )
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(
- text = stringResource(R.string.version), style = TextStyle(
- fontSize = 16.sp,
- color = textColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- Text(
- text = Build.ID + " (${Build.VERSION.SDK_INT_FULL})",
- style = TextStyle(
- fontSize = 16.sp,
- color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
- alpha = 0.8f
- ),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- }
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier.padding(horizontal = 12.dp)
- )
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(
- text = stringResource(R.string.xposed_available), style = TextStyle(
- fontSize = 16.sp,
- color = textColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- Text(
- text = if (XposedState.isAvailable) stringResource(R.string.yes) else stringResource(R.string.no), style = TextStyle(
- fontSize = 16.sp,
- color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
- alpha = 0.8f
- ),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- }
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier.padding(horizontal = 12.dp)
- )
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(
- text = stringResource(R.string.app_enabled_in_xposed), style = TextStyle(
- fontSize = 16.sp,
- color = textColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- Text(
- text = if (XposedState.bluetoothScopeEnabled) stringResource(R.string.yes) else stringResource(R.string.no), style = TextStyle(
- fontSize = 16.sp,
- color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
- alpha = 0.8f
- ),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
+ DeviceInfoDivider()
+ DeviceInfoRow(
+ label = stringResource(R.string.app_enabled_in_xposed),
+ value = if (XposedState.bluetoothScopeEnabled) stringResource(R.string.yes) else stringResource(R.string.no)
)
}
}
}
}
+
+@Composable
+private fun DeviceInfoRow(label: String, value: String) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp, vertical = 14.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+}
+
+@Composable
+private fun DeviceInfoDivider() {
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant,
+ modifier = Modifier.padding(horizontal = 12.dp)
+ )
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ExpressiveModifiers.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ExpressiveModifiers.kt
new file mode 100644
index 000000000..79e2f4290
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/ExpressiveModifiers.kt
@@ -0,0 +1,57 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.presentation.components
+
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.interaction.InteractionSource
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.getValue
+
+/**
+ * Material 3 Expressive's signature feel is physics-based, slightly-overshooting motion. This helper
+ * gives any clickable surface a springy "squish" on press: scale dips toward [pressedScale] while
+ * held, then bounces back with a low-damping spring. Drive it from a [androidx.compose.foundation.interaction.MutableInteractionSource]
+ * shared with the component's `clickable`/`Surface`/`Button`, e.g.:
+ *
+ * ```
+ * val interaction = remember { MutableInteractionSource() }
+ * val scale by rememberBounceScale(interaction)
+ * Surface(onClick = …, interactionSource = interaction,
+ * modifier = Modifier.graphicsLayer { scaleX = scale; scaleY = scale }) { … }
+ * ```
+ */
+@Composable
+fun rememberBounceScale(
+ interactionSource: InteractionSource,
+ pressedScale: Float = 0.96f,
+): State {
+ val pressed by interactionSource.collectIsPressedAsState()
+ return animateFloatAsState(
+ targetValue = if (pressed) pressedScale else 1f,
+ animationSpec = spring(
+ dampingRatio = 0.42f,
+ stiffness = Spring.StiffnessMedium,
+ ),
+ label = "bounceScale",
+ )
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HearingHealthSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HearingHealthSettings.kt
index a444ddae8..8819a4a8a 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HearingHealthSettings.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/HearingHealthSettings.kt
@@ -20,26 +20,17 @@
package me.kavishdevar.librepods.presentation.components
-import androidx.compose.foundation.background
-import androidx.compose.foundation.isSystemInDarkTheme
-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.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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
-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 me.kavishdevar.librepods.R
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -51,55 +42,42 @@ fun HearingHealthSettings(
hasHearingAidCapability: Boolean,
vendorIdHook: Boolean
) {
- val isDarkTheme = isSystemInDarkTheme()
- val textColor = if (isDarkTheme) Color.White else Color.Black
- val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val shouldShowHearingAid = hasHearingAidCapability && vendorIdHook
if (hasPPECapability && shouldShowHearingAid) {
- Box(
- modifier = Modifier
- .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
- .padding(horizontal = 16.dp, vertical = 4.dp)
- ){
- Text(
- text = stringResource(R.string.hearing_health),
- style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- }
- Column(
- modifier = Modifier
- .clip(RoundedCornerShape(28.dp))
- .fillMaxWidth()
- .background(backgroundColor, RoundedCornerShape(28.dp))
- .padding(top = 2.dp)
+ Text(
+ text = stringResource(R.string.hearing_health),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 20.dp, end = 16.dp, top = 8.dp, bottom = 6.dp)
+ )
+ Surface(
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.fillMaxWidth()
) {
- NavigationButton(
- to = "hearing_protection",
- name = stringResource(R.string.hearing_protection),
- navController = navController,
- independent = false
- )
-
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier
- .padding(horizontal = 12.dp)
- )
+ Column {
+ NavigationButton(
+ to = "hearing_protection",
+ name = stringResource(R.string.hearing_protection),
+ navController = navController,
+ independent = false
+ )
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant,
+ modifier = Modifier.padding(horizontal = 20.dp)
+ )
- NavigationButton(
- to = "hearing_aid",
- name = stringResource(R.string.hearing_aid),
- navController = navController,
- independent = false
- )
+ NavigationButton(
+ to = "hearing_aid",
+ name = stringResource(R.string.hearing_aid),
+ navController = navController,
+ independent = false
+ )
+ }
}
} else if (shouldShowHearingAid) {
NavigationButton(
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/IconMappings.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/IconMappings.kt
new file mode 100644
index 000000000..59dd32c38
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/IconMappings.kt
@@ -0,0 +1,63 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.presentation.components
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
+import androidx.compose.material.icons.automirrored.filled.VolumeDown
+import androidx.compose.material.icons.automirrored.filled.VolumeUp
+import androidx.compose.material.icons.filled.Cancel
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material.icons.filled.KeyboardArrowUp
+import androidx.compose.material.icons.filled.Numbers
+import androidx.compose.material.icons.filled.Sensors
+import androidx.compose.material.icons.filled.SensorsOff
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.Tag
+import androidx.compose.ui.graphics.vector.ImageVector
+
+/*
+ * The old UI rendered Apple SF Symbols as text glyphs (only renderable with the bundled SF Pro
+ * font). The Material 3 redesign uses real Material vector icons instead. This map lets the shared
+ * components and screens convert the legacy glyph strings to [ImageVector]s with zero call-site
+ * churn — `StyledIconButton(icon = "")` keeps compiling but now draws a Material Settings icon.
+ *
+ * Returns null for an unrecognised glyph so callers can fall back gracefully.
+ */
+fun sfSymbolToIcon(glyph: String): ImageVector? = when (glyph) {
+ "" -> Icons.AutoMirrored.Filled.ArrowBack // U+100BF6 chevron.backward (back)
+ "" -> Icons.AutoMirrored.Filled.KeyboardArrowRight // U+100BFB chevron.forward (disclosure)
+ "" -> Icons.Filled.Settings // U+10035F gearshape
+ "" -> Icons.Filled.Delete // U+100211 trash
+ "" -> Icons.Filled.KeyboardArrowUp // U+100189 chevron.up (sent)
+ "" -> Icons.Filled.KeyboardArrowDown // U+10018A chevron.down (received)
+ "" -> Icons.Filled.KeyboardArrowDown // U+10018F chevron.down (disclosure)
+ "" -> Icons.AutoMirrored.Filled.VolumeDown // U+1002A5 speaker.wave.1
+ "" -> Icons.AutoMirrored.Filled.VolumeUp // U+1002A9 speaker.wave.3
+ "" -> Icons.Filled.Sensors // U+100285 head tracking active
+ "" -> Icons.Filled.SensorsOff // U+100283 head tracking inactive
+ "" -> Icons.Filled.Cancel // U+100061 xmark.circle.fill (clear)
+ "" -> Icons.Filled.Check // U+100185 checkmark
+ "" -> Icons.Filled.Tag // U+10001B left bud badge
+ "" -> Icons.Filled.Numbers // U+100027 right bud badge
+ else -> null
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/MicrophoneSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/MicrophoneSettings.kt
index 5f693e9b9..deec60533 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/MicrophoneSettings.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/MicrophoneSettings.kt
@@ -20,10 +20,8 @@
package me.kavishdevar.librepods.presentation.components
-import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.detectTapGestures
-import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -31,7 +29,12 @@ import androidx.compose.foundation.layout.Row
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.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -43,7 +46,6 @@ 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.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
@@ -51,11 +53,7 @@ import androidx.compose.ui.layout.positionInParent
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
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.unit.dp
-import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch
@@ -69,194 +67,188 @@ fun MicrophoneSettings(
micModeValue: Byte,
onMicModeValueChanged: (Byte) -> Unit
) {
- val isDarkTheme = isSystemInDarkTheme()
- val textColor = if (isDarkTheme) Color.White else Color.Black
- val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
-
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .background(backgroundColor, RoundedCornerShape(28.dp))
- .padding(top = 2.dp)
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ contentColor = MaterialTheme.colorScheme.onSurface
) {
- var selectedMode by remember {
- mutableStateOf(
- when (micModeValue) {
- 0x00.toByte() -> "Automatic"
- 0x01.toByte() -> "Always Right"
- 0x02.toByte() -> "Always Left"
- else -> "Automatic"
- }
- )
- }
- var showDropdown by remember { mutableStateOf(false) }
- var touchOffset by remember { mutableStateOf(null) }
- var boxPosition by remember { mutableStateOf(Offset.Zero) }
- var lastDismissTime by remember { mutableLongStateOf(0L) }
- val reopenThresholdMs = 250L
-
- val density = LocalDensity.current
- val itemHeightPx = with(density) { 48.dp.toPx() }
- var parentHoveredIndex by remember { mutableStateOf(null) }
- var parentDragActive by remember { mutableStateOf(false) }
- var previousIdx by remember { mutableStateOf(null) }
- val haptics = LocalHapticFeedback.current
- val scope = rememberCoroutineScope()
- val microphoneAutomaticText = stringResource(R.string.microphone_automatic)
- val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right)
- val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left)
- Row(
+ Column(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 16.dp)
- .height(58.dp)
- .pointerInput(Unit) {
- detectTapGestures { offset ->
- val now = System.currentTimeMillis()
- if (showDropdown) {
- showDropdown = false
- lastDismissTime = now
- } else {
- if (now - lastDismissTime > reopenThresholdMs) {
- touchOffset = offset
- showDropdown = true
- }
- }
+ .padding(top = 2.dp)
+ ) {
+ var selectedMode by remember {
+ mutableStateOf(
+ when (micModeValue) {
+ 0x00.toByte() -> "Automatic"
+ 0x01.toByte() -> "Always Right"
+ 0x02.toByte() -> "Always Left"
+ else -> "Automatic"
}
- }
- .pointerInput(Unit) {
- detectDragGesturesAfterLongPress(
- onDragStart = { offset ->
+ )
+ }
+ var showDropdown by remember { mutableStateOf(false) }
+ var touchOffset by remember { mutableStateOf(null) }
+ var boxPosition by remember { mutableStateOf(Offset.Zero) }
+ var lastDismissTime by remember { mutableLongStateOf(0L) }
+ val reopenThresholdMs = 250L
+
+ val density = LocalDensity.current
+ val itemHeightPx = with(density) { 48.dp.toPx() }
+ var parentHoveredIndex by remember { mutableStateOf(null) }
+ var parentDragActive by remember { mutableStateOf(false) }
+ var previousIdx by remember { mutableStateOf(null) }
+ val haptics = LocalHapticFeedback.current
+ val scope = rememberCoroutineScope()
+ val microphoneAutomaticText = stringResource(R.string.microphone_automatic)
+ val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right)
+ val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left)
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp)
+ .height(58.dp)
+ .pointerInput(Unit) {
+ detectTapGestures { offset ->
val now = System.currentTimeMillis()
- touchOffset = offset
- if (!showDropdown && now - lastDismissTime > reopenThresholdMs) {
- showDropdown = true
- }
- lastDismissTime = now
- parentDragActive = true
- parentHoveredIndex = 0
- },
- onDrag = { change, _ ->
- val current = change.position
- val touch = touchOffset ?: current
- val posInPopupY = current.y - touch.y
- val idx = (posInPopupY / itemHeightPx).toInt()
- if (idx != previousIdx) {
- scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
+ if (showDropdown) {
+ showDropdown = false
+ lastDismissTime = now
+ } else {
+ if (now - lastDismissTime > reopenThresholdMs) {
+ touchOffset = offset
+ showDropdown = true
+ }
}
- parentHoveredIndex = idx
- previousIdx = idx
- },
- onDragEnd = {
- parentDragActive = false
- parentHoveredIndex?.let { idx ->
- val options = listOf(
- microphoneAutomaticText,
- microphoneAlwaysRightText,
- microphoneAlwaysLeftText
- )
- if (idx in options.indices) {
- val option = options[idx]
- selectedMode = option
- showDropdown = false
- lastDismissTime = System.currentTimeMillis()
- val byteValue = when (option) {
- options[0] -> 0x00
- options[1] -> 0x01
- options[2] -> 0x02
- else -> 0x00
- }
+ }
+ }
+ .pointerInput(Unit) {
+ detectDragGesturesAfterLongPress(
+ onDragStart = { offset ->
+ val now = System.currentTimeMillis()
+ touchOffset = offset
+ if (!showDropdown && now - lastDismissTime > reopenThresholdMs) {
+ showDropdown = true
+ }
+ lastDismissTime = now
+ parentDragActive = true
+ parentHoveredIndex = 0
+ },
+ onDrag = { change, _ ->
+ val current = change.position
+ val touch = touchOffset ?: current
+ val posInPopupY = current.y - touch.y
+ val idx = (posInPopupY / itemHeightPx).toInt()
+ if (idx != previousIdx) {
+ scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) }
+ }
+ parentHoveredIndex = idx
+ previousIdx = idx
+ },
+ onDragEnd = {
+ parentDragActive = false
+ parentHoveredIndex?.let { idx ->
+ val options = listOf(
+ microphoneAutomaticText,
+ microphoneAlwaysRightText,
+ microphoneAlwaysLeftText
+ )
+ if (idx in options.indices) {
+ val option = options[idx]
+ selectedMode = option
+ showDropdown = false
+ lastDismissTime = System.currentTimeMillis()
+ val byteValue = when (option) {
+ options[0] -> 0x00
+ options[1] -> 0x01
+ options[2] -> 0x02
+ else -> 0x00
+ }
// service.aacpManager.sendControlCommand(
// AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE.value,
// byteArrayOf(byteValue.toByte())
// )
- onMicModeValueChanged(byteValue.toByte())
+ onMicModeValueChanged(byteValue.toByte())
+ }
}
+ if (parentHoveredIndex != null && parentHoveredIndex in 0..2) {
+ scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
+ }
+ parentHoveredIndex = null
+ },
+ onDragCancel = {
+ parentDragActive = false
+ parentHoveredIndex = null
}
- if (parentHoveredIndex != null && parentHoveredIndex in 0..2) {
- scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) }
- }
- parentHoveredIndex = null
- },
- onDragCancel = {
- parentDragActive = false
- parentHoveredIndex = null
- }
- )
- },
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = stringResource(R.string.microphone_mode),
- style = TextStyle(
- fontSize = 16.sp,
- color = textColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- modifier = Modifier.padding(bottom = 4.dp)
- )
- Box(
- modifier = Modifier.onGloballyPositioned { coordinates ->
- boxPosition = coordinates.positionInParent()
- }
+ )
+ },
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
) {
- Row(
- verticalAlignment = Alignment.CenterVertically
+ Text(
+ text = stringResource(R.string.microphone_mode),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(bottom = 4.dp)
+ )
+ Box(
+ modifier = Modifier.onGloballyPositioned { coordinates ->
+ boxPosition = coordinates.positionInParent()
+ }
) {
- Text(
- text = selectedMode,
- style = TextStyle(
- fontSize = 16.sp,
- color = textColor.copy(alpha = 0.8f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = selectedMode,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
)
- )
- Text(
- text = "",
- style = TextStyle(
- fontSize = 16.sp,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
+ Icon(
+ imageVector = Icons.Filled.KeyboardArrowDown,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier
+ .padding(start = 6.dp)
+ .size(20.dp)
+ )
+ }
+
+ val microphoneAutomaticText = stringResource(R.string.microphone_automatic)
+ val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right)
+ val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left)
+
+ StyledDropdown(
+ expanded = showDropdown,
+ onDismissRequest = {
+ showDropdown = false
+ lastDismissTime = System.currentTimeMillis()
+ },
+ options = listOf(
+ microphoneAutomaticText,
+ microphoneAlwaysRightText,
+ microphoneAlwaysLeftText
),
- modifier = Modifier
- .padding(start = 6.dp)
+ selectedOption = selectedMode,
+ touchOffset = touchOffset,
+ boxPosition = boxPosition,
+ externalHoveredIndex = parentHoveredIndex,
+ externalDragActive = parentDragActive,
+ onOptionSelected = { option ->
+ selectedMode = option
+ showDropdown = false
+ val byteValue = when (option) {
+ microphoneAutomaticText -> 0x00
+ microphoneAlwaysRightText -> 0x01
+ microphoneAlwaysLeftText -> 0x02
+ else -> 0x00
+ }
+ onMicModeValueChanged(byteValue.toByte())
+ },
+ hazeState = hazeState
)
}
-
- val microphoneAutomaticText = stringResource(R.string.microphone_automatic)
- val microphoneAlwaysRightText = stringResource(R.string.microphone_always_right)
- val microphoneAlwaysLeftText = stringResource(R.string.microphone_always_left)
-
- StyledDropdown(
- expanded = showDropdown,
- onDismissRequest = {
- showDropdown = false
- lastDismissTime = System.currentTimeMillis()
- },
- options = listOf(
- microphoneAutomaticText,
- microphoneAlwaysRightText,
- microphoneAlwaysLeftText
- ),
- selectedOption = selectedMode,
- touchOffset = touchOffset,
- boxPosition = boxPosition,
- externalHoveredIndex = parentHoveredIndex,
- externalDragActive = parentDragActive,
- onOptionSelected = { option ->
- selectedMode = option
- showDropdown = false
- val byteValue = when (option) {
- microphoneAutomaticText -> 0x00
- microphoneAlwaysRightText -> 0x01
- microphoneAlwaysLeftText -> 0x02
- else -> 0x00
- }
- onMicModeValueChanged(byteValue.toByte())
- },
- hazeState = hazeState
- )
}
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NavigationButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NavigationButton.kt
index ceff731a2..0adefee46 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NavigationButton.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NavigationButton.kt
@@ -18,49 +18,47 @@
package me.kavishdevar.librepods.presentation.components
-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.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
-import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
-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.dp
-import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
-import kotlinx.coroutines.launch
-import me.kavishdevar.librepods.R
+/**
+ * Material 3 Expressive settings row. A tonal [Surface] with a leading label, optional trailing
+ * value and a chevron, that springs on press. `independent = true` rounds it into a standalone
+ * card; `false` makes it a flush row for grouping inside a section card.
+ */
@Composable
fun NavigationButton(
to: String,
name: String,
- navController: NavController, onClick: (() -> Unit)? = null,
+ navController: NavController,
+ onClick: (() -> Unit)? = null,
independent: Boolean = true,
title: String? = null,
description: String? = null,
@@ -68,106 +66,73 @@ fun NavigationButton(
height: Dp = 58.dp,
enabled: 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))
val haptics = LocalHapticFeedback.current
- val scope = rememberCoroutineScope()
+ val interaction = remember { MutableInteractionSource() }
+ val scale by rememberBounceScale(interaction)
Column {
if (title != null) {
- Box(
- modifier = Modifier
- .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
- .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 4.dp)
- ){
- Text(
- text = title,
- style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- }
+ Text(
+ text = title,
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 20.dp, end = 16.dp, top = 8.dp, bottom = 6.dp)
+ )
}
- Row(
+ Surface(
+ onClick = {
+ if (enabled) {
+ haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
+ if (onClick != null) onClick() else navController.navigate(to)
+ }
+ },
+ enabled = enabled,
+ interactionSource = interaction,
+ shape = if (independent) MaterialTheme.shapes.large else RectangleShape,
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ contentColor = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
- .background(
- animatedBackgroundColor,
- RoundedCornerShape(if (independent) 28.dp else 0.dp)
- )
- .height(height)
- .pointerInput(Unit) {
- detectTapGestures(
- onPress = {
- if (enabled) {
- backgroundColor =
- if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
- tryAwaitRelease()
- backgroundColor =
- if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
- }
- },
- onTap = {
- if (enabled) {
- scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
- if (onClick != null) onClick() else navController.navigate(to)
- }
- }
- )
+ .fillMaxWidth()
+ .graphicsLayer {
+ scaleX = scale
+ scaleY = scale
}
- .padding(horizontal = 16.dp),
- verticalAlignment = Alignment.CenterVertically,
) {
- Text(
- text = name,
- style = TextStyle(
- fontSize = 16.sp,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- color = if (isDarkTheme) Color.White else Color.Black,
- )
- )
- Spacer(modifier = Modifier.weight(1f))
- if (currentState != null) {
+ Row(
+ modifier = Modifier
+ .heightIn(min = height)
+ .padding(horizontal = 20.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
Text(
- text = currentState,
- style = TextStyle(
- fontSize = 16.sp,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.8f),
+ text = name,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Spacer(modifier = Modifier.weight(1f))
+ if (currentState != null) {
+ Text(
+ text = currentState,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
)
+ Spacer(modifier = Modifier.width(6.dp))
+ }
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(20.dp),
)
}
- Text(
- text = "",
- style = TextStyle(
- fontSize = 16.sp,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f)
- ),
- modifier = Modifier
- .padding(start = if (currentState != null) 6.dp else 0.dp)
- )
}
if (description != null) {
- Box(
- modifier = Modifier
- .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) // because blur effect doesn't work for some reason
- .padding(horizontal = 16.dp, vertical = 4.dp),
- ) {
- Text(
- text = description,
- style = TextStyle(
- fontSize = 12.sp,
- fontWeight = FontWeight.Light,
- color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- // modifier = Modifier.padding(horizontal = 16.dp)
- )
- }
+ Text(
+ text = description,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 20.dp, end = 16.dp, top = 6.dp)
+ )
}
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NoiseControlSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NoiseControlSettings.kt
index 453c35c76..7383a3bd6 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NoiseControlSettings.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NoiseControlSettings.kt
@@ -21,15 +21,13 @@
package me.kavishdevar.librepods.presentation.components
import android.annotation.SuppressLint
-import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.Spring
-import androidx.compose.animation.core.SpringSpec
import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
-import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
@@ -44,225 +42,129 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
-import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
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.alpha
-import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.text.font.Font
-import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.NoiseControlMode
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.roundToInt
-@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
+private fun modeIconRes(mode: NoiseControlMode): Int = when (mode) {
+ NoiseControlMode.OFF -> R.drawable.noise_cancellation
+ NoiseControlMode.TRANSPARENCY -> R.drawable.transparency
+ NoiseControlMode.ADAPTIVE -> R.drawable.adaptive
+ NoiseControlMode.NOISE_CANCELLATION -> R.drawable.noise_cancellation
+}
+
+/**
+ * Material 3 Expressive segmented noise-control selector: a vibrant primary "pill" that slides
+ * between modes with a low-damping (bouncy) spring and can be dragged. The adaptive mode shows a
+ * playful rainbow gradient pill. Signature & behaviour preserved from the original.
+ */
+@SuppressLint("UnusedBoxWithConstraintsScope")
@Composable
fun NoiseControlSettings(
showOffListeningMode: Boolean,
noiseControlModeValue: Int,
onNoiseControlModeChanged: (Int) -> Unit
) {
- val isDarkTheme = isSystemInDarkTheme()
- val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
- val textColor = if (isDarkTheme) Color.White else Color.Black
- val textColorSelected = if (isDarkTheme) Color.White else Color.Black
- val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF)
-
-
-
+ val colorScheme = MaterialTheme.colorScheme
val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) }
- val d1a = remember { mutableFloatStateOf(0f) }
- val d2a = remember { mutableFloatStateOf(0f) }
- val d3a = remember { mutableFloatStateOf(0f) }
-
- // this function exists solely for the dividers, should get rid of it
fun onModeSelected(mode: NoiseControlMode, received: Boolean = false) {
val previousMode = noiseControlMode.value
-
val targetMode = if (!showOffListeningMode && mode == NoiseControlMode.OFF) {
- NoiseControlMode.TRANSPARENCY
+ NoiseControlMode.TRANSPARENCY
} else {
mode
}
-
noiseControlMode.value = targetMode
-
if (!received && targetMode != previousMode) onNoiseControlModeChanged(targetMode.ordinal + 1)
-
-
- when (noiseControlMode.value) {
- NoiseControlMode.NOISE_CANCELLATION -> {
- d1a.floatValue = 1f
- d2a.floatValue = 1f
- d3a.floatValue = 0f
- }
- NoiseControlMode.OFF -> {
- d1a.floatValue = 0f
- d2a.floatValue = 1f
- d3a.floatValue = 1f
- }
- NoiseControlMode.ADAPTIVE -> {
- d1a.floatValue = 1f
- d2a.floatValue = 0f
- d3a.floatValue = 0f
- }
- NoiseControlMode.TRANSPARENCY -> {
- d1a.floatValue = 0f
- d2a.floatValue = 0f
- d3a.floatValue = 1f
- }
- }
}
-
val index = (noiseControlModeValue - 1).coerceIn(0, NoiseControlMode.entries.size - 1)
noiseControlMode.value = NoiseControlMode.entries[index]
-
onModeSelected(noiseControlMode.value, received = true)
- Box(
- modifier = Modifier
- .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
- .padding(horizontal = 16.dp, vertical = 4.dp)
- ){
- Text(
- text = stringResource(R.string.noise_control),
- style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
+ val modes = remember(showOffListeningMode) {
+ buildList {
+ if (showOffListeningMode) add(NoiseControlMode.OFF)
+ add(NoiseControlMode.TRANSPARENCY)
+ add(NoiseControlMode.ADAPTIVE)
+ add(NoiseControlMode.NOISE_CANCELLATION)
+ }
+ }
+
+ val adaptiveBrush = remember {
+ Brush.sweepGradient(
+ listOf(
+ colorScheme.primary, colorScheme.tertiary, colorScheme.secondary,
+ colorScheme.primary
)
)
}
+ Text(
+ text = stringResource(R.string.noise_control),
+ style = MaterialTheme.typography.labelLarge,
+ color = colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 20.dp, end = 16.dp, top = 8.dp, bottom = 6.dp)
+ )
+
BoxWithConstraints(
modifier = Modifier
.fillMaxWidth()
- .padding(vertical = 8.dp)
+ .padding(vertical = 4.dp)
) {
val density = LocalDensity.current
- val buttonCount = if (showOffListeningMode) 4 else 3
+ val buttonCount = modes.size
val buttonWidth = maxWidth / buttonCount
+ val selectedSlot = modes.indexOf(noiseControlMode.value).coerceAtLeast(0)
+
val isDragging = remember { mutableStateOf(false) }
var dragOffset by remember {
- mutableFloatStateOf(
- with(density) {
- when(noiseControlMode.value) {
- NoiseControlMode.OFF -> if (showOffListeningMode) 0f else buttonWidth.toPx()
- NoiseControlMode.TRANSPARENCY -> if (showOffListeningMode) buttonWidth.toPx() else 0f
- NoiseControlMode.ADAPTIVE -> if (showOffListeningMode) (buttonWidth * 2).toPx() else buttonWidth.toPx()
- NoiseControlMode.NOISE_CANCELLATION -> if (showOffListeningMode) (buttonWidth * 3).toPx() else (buttonWidth * 2).toPx()
- }
- }
- )
- }
-
- val animationSpec: AnimationSpec = SpringSpec(
- dampingRatio = Spring.DampingRatioLowBouncy,
- stiffness = Spring.StiffnessMediumLow,
- visibilityThreshold = 0.01f
- )
-
- val targetOffset = buttonWidth * when(noiseControlMode.value) {
- NoiseControlMode.OFF -> if (showOffListeningMode) 0 else 1
- NoiseControlMode.TRANSPARENCY -> if (showOffListeningMode) 1 else 0
- NoiseControlMode.ADAPTIVE -> if (showOffListeningMode) 2 else 1
- NoiseControlMode.NOISE_CANCELLATION -> if (showOffListeningMode) 3 else 2
+ mutableFloatStateOf(with(density) { (buttonWidth * selectedSlot).toPx() })
}
val animatedOffset by animateFloatAsState(
targetValue = with(density) {
- if (isDragging.value) dragOffset else targetOffset.toPx()
+ if (isDragging.value) dragOffset else (buttonWidth * selectedSlot).toPx()
},
- animationSpec = animationSpec,
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioLowBouncy,
+ stiffness = Spring.StiffnessMediumLow,
+ visibilityThreshold = 0.01f
+ ),
label = "selector"
)
- Column(
- modifier = Modifier.fillMaxWidth()
- ) {
+ Column(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.fillMaxWidth()
- .height(60.dp)
- .background(backgroundColor, RoundedCornerShape(28.dp))
+ .height(64.dp)
+ .clip(RoundedCornerShape(32.dp))
+ .background(colorScheme.surfaceContainerHighest)
) {
- Row(
- modifier = Modifier.fillMaxWidth()
- ) {
- if (showOffListeningMode) {
- NoiseControlButton(
- icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
- onClick = { onModeSelected(NoiseControlMode.OFF) },
- textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor,
- modifier = Modifier.weight(1f),
- usePadding = false
- )
- VerticalDivider(
- thickness = 1.dp,
- modifier = Modifier
- .padding(vertical = 10.dp)
- .alpha(d1a.floatValue),
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
- )
- }
- NoiseControlButton(
- icon = ImageBitmap.imageResource(R.drawable.transparency),
- onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) },
- textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor,
- modifier = Modifier.weight(1f),
- usePadding = false
- )
- VerticalDivider(
- thickness = 1.dp,
- modifier = Modifier
- .padding(vertical = 10.dp)
- .alpha(d2a.floatValue),
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
- )
- NoiseControlButton(
- icon = ImageBitmap.imageResource(R.drawable.adaptive),
- onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) },
- textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor,
- modifier = Modifier.weight(1f),
- usePadding = false
- )
- VerticalDivider(
- thickness = 1.dp,
- modifier = Modifier
- .padding(vertical = 10.dp)
- .alpha(d3a.floatValue),
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
- )
- NoiseControlButton(
- icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
- onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) },
- textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor,
- modifier = Modifier.weight(1f),
- usePadding = false
- )
- }
-
+ // Sliding selection pill
Box(
modifier = Modifier
.width(buttonWidth)
@@ -281,23 +183,23 @@ fun NoiseControlSettings(
onDragStopped = {
isDragging.value = false
val position = dragOffset / with(density) { buttonWidth.toPx() }
- val newIndex = position.roundToInt()
- val newMode = when(newIndex) {
- 0 -> if (showOffListeningMode) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY
- 1 -> if (showOffListeningMode) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE
- 2 -> if (showOffListeningMode) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION
- 3 -> NoiseControlMode.NOISE_CANCELLATION
- else -> noiseControlMode.value // Keep current if index is invalid
- }
- onModeSelected(newMode)
+ val newSlot = position.roundToInt().coerceIn(0, buttonCount - 1)
+ onModeSelected(modes[newSlot])
}
)
) {
Box(
modifier = Modifier
.fillMaxSize()
- .padding(3.dp)
- .background(selectedBackground, RoundedCornerShape(26.dp))
+ .padding(4.dp)
+ .clip(RoundedCornerShape(28.dp))
+ .then(
+ if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) {
+ Modifier.background(adaptiveBrush)
+ } else {
+ Modifier.background(colorScheme.primary)
+ }
+ )
)
}
@@ -306,92 +208,43 @@ fun NoiseControlSettings(
.fillMaxWidth()
.zIndex(1f)
) {
- if (showOffListeningMode) {
+ modes.forEach { mode ->
+ val selected = noiseControlMode.value == mode
NoiseControlButton(
- icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
- onClick = { onModeSelected(NoiseControlMode.OFF) },
- textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor,
+ icon = ImageBitmap.imageResource(modeIconRes(mode)),
+ onClick = { onModeSelected(mode) },
+ textColor = if (selected) colorScheme.onPrimary else colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
usePadding = false
)
- VerticalDivider(
- thickness = 1.dp,
- modifier = Modifier
- .padding(vertical = 10.dp)
- .alpha(d1a.floatValue),
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
- )
}
- NoiseControlButton(
- icon = ImageBitmap.imageResource(R.drawable.transparency),
- onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) },
- textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor,
- modifier = Modifier.weight(1f),
- usePadding = false
- )
- VerticalDivider(
- thickness = 1.dp,
- modifier = Modifier
- .padding(vertical = 10.dp)
- .alpha(d2a.floatValue),
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
- )
- NoiseControlButton(
- icon = ImageBitmap.imageResource(R.drawable.adaptive),
- onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) },
- textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor,
- modifier = Modifier.weight(1f),
- usePadding = false
- )
- VerticalDivider(
- thickness = 1.dp,
- modifier = Modifier
- .padding(vertical = 10.dp)
- .alpha(d3a.floatValue),
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
- )
- NoiseControlButton(
- icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
- onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) },
- textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor,
- modifier = Modifier.weight(1f),
- usePadding = false
- )
}
}
Row(
modifier = Modifier
.fillMaxWidth()
- .padding(top = 4.dp)
+ .padding(top = 6.dp)
) {
- if (showOffListeningMode) {
+ modes.forEach { mode ->
+ val selected = noiseControlMode.value == mode
Text(
- text = stringResource(R.string.off),
- style = TextStyle(fontSize = 12.sp, color = textColor),
+ text = stringResource(modeLabelRes(mode)),
+ style = MaterialTheme.typography.labelMedium,
+ color = if (selected) colorScheme.onSurface else colorScheme.onSurfaceVariant,
+ fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f)
)
}
- Text(
- text = stringResource(R.string.transparency),
- style = TextStyle(fontSize = 12.sp, color = textColor),
- textAlign = TextAlign.Center,
- modifier = Modifier.weight(1f)
- )
- Text(
- text = stringResource(R.string.adaptive),
- style = TextStyle(fontSize = 12.sp, color = textColor),
- textAlign = TextAlign.Center,
- modifier = Modifier.weight(1f)
- )
- Text(
- text = stringResource(R.string.noise_cancellation),
- style = TextStyle(fontSize = 12.sp, color = textColor),
- textAlign = TextAlign.Center,
- modifier = Modifier.weight(1f)
- )
}
}
}
}
+
+private fun modeLabelRes(mode: NoiseControlMode): Int = when (mode) {
+ NoiseControlMode.OFF -> R.string.off
+ NoiseControlMode.TRANSPARENCY -> R.string.transparency
+ NoiseControlMode.ADAPTIVE -> R.string.adaptive
+ NoiseControlMode.NOISE_CANCELLATION -> R.string.noise_cancellation
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/PressAndHoldSettings.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/PressAndHoldSettings.kt
index a29ea725c..1cc07b979 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/PressAndHoldSettings.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/PressAndHoldSettings.kt
@@ -18,26 +18,17 @@
package me.kavishdevar.librepods.presentation.components
-import androidx.compose.foundation.background
-import androidx.compose.foundation.isSystemInDarkTheme
-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.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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
-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 me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.StemAction
@@ -48,10 +39,6 @@ fun PressAndHoldSettings(
leftAction: StemAction,
rightAction: StemAction
) {
- val isDarkTheme = isSystemInDarkTheme()
- val textColor = if (isDarkTheme) Color.White else Color.Black
- val dividerColor = Color(0x40888888)
-
val leftActionText = when (leftAction) {
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
@@ -63,46 +50,39 @@ fun PressAndHoldSettings(
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
else -> "INVALID!!"
}
- Box(
- modifier = Modifier
- .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
- .padding(horizontal = 16.dp, vertical = 4.dp)
- ){
- Text(
- text = stringResource(R.string.press_and_hold_airpods),
- style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- }
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .background(if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF), RoundedCornerShape(28.dp))
- .clip(RoundedCornerShape(28.dp))
+
+ Text(
+ text = stringResource(R.string.press_and_hold_airpods),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 20.dp, end = 16.dp, top = 8.dp, bottom = 6.dp)
+ )
+ Surface(
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ modifier = Modifier.fillMaxWidth()
) {
- NavigationButton(
- to = "long_press/Left",
- name = stringResource(R.string.left),
- navController = navController,
- independent = false,
- currentState = leftActionText,
- )
- HorizontalDivider(
- thickness = 1.dp,
- color = dividerColor,
- modifier = Modifier
- .padding(horizontal = 16.dp)
- )
- NavigationButton(
- to = "long_press/Right",
- name = stringResource(R.string.right),
- navController = navController,
- independent = false,
- currentState = rightActionText,
- )
+ Column {
+ NavigationButton(
+ to = "long_press/Left",
+ name = stringResource(R.string.left),
+ navController = navController,
+ independent = false,
+ currentState = leftActionText,
+ )
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant,
+ modifier = Modifier
+ .padding(horizontal = 20.dp)
+ )
+ NavigationButton(
+ to = "long_press/Right",
+ name = stringResource(R.string.right),
+ navController = navController,
+ independent = false,
+ currentState = rightActionText,
+ )
+ }
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledBottomSheet.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledBottomSheet.kt
index b98139810..e6f253e94 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledBottomSheet.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledBottomSheet.kt
@@ -1,28 +1,23 @@
package me.kavishdevar.librepods.presentation.components
import androidx.compose.animation.core.animateFloatAsState
-import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetValue
+import androidx.compose.material3.Surface
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.lerp
import com.kyant.backdrop.backdrops.LayerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
-import com.kyant.backdrop.drawBackdrop
-import com.kyant.backdrop.effects.blur
-import com.kyant.backdrop.effects.lens
-import com.kyant.backdrop.effects.vibrancy
@OptIn(ExperimentalMaterial3Api::class)
@@ -35,10 +30,9 @@ fun StyledBottomSheet(
) {
if (!visible) return
- val isDarkTheme = isSystemInDarkTheme()
val sheetState = rememberModalBottomSheetState(false) // move this to parent composable
- val isExpanded = sheetState.targetValue == SheetValue.Expanded
+ val isExpanded = sheetState.targetValue == SheetValue.Expanded
val progress by animateFloatAsState(
targetValue = if (isExpanded) 1f else 0f,
@@ -52,36 +46,24 @@ fun StyledBottomSheet(
sheetState = sheetState,
containerColor = Color.Transparent,
dragHandle = { },
- shape = RoundedCornerShape(animatedCorner),
- scrimColor = Color.Transparent,
+ shape = MaterialTheme.shapes.extraLarge,
modifier = Modifier.padding(4.dp)
) {
val innerBackdrop = rememberLayerBackdrop()
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .clip(RoundedCornerShape(animatedCorner))
- .drawBackdrop(
- backdrop = backdrop,
- exportedBackdrop = innerBackdrop,
- shape = { RoundedCornerShape(animatedCorner) },
- effects = {
- vibrancy()
- blur(4f.dp.toPx())
- lens(12f.dp.toPx(), 48f.dp.toPx(), true)
- },
- onDrawSurface = {
- drawRect(
- if (isDarkTheme) Color.DarkGray.copy(alpha = 0.3f) else Color(
- 0xFFE0E0E0
- ).copy(alpha = 0.45f)
- )
- }
- )
- .padding(top = 24.dp)
- .padding(horizontal = 16.dp)
+ Surface(
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.fillMaxWidth()
) {
- content(innerBackdrop, progress)
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 24.dp)
+ .padding(horizontal = 16.dp)
+ ) {
+ content(innerBackdrop, progress)
+ }
}
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledButton.kt
index 6c60148fc..49ed6a3b2 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledButton.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledButton.kt
@@ -18,312 +18,69 @@
package me.kavishdevar.librepods.presentation.components
-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.interaction.MutableInteractionSource
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.foundation.layout.heightIn
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.contentColorFor
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.graphicsLayer
import androidx.compose.ui.graphics.isSpecified
-import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
-import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
-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.lens
-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
+/**
+ * Material 3 Expressive filled button with a springy press. Signature preserved from the old
+ * liquid-glass button; [backdrop], [isInteractive] and [maxScale] are kept for source compatibility
+ * but are no longer used. [surfaceColor] overrides the container; [tint] overrides the content color.
+ */
@Composable
fun StyledButton(
onClick: () -> Unit,
- backdrop: Backdrop,
+ @Suppress("UNUSED_PARAMETER") backdrop: Backdrop,
modifier: Modifier = Modifier,
- isInteractive: Boolean = true,
+ @Suppress("UNUSED_PARAMETER") isInteractive: Boolean = true,
tint: Color = Color.Unspecified,
surfaceColor: Color = Color.Unspecified,
- maxScale: Float = 0.1f,
+ @Suppress("UNUSED_PARAMETER") maxScale: Float = 0.1f,
enabled: Boolean = true,
content: @Composable RowScope.() -> Unit,
) {
- val isInteractive = enabled && isInteractive
- val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
- 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 interaction = remember { MutableInteractionSource() }
+ val scale by rememberBounceScale(interaction)
- 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;
+ val container = if (surfaceColor.isSpecified) surfaceColor else MaterialTheme.colorScheme.secondaryContainer
+ val onContainer = if (tint.isSpecified) tint else contentColorFor(container)
-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())
- },
- layerBlock = 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 && enabled) {
- val color = if (isPressed) {
- Color(
- red = surfaceColor.red * 0.5f,
- green = surfaceColor.green * 0.5f,
- blue = surfaceColor.blue * 0.5f,
- alpha = surfaceColor.alpha
- )
- } else {
- surfaceColor
- }
- drawRect(color)
- } else {
- if (isPressed) {
- drawRect(Color.Black.copy(alpha = 0.4f))
- drawRect(Color.White.copy(alpha = 0.2f))
- }
- }
- },
- onDrawFront = null,
- highlight = { Highlight.Ambient.copy(alpha = 0f) }
- )
- } else {
- Modifier.drawBackdrop(
- backdrop = backdrop,
- shape = { RoundedCornerShape(28f.dp) },
- effects = {
- vibrancy()
- blur(2f.dp.toPx())
- lens(
- refractionHeight = 12f.dp.toPx(),
- refractionAmount = 24f.dp.toPx(),
- depthEffect = true,
- chromaticAberration = true
- )
- },
- layerBlock = {
- val width = size.width
- val height = size.height
-
- val progress = progressAnimation.value
- 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 = {
- if (enabled) {
- haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
- onClick()
- }
- }
- )
- .then(
- if (isInteractive) {
- Modifier.pointerInput(scope) {
- val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
- val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
- val onDragStop: () -> Unit = {
- scope.launch {
- launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
- launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
- launch {
- offsetAnimation.animateTo(
- Offset.Zero,
- offsetAnimationSpec
- )
- }
- }
- }
- inspectDragGestures(
- onDragStart = { down ->
- pressStartPosition = down.position
- scope.launch {
- launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
- launch {
- progressAnimation.animateTo(
- 1f,
- progressAnimationSpec
- )
- }
- launch { offsetAnimation.snapTo(Offset.Zero) }
- }
- },
- onDragEnd = {
- onDragStop()
- },
- onDragCancel = onDragStop
- ) { _, dragAmount ->
- scope.launch {
- if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
- HapticFeedbackType.SegmentFrequentTick
- )
- offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
- }
- }
- }
- } else {
- Modifier.pointerInput(Unit) {
- detectTapGestures(
- onPress = {
- isPressed = true
- tryAwaitRelease()
- isPressed = false
- },
- onTap = {
- if (enabled) {
- haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
- onClick()
- }
- }
- )
- }
- }
- )
- .height(48f.dp)
- .padding(horizontal = 16f.dp),
- horizontalArrangement = Arrangement.spacedBy(8f.dp, Alignment.CenterHorizontally),
- verticalAlignment = Alignment.CenterVertically,
- content = content
+ Button(
+ onClick = {
+ if (enabled) {
+ haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
+ onClick()
+ }
+ },
+ enabled = enabled,
+ interactionSource = interaction,
+ shape = MaterialTheme.shapes.large,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = container,
+ contentColor = onContainer,
+ ),
+ modifier = modifier
+ .heightIn(min = 56.dp)
+ .graphicsLayer {
+ scaleX = scale
+ scaleY = scale
+ },
+ content = content,
)
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledDropdown.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledDropdown.kt
index 4446f0856..3f12367ea 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledDropdown.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledDropdown.kt
@@ -28,7 +28,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -36,13 +35,13 @@ import androidx.compose.foundation.layout.Row
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.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.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -60,22 +59,13 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalHapticFeedback
-import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.text.font.Font
-import androidx.compose.ui.text.font.FontFamily
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 kotlinx.coroutines.launch
-import me.kavishdevar.librepods.R
@ExperimentalHazeMaterialsApi
@Composable
@@ -89,7 +79,7 @@ fun StyledDropdown(
onOptionSelected: (String) -> Unit,
externalHoveredIndex: Int? = null,
externalDragActive: Boolean = false,
- hazeState: HazeState,
+ @Suppress("UNUSED_PARAMETER") hazeState: HazeState,
@SuppressLint("ModifierParameter") modifier: Modifier = Modifier
) {
if (expanded) {
@@ -103,13 +93,15 @@ fun StyledDropdown(
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut()
) {
- Card(
+ Surface(
modifier = modifier
.padding(8.dp)
.width(300.dp)
- .background(Color.Transparent)
- .clip(RoundedCornerShape(8.dp)),
- elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
+ .clip(MaterialTheme.shapes.large),
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ tonalElevation = 3.dp,
+ shadowElevation = 4.dp
) {
var hoveredIndex by remember { mutableStateOf(null) }
val itemHeight = 48.dp
@@ -176,13 +168,17 @@ fun StyledDropdown(
} else {
index == hoveredIndex
}
- val isSystemInDarkTheme = isSystemInDarkTheme()
+ val isSelected = text == selectedOption
Box(
modifier = Modifier
.fillMaxWidth()
.height(itemHeight)
.background(
- Color.Transparent
+ if (isHovered) {
+ MaterialTheme.colorScheme.surfaceContainerHighest
+ } else {
+ Color.Transparent
+ }
)
.clickable(
interactionSource = remember { MutableInteractionSource() },
@@ -192,23 +188,7 @@ fun StyledDropdown(
onOptionSelected(text)
onDismissRequest()
}
- .hazeEffect(
- state = hazeState,
- style = CupertinoMaterials.regular(),
- block = fun HazeEffectScope.() {
- alpha = 1f
- backgroundColor = if (isSystemInDarkTheme) {
- Color(0xB02C2C2E)
- } else {
- Color(0xB0FFFFFF)
- }
- tints = if (isHovered) listOf(
- HazeTint(
- color = if (isSystemInDarkTheme) Color(0x338A8A8A) else Color(0x40D9D9D9)
- )
- ) else listOf()
- })
- .padding(horizontal = 12.dp),
+ .padding(horizontal = 16.dp),
contentAlignment = Alignment.CenterStart
) {
Row(
@@ -217,37 +197,26 @@ fun StyledDropdown(
verticalAlignment = Alignment.CenterVertically
) {
Text(
- text,
- style = TextStyle(
- fontSize = 16.sp,
- color = if (isSystemInDarkTheme()) Color.White else Color.Black.copy(alpha = 0.75f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
+ text = text,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface
)
- Checkbox(
- checked = text == selectedOption,
- onCheckedChange = { onOptionSelected(text) },
- colors = CheckboxDefaults.colors().copy(
- checkedCheckmarkColor = Color(0xFF007AFF),
- uncheckedCheckmarkColor = Color.Transparent,
- checkedBoxColor = Color.Transparent,
- uncheckedBoxColor = Color.Transparent,
- checkedBorderColor = Color.Transparent,
- uncheckedBorderColor = Color.Transparent,
- disabledBorderColor = Color.Transparent,
- disabledCheckedBoxColor = Color.Transparent,
- disabledUncheckedBoxColor = Color.Transparent,
- disabledUncheckedBorderColor = Color.Transparent
+ if (isSelected) {
+ Icon(
+ imageVector = Icons.Filled.Check,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(20.dp)
)
- )
+ }
}
}
if (index != options.lastIndex) {
- HorizontalDivider(
+ androidx.compose.material3.HorizontalDivider(
thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier.padding(start = 12.dp, end = 0.dp)
+ color = MaterialTheme.colorScheme.outlineVariant,
+ modifier = Modifier.padding(start = 16.dp, end = 0.dp)
)
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledIconButton.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledIconButton.kt
index 6937587a2..1dbcce842 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledIconButton.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledIconButton.kt
@@ -18,289 +18,71 @@
package me.kavishdevar.librepods.presentation.components
-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.isSystemInDarkTheme
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.MaterialTheme
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.graphicsLayer
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.hapticfeedback.HapticFeedbackType
-import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
-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.LayerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
-import com.kyant.backdrop.drawBackdrop
-import com.kyant.backdrop.effects.lens
-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
+/**
+ * Material 3 Expressive tonal icon button. Replaces the old SF-Pro glyph icon button: the [icon]
+ * string (an SF Symbol glyph) is converted to a Material [androidx.compose.ui.graphics.vector.ImageVector]
+ * via [sfSymbolToIcon]. The [backdrop] parameter is retained for source compatibility but unused.
+ */
@Composable
fun StyledIconButton(
modifier: Modifier = Modifier,
icon: String,
iconTint: Color = Color.Unspecified,
surfaceColor: Color = Color.Unspecified,
- backdrop: LayerBackdrop = rememberLayerBackdrop(),
+ @Suppress("UNUSED_PARAMETER") backdrop: LayerBackdrop = rememberLayerBackdrop(),
onClick: () -> Unit,
enabled: Boolean = true
) {
val haptics = LocalHapticFeedback.current
- val darkMode = isSystemInDarkTheme()
- val scope = 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 density = LocalDensity.current
+ val interaction = remember { MutableInteractionSource() }
+ val scale by rememberBounceScale(interaction)
+ val vector = sfSymbolToIcon(icon)
- 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;
+ val containerColor =
+ if (surfaceColor.isSpecified) surfaceColor else MaterialTheme.colorScheme.surfaceContainerHighest
+ val contentColor =
+ if (iconTint.isSpecified) iconTint else MaterialTheme.colorScheme.onSurface
-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 isDarkTheme = isSystemInDarkTheme()
- TextButton(
+ FilledTonalIconButton(
onClick = {
if (enabled) {
- scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
+ haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
onClick()
}
},
- shape = RoundedCornerShape(56.dp),
- modifier = modifier
- .padding(horizontal = 12.dp)
- .drawBackdrop(
- backdrop = backdrop,
- shape = { RoundedCornerShape(56.dp) },
- highlight = { Highlight.Ambient.copy(alpha = if (isDarkTheme) 1f else 0f) },
- shadow = {
- Shadow(
- radius = 12f.dp,
- color = Color.Black.copy(if (isDarkTheme) 0.08f else 0.2f)
- )
- },
- layerBlock = {
- if (!enabled) return@drawBackdrop
- val width = size.width
- val height = size.height
-
- val progress = progressAnimation.value
- val scale = lerp(1f, 1.5f, 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 (!enabled) {
- drawRect(
- (if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(0.5f)
- )
- return@drawBackdrop
- }
- 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)
-
- if (surfaceColor.isSpecified) {
- drawRect(surfaceColor)
- }
-
- drawRect(
- (if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(
- progress.coerceIn(
- 0.15f,
- 0.35f
- )
- )
- )
- },
- onDrawFront = {
- if (!enabled) return@drawBackdrop
- 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 = {
- lens(
- refractionHeight = 6f.dp.toPx(),
- refractionAmount = size.height / 2f,
- depthEffect = true,
- chromaticAberration = true
- )
- },
- )
- .pointerInput(scope) {
- val onDragStop: () -> Unit = {
- if (enabled) {
- scope.launch {
- launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
- launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
- launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
- }
- }
- }
- inspectDragGestures(
- onDragStart = { down ->
- if (enabled) {
- pressStartPosition = down.position
- scope.launch {
- launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
- launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
- launch { offsetAnimation.snapTo(Offset.Zero) }
- }
- }
- },
- onDragEnd = { onDragStop() },
- onDragCancel = onDragStop
- ) { _, dragAmount ->
- scope.launch {
- if (enabled) {
- if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
- HapticFeedbackType.SegmentFrequentTick
- )
- offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
- }
- }
- }
- }
- .size(with(density) { 48.sp.toDp() }),
+ enabled = enabled,
+ interactionSource = interaction,
+ colors = IconButtonDefaults.filledTonalIconButtonColors(
+ containerColor = containerColor,
+ contentColor = contentColor,
+ ),
+ modifier = modifier.graphicsLayer {
+ scaleX = scale
+ scaleY = scale
+ },
) {
- Text(
- text = icon,
- style = TextStyle(
- fontSize = 20.sp,
- fontWeight = FontWeight.Normal,
- color = if (iconTint.isSpecified) iconTint else if (darkMode) Color.White else Color.Black,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
+ if (vector != null) {
+ Icon(imageVector = vector, contentDescription = null)
+ } else {
+ Text(text = icon)
+ }
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledInputField.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledInputField.kt
index 86eae6a68..a7c0670f1 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledInputField.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledInputField.kt
@@ -1,12 +1,9 @@
package me.kavishdevar.librepods.presentation.components
-import android.R.attr.singleLine
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.updateTransition
-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.Row
import androidx.compose.foundation.layout.Spacer
@@ -15,12 +12,17 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Cancel
+import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -28,17 +30,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
-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 me.kavishdevar.librepods.R
@Composable
@@ -48,13 +44,11 @@ fun StyledInputField(
placeholder: String = "",
singleLine: Boolean = true
){
- val isDarkTheme = isSystemInDarkTheme()
- val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
- val textColor = if (isDarkTheme) Color.White else Color.Black
val minHeight = if (singleLine) 58.dp else 120.dp
val verticalAlignment = if (singleLine) Alignment.CenterVertically else Alignment.Top
val hasText = inputState.text.isNotEmpty()
val density = LocalDensity.current
+ val cursorColor = MaterialTheme.colorScheme.primary
val spacerHeight by animateDpAsState(
targetValue = if (hasText) with(density) { 32.sp.toDp() } else 0.dp,
label = "labelSpacer"
@@ -70,85 +64,78 @@ fun StyledInputField(
Box(
modifier = Modifier.fillMaxWidth()
) {
- Row(
- verticalAlignment = verticalAlignment,
- modifier = Modifier
- .fillMaxWidth()
- .heightIn(min = minHeight)
- .background(
- backgroundColor,
- RoundedCornerShape(28.dp)
- )
- .padding(horizontal = 16.dp, vertical = 8.dp)
- .pointerInput(Unit) {
- detectTapGestures {
- focusRequester.requestFocus()
- }
- }
+ Surface(
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ contentColor = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.fillMaxWidth()
) {
- BasicTextField(
- state = inputState,
- lineLimits = if (singleLine) TextFieldLineLimits.SingleLine else TextFieldLineLimits.Default,
- textStyle = TextStyle(
- fontSize = 16.sp,
- color = textColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- cursorBrush = SolidColor(textColor),
- decorator = { innerTextField ->
- Row(
- modifier = Modifier.padding(top = if (singleLine) 0.dp else 16.dp),
- verticalAlignment = verticalAlignment,
- ) {
+ Row(
+ verticalAlignment = verticalAlignment,
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = minHeight)
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ .pointerInput(Unit) {
+ detectTapGestures {
+ focusRequester.requestFocus()
+ }
+ }
+ ) {
+ BasicTextField(
+ state = inputState,
+ lineLimits = if (singleLine) TextFieldLineLimits.SingleLine else TextFieldLineLimits.Default,
+ textStyle = MaterialTheme.typography.bodyLarge.copy(
+ color = MaterialTheme.colorScheme.onSurface
+ ),
+ cursorBrush = SolidColor(cursorColor),
+ decorator = { innerTextField ->
Row(
- modifier = Modifier
- .weight(1f)
+ modifier = Modifier.padding(top = if (singleLine) 0.dp else 16.dp),
+ verticalAlignment = verticalAlignment,
) {
- Box(
+ Row(
modifier = Modifier
- .weight(1f),
- contentAlignment = if (singleLine) Alignment.CenterStart else Alignment.TopStart
+ .weight(1f)
) {
- Text(
- text = placeholder,
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Light,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- color = textColor.copy(alpha = 0.8f)
- ),
+ Box(
modifier = Modifier
- .offset(y = yOffset)
- )
+ .weight(1f),
+ contentAlignment = if (singleLine) Alignment.CenterStart else Alignment.TopStart
+ ) {
+ Text(
+ text = placeholder,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier
+ .offset(y = yOffset)
+ )
- innerTextField()
+ innerTextField()
+ }
}
- }
- if (singleLine && !inputState.text.isEmpty()) {
- IconButton(
- onClick = {
- inputState.clearText()
+ if (singleLine && !inputState.text.isEmpty()) {
+ IconButton(
+ onClick = {
+ inputState.clearText()
+ }
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Cancel,
+ contentDescription = "Clear text",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(20.dp)
+ )
}
- ) {
- Text(
- text = "",
- style = TextStyle(
- fontSize = 16.sp,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
- alpha = 0.6f
- )
- ),
- )
}
}
- }
- },
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 8.dp)
- .focusRequester(focusRequester)
- )
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 8.dp)
+ .focusRequester(focusRequester)
+ )
+ }
}
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledScaffold.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledScaffold.kt
index 6376188ca..e2b3eef96 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledScaffold.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledScaffold.kt
@@ -18,47 +18,37 @@
package me.kavishdevar.librepods.presentation.components
-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.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.CenterAlignedTopAppBar
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
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.remember
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.draw.shadow
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.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.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import androidx.compose.ui.zIndex
import com.kyant.backdrop.backdrops.LayerBackdrop
-import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
-import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeState
-import dev.chrisbanes.haze.HazeTint
-import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.rememberHazeState
-import me.kavishdevar.librepods.R
+/**
+ * Material 3 Expressive screen scaffold: an edge-to-edge [Scaffold] with a [CenterAlignedTopAppBar].
+ *
+ * The content lambda still receives `(spacerValue, hazeState, bottomPadding)` so existing screens
+ * compile unchanged. Because the M3 top app bar reserves its own space, `spacerValue` is now `0.dp`
+ * (the leading spacer screens add becomes a no-op). The [HazeState] and the action-button
+ * [LayerBackdrop] are still provided for source compatibility, though the new design doesn't blur.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StyledScaffold(
title: String,
@@ -66,70 +56,43 @@ fun StyledScaffold(
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable (spacerValue: Dp, hazeState: HazeState, bottomPadding: Dp) -> Unit
) {
- val isDarkTheme = isSystemInDarkTheme()
- val hazeState = rememberHazeState(blurEnabled = true)
+ val hazeState = rememberHazeState()
+ val backdrop = rememberLayerBackdrop()
Scaffold(
- containerColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7),
+ containerColor = MaterialTheme.colorScheme.background,
+ contentColor = MaterialTheme.colorScheme.onBackground,
snackbarHost = { SnackbarHost(snackbarHostState) },
- modifier = Modifier
- .then(if (!isDarkTheme) Modifier.shadow(elevation = 36.dp, shape = RoundedCornerShape(52.dp), ambientColor = Color.Black, spotColor = Color.Black) else Modifier)
- .clip(RoundedCornerShape(52.dp))
- ) { paddingValues ->
- val topPadding = paddingValues.calculateTopPadding()
- val bottomPadding = paddingValues.calculateBottomPadding() + 16.dp
- val startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current)
- val endPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current)
-
+ topBar = {
+ CenterAlignedTopAppBar(
+ title = {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ actions = { actionButtons.forEach { it(backdrop) } },
+ colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
+ containerColor = MaterialTheme.colorScheme.background,
+ scrolledContainerColor = MaterialTheme.colorScheme.background,
+ titleContentColor = MaterialTheme.colorScheme.onBackground,
+ actionIconContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ ),
+ )
+ },
+ ) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
- .padding(start = startPadding, end = endPadding)
+ .padding(top = innerPadding.calculateTopPadding())
) {
- val backdrop = rememberLayerBackdrop()
- Box(
- modifier = Modifier
- .zIndex(2f)
- .height(64.dp + topPadding)
- .fillMaxWidth()
- .layerBackdrop(backdrop)
- .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 + 12.dp))
- 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.fillMaxWidth(),
- textAlign = TextAlign.Center
- )
- }
- }
- Row(
- modifier = Modifier
- .zIndex(3f)
- .padding(top = topPadding, end = 8.dp)
- .align(Alignment.TopEnd)
- ) {
- actionButtons.forEach { actionButton ->
- actionButton(backdrop)
- }
- }
-
- content(topPadding + 64.dp, hazeState, bottomPadding + 12.dp)
+ content(0.dp, hazeState, innerPadding.calculateBottomPadding() + 16.dp)
}
}
}
-
@Composable
fun StyledScaffold(
title: String,
@@ -141,7 +104,7 @@ fun StyledScaffold(
title = title,
actionButtons = actionButtons,
snackbarHostState = snackbarHostState,
- ) { _, _, _->
+ ) { _, _, _ ->
content()
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSelectList.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSelectList.kt
index b9f6c2f70..3e5cb051b 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSelectList.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSelectList.kt
@@ -18,42 +18,36 @@
package me.kavishdevar.librepods.presentation.components
-import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
-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.interaction.MutableInteractionSource
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.fillMaxWidth
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
-import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.graphics.RectangleShape
+import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
-import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
-import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.text.font.Font
-import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import me.kavishdevar.librepods.R
data class SelectItem(
val name: String,
@@ -65,121 +59,108 @@ data class SelectItem(
val enabled: Boolean = true
)
+/**
+ * Material 3 Expressive single-select list. A tonal [Surface] card whose rows spring on press and
+ * mark the current choice with a Material check icon that fades in. Each row is flush (square) and
+ * the whole group is rounded into one [MaterialTheme.shapes.large] card.
+ */
@Composable
fun StyledSelectList(
items: List,
modifier: Modifier = Modifier
) {
- val isDarkTheme = isSystemInDarkTheme()
- val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
- val textColor = if (isDarkTheme) Color.White else Color.Black
-
val haptics = LocalHapticFeedback.current
- Column(
- modifier = modifier
- .fillMaxWidth()
- .background(backgroundColor, RoundedCornerShape(28.dp)),
- horizontalAlignment = Alignment.CenterHorizontally
+ Surface(
+ modifier = modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surfaceContainer,
) {
- val visibleItems = items.filter { it.visible }
- visibleItems.forEachIndexed { index, item ->
- val isFirst = index == 0
- val isLast = index == visibleItems.size - 1
- val hasIcon = item.iconRes != null
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ val visibleItems = items.filter { it.visible }
+ visibleItems.forEachIndexed { index, item ->
+ val isLast = index == visibleItems.size - 1
+ val hasIcon = item.iconRes != null
- val shape = when {
- isFirst && isLast -> RoundedCornerShape(28.dp)
- isFirst -> RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp)
- isLast -> RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
- else -> RoundedCornerShape(0.dp)
- }
- var itemBackgroundColor by remember { mutableStateOf(if (item.enabled) backgroundColor else if (isDarkTheme) Color(0x40050505) else Color(0x40D9D9D9)) }
- val animatedBackgroundColor by animateColorAsState(targetValue = itemBackgroundColor, animationSpec = tween(durationMillis = 500))
+ val interaction = remember(item) { MutableInteractionSource() }
+ val scale by rememberBounceScale(interaction)
- Row(
- modifier = Modifier
- .heightIn(min = if (hasIcon) 72.dp else 55.dp)
- .background(animatedBackgroundColor, shape)
- .pointerInput(Unit) {
- detectTapGestures(
- onPress = {
- if (item.enabled) {
- itemBackgroundColor =
- if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
- tryAwaitRelease()
- itemBackgroundColor = backgroundColor
- }
- },
- onTap = {
- if (item.enabled) {
- haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
- item.onClick()
- }
- }
- )
- }
- .padding(horizontal = 16.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- if (hasIcon) {
- Icon(
- painter = painterResource(item.iconRes),
- contentDescription = "Icon",
- tint = Color(0xFF007AFF),
- modifier = Modifier
- .height(48.dp)
- .wrapContentWidth()
- )
- }
- Column(
+ Surface(
+ onClick = {
+ if (item.enabled) {
+ haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
+ item.onClick()
+ }
+ },
+ enabled = item.enabled,
+ interactionSource = interaction,
+ shape = RectangleShape,
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ contentColor = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
- .weight(1f)
- .padding(vertical = 2.dp)
- .padding(start = if (hasIcon) 8.dp else 4.dp)
+ .fillMaxWidth()
+ .graphicsLayer {
+ scaleX = scale
+ scaleY = scale
+ }
) {
- Text(
- item.name,
- fontSize = 16.sp,
- color = textColor,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- )
- item.description?.let {
- Text(
- it,
- fontSize = 14.sp,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro)),
+ Row(
+ modifier = Modifier
+ .heightIn(min = if (hasIcon) 72.dp else 56.dp)
+ .padding(horizontal = 20.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ if (hasIcon) {
+ Icon(
+ painter = painterResource(item.iconRes!!),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier
+ .size(40.dp)
+ .wrapContentWidth()
+ )
+ }
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .padding(vertical = 8.dp)
+ .padding(start = if (hasIcon) 12.dp else 0.dp)
+ ) {
+ Text(
+ text = item.name,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ item.description?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ val floatAnimateState by animateFloatAsState(
+ targetValue = if (item.selected) 1f else 0f,
+ label = "checkAlpha"
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Icon(
+ imageVector = Icons.Filled.Check,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary.copy(alpha = floatAnimateState),
+ modifier = Modifier.size(24.dp)
)
}
}
- val floatAnimateState by animateFloatAsState(
- targetValue = if (item.selected) 1f else 0f,
- animationSpec = tween(durationMillis = 300)
- )
- Text(
- text = "",
- style = TextStyle(
- fontSize = 20.sp,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- color = Color(0xFF007AFF).copy(alpha = floatAnimateState),
- ),
- modifier = Modifier.padding(end = 4.dp)
- )
- }
- if (!isLast) {
- if (hasIcon) {
+ if (!isLast) {
HorizontalDivider(
thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier.padding(start = 72.dp, end = 20.dp)
- )
- } else {
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier.padding(start = 20.dp, end = 20.dp)
+ color = MaterialTheme.colorScheme.outlineVariant,
+ modifier = Modifier.padding(
+ start = if (hasIcon) 72.dp else 20.dp,
+ end = 20.dp
+ )
)
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSlider.kt
index 58c9b571d..7ce7ed503 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSlider.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSlider.kt
@@ -18,203 +18,42 @@
package me.kavishdevar.librepods.presentation.components
-import android.annotation.SuppressLint
-import android.content.res.Configuration
-import android.util.Log
-import androidx.compose.animation.core.Animatable
-import androidx.compose.animation.core.FiniteAnimationSpec
-import androidx.compose.animation.core.spring
-import androidx.compose.foundation.background
-import androidx.compose.foundation.gestures.Orientation
-import androidx.compose.foundation.gestures.draggable
-import androidx.compose.foundation.gestures.rememberDraggableState
-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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.derivedStateOf
-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.clip
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.graphicsLayer
-import androidx.compose.ui.hapticfeedback.HapticFeedbackType
-import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.input.pointer.util.VelocityTracker
-import androidx.compose.ui.input.pointer.util.addPointerInputChange
-import androidx.compose.ui.layout.layout
-import androidx.compose.ui.layout.onGloballyPositioned
-import androidx.compose.ui.layout.onSizeChanged
-import androidx.compose.ui.layout.positionInParent
-import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.platform.LocalHapticFeedback
-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.Velocity
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-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.backdrops.layerBackdrop
-import com.kyant.backdrop.backdrops.rememberCombinedBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
-import com.kyant.backdrop.drawBackdrop
-import com.kyant.backdrop.effects.blur
-import com.kyant.backdrop.effects.lens
-import com.kyant.backdrop.highlight.Highlight
-import com.kyant.backdrop.shadow.InnerShadow
-import com.kyant.backdrop.shadow.Shadow
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
-import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.utils.inspectDragGestures
-import kotlin.math.abs
-import kotlin.math.roundToInt
-@Composable
-fun rememberMomentumAnimation(
- maxScale: Float,
- progressAnimationSpec: FiniteAnimationSpec =
- spring(1f, 1000f, 0.01f),
- velocityAnimationSpec: FiniteAnimationSpec =
- spring(0.5f, 250f, 5f),
- scaleXAnimationSpec: FiniteAnimationSpec =
- spring(0.4f, 400f, 0.01f),
- scaleYAnimationSpec: FiniteAnimationSpec =
- spring(0.6f, 400f, 0.01f)
-): MomentumAnimation {
- val animationScope = rememberCoroutineScope()
- return remember(
- maxScale,
- animationScope,
- progressAnimationSpec,
- velocityAnimationSpec,
- scaleXAnimationSpec,
- scaleYAnimationSpec
- ) {
- MomentumAnimation(
- maxScale = maxScale,
- animationScope = animationScope,
- progressAnimationSpec = progressAnimationSpec,
- velocityAnimationSpec = velocityAnimationSpec,
- scaleXAnimationSpec = scaleXAnimationSpec,
- scaleYAnimationSpec = scaleYAnimationSpec
- )
- }
-}
-
-class MomentumAnimation(
- val maxScale: Float,
- private val animationScope: CoroutineScope,
- private val progressAnimationSpec: FiniteAnimationSpec,
- private val velocityAnimationSpec: FiniteAnimationSpec,
- private val scaleXAnimationSpec: FiniteAnimationSpec,
- private val scaleYAnimationSpec: FiniteAnimationSpec
-) {
-
- private val velocityTracker = VelocityTracker()
-
- private val progressAnimation = Animatable(0f)
- private val velocityAnimation = Animatable(0f)
- private val scaleXAnimation = Animatable(1f)
- private val scaleYAnimation = Animatable(1f)
-
- val progress: Float get() = progressAnimation.value
- val velocity: Float get() = velocityAnimation.value
- val scaleX: Float get() = scaleXAnimation.value
- val scaleY: Float get() = scaleYAnimation.value
-
- var isDragging: Boolean by mutableStateOf(false)
- private set
-
- val modifier: Modifier = Modifier.pointerInput(Unit) {
- inspectDragGestures(
- onDragStart = {
- isDragging = true
- velocityTracker.resetTracking()
- startPressingAnimation()
- },
- onDragEnd = { change ->
- isDragging = false
- val velocity = velocityTracker.calculateVelocity()
- updateVelocity(velocity)
- velocityTracker.addPointerInputChange(change)
- velocityTracker.resetTracking()
- endPressingAnimation()
- settleVelocity()
- },
- onDragCancel = {
- isDragging = false
- velocityTracker.resetTracking()
- endPressingAnimation()
- settleVelocity()
- }
- ) { change, _ ->
- isDragging = true
- velocityTracker.addPointerInputChange(change)
- val velocity = velocityTracker.calculateVelocity()
- updateVelocity(velocity)
- }
- }
-
- private fun updateVelocity(velocity: Velocity) {
- animationScope.launch { velocityAnimation.animateTo(velocity.x, velocityAnimationSpec) }
- }
-
- private fun settleVelocity() {
- animationScope.launch { velocityAnimation.animateTo(0f, velocityAnimationSpec) }
- }
-
- fun startPressingAnimation() {
- animationScope.launch {
- launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
- launch { scaleXAnimation.animateTo(maxScale, scaleXAnimationSpec) }
- launch { scaleYAnimation.animateTo(maxScale, scaleYAnimationSpec) }
- }
- }
-
- fun endPressingAnimation() {
- animationScope.launch {
- launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
- launch { scaleXAnimation.animateTo(1f, scaleXAnimationSpec) }
- launch { scaleYAnimation.animateTo(1f, scaleYAnimationSpec) }
- }
- }
-}
-
-@SuppressLint("UnrememberedMutableState")
+/**
+ * Material 3 Expressive slider. Signature preserved; the old liquid-glass track is replaced with an
+ * M3 [Slider] (springy thumb + expressive motion). [startIcon]/[endIcon] are SF-Symbol glyph strings
+ * converted to Material icons via [sfSymbolToIcon]. [backdrop], [snapPoints] and [snapThreshold] are
+ * retained for compatibility but unused.
+ */
@Composable
fun StyledSlider(
label: String? = null,
value: Float,
onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange,
- backdrop: Backdrop = rememberLayerBackdrop(),
- snapPoints: List = emptyList(),
- snapThreshold: Float = 0.05f,
+ @Suppress("UNUSED_PARAMETER") backdrop: Backdrop = rememberLayerBackdrop(),
+ @Suppress("UNUSED_PARAMETER") snapPoints: List = emptyList(),
+ @Suppress("UNUSED_PARAMETER") snapThreshold: Float = 0.05f,
startIcon: String? = null,
endIcon: String? = null,
startLabel: String? = null,
@@ -223,409 +62,89 @@ fun StyledSlider(
description: String? = null,
enabled: Boolean = true
) {
- val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
- val isLightTheme = !isSystemInDarkTheme()
- val trackColor =
- if (isLightTheme) Color(0xFF787878).copy(0.2f)
- else Color(0xFF787880).copy(0.36f)
- val accentColor =
- if (enabled) {
- if (isLightTheme) Color(0xFF0088FF)
- else Color(0xFF0091FF)
- } else {
- trackColor
- }
- val labelTextColor = if (isLightTheme) Color.Black else Color.White
-
- val fraction by derivedStateOf {
- ((value - valueRange.start) / (valueRange.endInclusive - valueRange.start))
- .fastCoerceIn(0f, 1f)
- }
-
- val sliderBackdrop = rememberLayerBackdrop()
- val trackWidthState = remember { mutableFloatStateOf(0f) }
- val trackPositionState = remember { mutableFloatStateOf(0f) }
- val startIconWidthState = remember { mutableFloatStateOf(0f) }
- val endIconWidthState = remember { mutableFloatStateOf(0f) }
- val density = LocalDensity.current
- val haptics = LocalHapticFeedback.current
- var lastDragValue by remember { mutableFloatStateOf(value) }
-
- val momentumAnimation = rememberMomentumAnimation(maxScale = 1.5f)
-
- val content = @Composable {
- Box(
- Modifier
- .fillMaxWidth(if (startIcon == null && endIcon == null) 0.95f else 1f)
- ) {
- Box(
- Modifier
- .padding(vertical = 4.dp)
- .layerBackdrop(sliderBackdrop)
- .fillMaxWidth()
- ) {
- Column(
- modifier = Modifier
- .fillMaxWidth(1f)
- .padding(vertical = 12.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- if (startLabel != null || endLabel != null) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 8.dp),
- horizontalArrangement = Arrangement.SpaceBetween
- ) {
- Text(
- text = startLabel ?: "",
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Normal,
- color = labelTextColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- Text(
- text = endLabel ?: "",
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Normal,
- color = labelTextColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- }
- Spacer(modifier = Modifier.height(12.dp))
- }
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 4.dp)
- .then(if (startIcon == null && endIcon == null) Modifier.padding(horizontal = 8.dp) else Modifier),
- ) {
- Row(
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(0.dp)
- ) {
- if (startIcon != null) {
- Text(
- text = startIcon,
- style = TextStyle(
- fontSize = 18.sp,
- fontWeight = FontWeight.Normal,
- color = accentColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- modifier = Modifier
- .padding(horizontal = 12.dp)
- .onGloballyPositioned {
- startIconWidthState.floatValue = it.size.width.toFloat()
- }
- )
- }
- Box(
- Modifier
- .weight(1f)
- .onSizeChanged { trackWidthState.floatValue = it.width.toFloat() }
- .onGloballyPositioned {
- trackPositionState.floatValue =
- it.positionInParent().y + it.size.height / 2f
- }
- ) {
- Box(
- Modifier
- .clip(RoundedCornerShape(28.dp))
- .background(trackColor)
- .height(6f.dp)
- .fillMaxWidth()
- )
-
- Box(
- Modifier
- .clip(RoundedCornerShape(28.dp))
- .background(accentColor)
- .height(6f.dp)
- .layout { measurable, constraints ->
- val placeable = measurable.measure(constraints)
- val fraction = fraction
- val width =
- (fraction * constraints.maxWidth).fastRoundToInt()
- layout(width, placeable.height) {
- placeable.place(0, 0)
- }
- }
- )
- }
- if (endIcon != null) {
- Text(
- text = endIcon,
- style = TextStyle(
- fontSize = 18.sp,
- fontWeight = FontWeight.Normal,
- color = accentColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- modifier = Modifier
- .padding(horizontal = 12.dp)
- .onGloballyPositioned {
- endIconWidthState.floatValue = it.size.width.toFloat()
- }
- )
- }
- }
- if (snapPoints.isNotEmpty() && startLabel != null && endLabel != null) Spacer(modifier = Modifier.height(4.dp))
- Row(
- modifier = Modifier
- .fillMaxWidth(),
- horizontalArrangement = Arrangement.Center
- ) {
- if (snapPoints.isNotEmpty()) {
- val trackWidth = if (startIcon != null && endIcon != null) trackWidthState.floatValue - with(density) { 6.dp.toPx() } * 2 else trackWidthState.floatValue- with(density) { 22.dp.toPx() }
- val startOffset =
- if (startIcon != null) startIconWidthState.floatValue + with(
- density
- ) { 34.dp.toPx() } else with(density) { 14.dp.toPx() }
- Box(
- Modifier
- .fillMaxWidth()
- ) {
- snapPoints.forEach { point ->
- val pointFraction =
- ((point - valueRange.start) / (valueRange.endInclusive - valueRange.start))
- .fastCoerceIn(0f, 1f)
- Box(
- Modifier
- .graphicsLayer {
- translationX =
- startOffset + pointFraction * trackWidth - 4.dp.toPx()
- }
- .size(2.dp)
- .background(
- trackColor,
- CircleShape
- )
- )
- }
- }
- }
- }
- }
- }
- }
-
- Box(
- Modifier
- .graphicsLayer {
-// val startOffset =
-// if (startIcon != null) startIconWidthState.floatValue + with(density) { 24.dp.toPx() } else with(density) { 12.dp.toPx() }
-// translationX =
-// startOffset + fraction * trackWidthState.floatValue - size.width / 2f
- val startOffset =
- if (startIcon != null)
- startIconWidthState.floatValue + with(density) { 24.dp.toPx() }
- else
- with(density) { 8.dp.toPx() }
-
- translationX =
- (startOffset + fraction * trackWidthState.floatValue - size.width / 2f)
- .fastCoerceIn(
- startOffset - size.width / 4f,
- startOffset + trackWidthState.floatValue - size.width * 3f / 4f
- )
- translationY = if (startLabel != null || endLabel != null) trackPositionState.floatValue + with(density) { 26.dp.toPx() } + size.height / 2f else trackPositionState.floatValue + with(density) { 8.dp.toPx() }
- }
- .then(
- if (enabled) {
- Modifier
- .draggable(
- rememberDraggableState { delta ->
- val trackWidth = trackWidthState.floatValue
- if (trackWidth > 0f) {
- val targetFraction = fraction + delta / trackWidth
- val targetValue =
- lerp(
- valueRange.start,
- valueRange.endInclusive,
- targetFraction
- )
- .fastCoerceIn(
- valueRange.start,
- valueRange.endInclusive
- )
- snapPoints.forEach { snap ->
- if ((lastDragValue < snap && targetValue >= snap) ||
- (snap in targetValue.. Unit = {
+ Column(
modifier = Modifier
- .fillMaxWidth(),
- verticalArrangement = Arrangement.spacedBy(2.dp)
+ .fillMaxWidth()
+ .padding(vertical = 8.dp)
) {
if (label != null) {
Text(
text = label,
- style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- color = labelTextColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- modifier = Modifier.padding(horizontal = 18.dp, vertical = 4.dp)
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(bottom = 8.dp)
)
}
-
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .background(backgroundColor, RoundedCornerShape(28.dp))
- .padding(horizontal = 8.dp, vertical = 0.dp)
- .heightIn(min = 58.dp),
- contentAlignment = Alignment.Center
+ if (startLabel != null || endLabel != null) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = startLabel ?: "",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Text(
+ text = endLabel ?: "",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
- content()
+ startIcon?.let { sfSymbolToIcon(it) }?.let { vector ->
+ Icon(
+ imageVector = vector,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(22.dp),
+ )
+ }
+ Slider(
+ value = value,
+ onValueChange = onValueChange,
+ valueRange = valueRange,
+ enabled = enabled,
+ modifier = Modifier.weight(1f),
+ )
+ endIcon?.let { sfSymbolToIcon(it) }?.let { vector ->
+ Icon(
+ imageVector = vector,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(22.dp),
+ )
+ }
}
-
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 = 18.dp, vertical = 4.dp)
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 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()
}
-}
-
-private fun snapIfClose(value: Float, points: List, threshold: Float = 0.05f): Float {
- val nearest = points.minByOrNull { abs(it - value) } ?: value
- return if (abs(nearest - value) <= threshold) nearest else value
-}
-@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
-@Composable
-fun StyledSliderPreview() {
- val a = remember { mutableFloatStateOf(0.5f) }
- Box(
- Modifier
- .background(if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF0F0F0))
- .padding(16.dp)
- .fillMaxSize()
- ) {
- Column (
- Modifier.align(Alignment.Center),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- )
- {
- StyledSlider(
- value = a.floatValue,
- onValueChange = {
- a.floatValue = it
- },
- valueRange = 0f..2f,
- snapPoints = listOf(1f),
- snapThreshold = 0.1f,
- independent = true,
- startIcon = "A",
- endIcon = "B",
- )
- StyledSlider(
- value = a.floatValue,
- onValueChange = {
- a.floatValue = it
- },
- valueRange = 0f..2f,
- snapPoints = listOf(1f),
- snapThreshold = 0.1f,
- independent = true,
- startIcon = "A",
- endIcon = "B",
- enabled = false
- )
+ if (independent) {
+ Surface(
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Box(modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp)) {
+ inner()
+ }
}
+ } else {
+ inner()
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSwitch.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSwitch.kt
index d479da0d2..3651d8dbc 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSwitch.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSwitch.kt
@@ -18,300 +18,44 @@
package me.kavishdevar.librepods.presentation.components
-import android.content.res.Configuration
-import androidx.compose.animation.animateColorAsState
-import androidx.compose.animation.core.Animatable
-import androidx.compose.animation.core.FastOutSlowInEasing
-import androidx.compose.animation.core.spring
-import androidx.compose.animation.core.tween
-import androidx.compose.foundation.background
-import androidx.compose.foundation.gestures.Orientation
-import androidx.compose.foundation.gestures.draggable
-import androidx.compose.foundation.gestures.rememberDraggableState
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.foundation.layout.Box
-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.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Switch
+import androidx.compose.material3.SwitchDefaults
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.derivedStateOf
-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.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.BlendMode
-import androidx.compose.ui.graphics.BlurEffect
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.Paint
-import androidx.compose.ui.graphics.TileMode
-import androidx.compose.ui.graphics.drawOutline
-import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
-import androidx.compose.ui.graphics.drawscope.scale
-import androidx.compose.ui.graphics.drawscope.translate
-import androidx.compose.ui.graphics.graphicsLayer
-import androidx.compose.ui.graphics.layer.CompositingStrategy
-import androidx.compose.ui.graphics.layer.drawLayer
-import androidx.compose.ui.graphics.rememberGraphicsLayer
-import androidx.compose.ui.hapticfeedback.HapticFeedbackType
-import androidx.compose.ui.layout.onSizeChanged
-import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.util.fastCoerceIn
-import androidx.compose.ui.util.lerp
-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.lens
-import com.kyant.backdrop.highlight.Highlight
-import com.kyant.backdrop.shadow.Shadow
-import kotlinx.coroutines.coroutineScope
-import kotlinx.coroutines.launch
-import kotlin.math.abs
+/**
+ * Material 3 Expressive [Switch]. The thumb already morphs/grows on press and animates with the
+ * theme's expressive motion scheme; we add a check-mark thumb when on for extra clarity.
+ */
@Composable
fun StyledSwitch(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
enabled: Boolean = true,
) {
- val isDarkTheme = isSystemInDarkTheme()
- val haptics = LocalHapticFeedback.current
-
- val onColor = if (enabled) Color(0xFF34C759) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
- val offColor = if (enabled) if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6) else if (isDarkTheme) Color(
- 0x805B5B5E
- ) else Color(0xFFD1D1D6)
-
- val trackWidth = 64.dp
- val trackHeight = 28.dp
- val thumbHeight = 24.dp
- val thumbWidth = 39.dp
-
- val backdrop = rememberLayerBackdrop()
- val switchBackdrop = rememberLayerBackdrop()
- val fraction by remember {
- derivedStateOf { if (checked) 1f else 0f }
- }
- val animatedFraction = remember { Animatable(fraction) }
- val trackWidthPx = remember { mutableFloatStateOf(0f) }
- val density = LocalDensity.current
- val scope = rememberCoroutineScope()
- val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
- val progressAnimation = remember { Animatable(0f) }
- val innerShadowLayer = rememberGraphicsLayer().apply {
- compositingStrategy = CompositingStrategy.Offscreen
- }
- val targetColor = if (checked) onColor else offColor
- val animatedTrackColor by animateColorAsState(targetColor)
- val totalDrag = remember { mutableFloatStateOf(0f) }
- val tapThreshold = 10f
- val isFirstComposition = remember { mutableStateOf(true) }
- LaunchedEffect(checked) {
- if (!isFirstComposition.value) {
- if (checked) {
- haptics.performHapticFeedback(HapticFeedbackType.ToggleOn)
- } else {
- haptics.performHapticFeedback(HapticFeedbackType.ToggleOff)
- }
- coroutineScope {
- launch {
- val targetFrac = if (checked) 1f else 0f
- animatedFraction.animateTo(targetFrac, progressAnimationSpec)
- }
- if (progressAnimation.value > 0f) return@coroutineScope
- launch {
- progressAnimation.animateTo(1f, tween(175, easing = FastOutSlowInEasing))
- progressAnimation.animateTo(0f, tween(175, easing = FastOutSlowInEasing))
- }
- }
- }
- isFirstComposition.value = false
- }
-
- Box(
- modifier = Modifier
- .width(trackWidth)
- .height(trackHeight),
- contentAlignment = Alignment.CenterStart
- ) {
- Box(
- modifier = Modifier
- .layerBackdrop(switchBackdrop)
- .clip(RoundedCornerShape(trackHeight / 2))
- .background(animatedTrackColor)
- .width(trackWidth)
- .height(trackHeight)
- .onSizeChanged { trackWidthPx.floatValue = it.width.toFloat() }
- )
- Box(
- modifier = Modifier
- .padding(horizontal = 2.dp)
- .graphicsLayer {
- translationX = animatedFraction.value * (trackWidthPx.floatValue - with(density) { thumbWidth.toPx() + 4.dp.toPx() })
- }
- .then(if (enabled) Modifier.draggable(
- rememberDraggableState { delta ->
- if (trackWidthPx.floatValue > 0f) {
- val oldFraction = animatedFraction.value
- val newFraction = (animatedFraction.value + delta / trackWidthPx.floatValue).fastCoerceIn(-0.3f, 1.3f)
- scope.launch {
- animatedFraction.snapTo(newFraction)
- }
- totalDrag.floatValue += abs(delta)
- val newChecked = newFraction >= 0.5f
- if (newChecked != checked) {
- onCheckedChange(newChecked)
- }
- if ((oldFraction < 0.5f && newFraction >= 0.5f) || (oldFraction >= 0.5f && newFraction < 0.5f)) {
- haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
- }
- }
- },
- Orientation.Horizontal,
- startDragImmediately = true,
- onDragStarted = {
- totalDrag.floatValue = 0f
- scope.launch {
- progressAnimation.animateTo(1f, progressAnimationSpec)
- }
- },
- onDragStopped = {
- scope.launch {
- if (totalDrag.floatValue < tapThreshold) {
- val newChecked = !checked
- onCheckedChange(newChecked)
- val snappedFraction = if (newChecked) 1f else 0f
- coroutineScope {
- launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
- launch { animatedFraction.animateTo(snappedFraction, progressAnimationSpec) }
- }
- } else {
- val snappedFraction = if (animatedFraction.value >= 0.5f) 1f else 0f
- onCheckedChange(snappedFraction >= 0.5f)
- coroutineScope {
- launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
- launch { animatedFraction.animateTo(snappedFraction, progressAnimationSpec) }
- }
- }
- }
- }
- ) else Modifier)
- .drawBackdrop(
- rememberCombinedBackdrop(backdrop, switchBackdrop),
- { RoundedCornerShape(thumbHeight / 2) },
- highlight = {
- val progress = progressAnimation.value
- Highlight.Ambient.copy(
- alpha = progress
- )
- },
- shadow = {
- Shadow(
- radius = 4f.dp,
- color = Color.Black.copy(0.05f)
- )
- },
- layerBlock = {
- val progress = progressAnimation.value
- val scale = lerp(1f, 1.5f, progress)
- scaleX = scale
- scaleY = scale
- },
- onDrawBackdrop = { drawScope ->
- drawIntoCanvas { canvas ->
- canvas.save()
- canvas.drawRect(
- left = 0f,
- top = 0f,
- right = size.width,
- bottom = size.height,
- paint = Paint().apply {
- color = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7)
- }
- )
- scale(0.7f) {
- drawScope()
- }
- }
- },
- onDrawSurface = {
- val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
-
- val shape = RoundedCornerShape(thumbHeight / 2)
- 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(1f - progress))
- },
- effects = {
- lens(
- refractionHeight = 6f.dp.toPx(),
- refractionAmount = size.height / 2f,
- depthEffect = true,
- chromaticAberration = true
- )
- }
+ Switch(
+ checked = checked,
+ onCheckedChange = onCheckedChange,
+ enabled = enabled,
+ thumbContent = if (checked) {
+ {
+ Icon(
+ imageVector = Icons.Filled.Check,
+ contentDescription = null,
+ modifier = Modifier.size(SwitchDefaults.IconSize),
)
- .width(thumbWidth)
- .height(thumbHeight)
- )
- }
+ }
+ } else null,
+ )
}
-@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
+@Preview
@Composable
fun StyledSwitchPreview() {
- val isDarkTheme = isSystemInDarkTheme()
- val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7)
- Box(
- modifier = Modifier
- .background(backgroundColor)
- .width(100.dp)
- .height(150.dp),
- contentAlignment = Alignment.Center
- ) {
- val checked = remember { mutableStateOf(true) }
- StyledSwitch(
- checked = checked.value,
- onCheckedChange = {
- checked.value = it
- },
- enabled = true,
- )
-// LaunchedEffect(Unit) {
-// delay(1000)
-// checked.value = false
-// delay(1000)
-// checked.value = true
-// }
- }
+ StyledSwitch(checked = true, onCheckedChange = {})
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledToggle.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledToggle.kt
index 4028dc4a2..deca526b5 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledToggle.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledToggle.kt
@@ -16,50 +16,34 @@
along with this program. If not, see .
*/
-@file:OptIn(ExperimentalEncodingApi::class)
-
package me.kavishdevar.librepods.presentation.components
-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.heightIn
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.rememberUpdatedState
-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.hapticfeedback.HapticFeedbackType
-import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.platform.LocalHapticFeedback
-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.graphics.RectangleShape
+import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import kotlinx.coroutines.launch
-import me.kavishdevar.librepods.R
-import kotlin.io.encoding.ExperimentalEncodingApi
+/**
+ * Material 3 Expressive labelled toggle row. `independent = true` renders a rounded tonal card
+ * whose whole surface springs and toggles on tap; `false` is a flush row for use inside a section.
+ */
@Composable
fun StyledToggle(
title: String? = null,
@@ -70,191 +54,86 @@ fun StyledToggle(
enabled: Boolean = true,
onCheckedChange: (Boolean) -> Unit,
) {
- val currentChecked by rememberUpdatedState(checked)
-
- val isDarkTheme = isSystemInDarkTheme()
- val textColor = if (isDarkTheme) Color.White else Color.Black
-
- val haptics = LocalHapticFeedback.current
- val scope = rememberCoroutineScope()
-
- var backgroundColor by remember {
- mutableStateOf(
- if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
- )
- }
-
- val animatedBackgroundColor by animateColorAsState(
- targetValue = backgroundColor,
- animationSpec = tween(durationMillis = 500)
- )
-
if (independent) {
- Column(modifier = Modifier.padding(vertical = 8.dp)) {
+ val interaction = remember { MutableInteractionSource() }
+ val scale by rememberBounceScale(interaction)
+ Column(modifier = Modifier.padding(vertical = 4.dp)) {
if (title != null) {
Text(
text = title,
- style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- modifier = Modifier.padding(
- start = 16.dp,
- end = 16.dp,
- top = 8.dp,
- bottom = 4.dp
- )
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 20.dp, end = 16.dp, top = 8.dp, bottom = 6.dp)
)
}
-
- Box(
+ Surface(
+ onClick = { if (enabled) onCheckedChange(!checked) },
+ enabled = enabled,
+ interactionSource = interaction,
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surfaceContainer,
modifier = Modifier
- .background(animatedBackgroundColor, RoundedCornerShape(28.dp))
- .padding(4.dp)
- .pointerInput(Unit) {
- detectTapGestures(
- onPress = {
- if (enabled) {
- backgroundColor =
- if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
- tryAwaitRelease()
- backgroundColor =
- if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
- }
- },
- onTap = {
- if (enabled) {
- scope.launch { haptics.performHapticFeedback(if (!currentChecked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
- onCheckedChange(!currentChecked)
- }
- }
- )
+ .fillMaxWidth()
+ .graphicsLayer {
+ scaleX = scale
+ scaleY = scale
}
) {
Row(
modifier = Modifier
- .fillMaxWidth()
- .height(55.dp)
- .padding(horizontal = 12.dp),
+ .heightIn(min = 56.dp)
+ .padding(horizontal = 20.dp, vertical = 8.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
- )
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface
)
-
+ Spacer(modifier = Modifier.width(12.dp))
StyledSwitch(
checked = checked,
enabled = enabled,
- onCheckedChange = {
- if (enabled) {
- scope.launch { haptics.performHapticFeedback(if (it) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
- onCheckedChange(it)
- }
- }
+ onCheckedChange = { if (enabled) onCheckedChange(it) }
)
}
}
-
if (description != null) {
- Spacer(modifier = Modifier.height(8.dp))
-
- Box(
- modifier = Modifier
- .padding(horizontal = 16.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))
- )
- )
- }
+ Text(
+ text = description,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 20.dp, end = 16.dp, top = 6.dp)
+ )
}
}
} else {
- val isPressed = remember { mutableStateOf(false) }
-
Row(
modifier = Modifier
.fillMaxWidth()
- .background(
- shape = RoundedCornerShape(28.dp),
- color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
- )
- .padding(16.dp)
- .pointerInput(Unit) {
- detectTapGestures(
- onPress = {
- isPressed.value = true
- tryAwaitRelease()
- isPressed.value = false
- }
- )
- }
- .clickable(
- indication = null,
- interactionSource = remember { MutableInteractionSource() }
- ) {
- if (enabled) {
- scope.launch { haptics.performHapticFeedback(if (!currentChecked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
- onCheckedChange(!currentChecked)
- }
- },
+ .padding(horizontal = 4.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
- Column(
- modifier = Modifier
- .weight(1f)
- .padding(end = 4.dp)
- ) {
+ Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
- style = TextStyle(
- fontSize = 16.sp,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- fontWeight = FontWeight.Normal,
- color = textColor
- )
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface
)
-
- Spacer(modifier = Modifier.height(4.dp))
-
if (description != null) {
Text(
text = description,
- style = TextStyle(
- fontSize = 12.sp,
- color = textColor.copy(0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- )
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
-
+ Spacer(modifier = Modifier.width(12.dp))
StyledSwitch(
checked = checked,
enabled = enabled,
- onCheckedChange = {
- if (enabled) {
- onCheckedChange(it)
- }
- }
+ onCheckedChange = { if (enabled) onCheckedChange(it) }
)
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/VerticalVolumeSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/VerticalVolumeSlider.kt
index 37bddd8e6..fd2f81556 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/VerticalVolumeSlider.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/VerticalVolumeSlider.kt
@@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
@@ -42,7 +43,6 @@ 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.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
@@ -71,8 +71,8 @@ fun VerticalVolumeSlider(
cornerRadiusChangeFactor: Float = 0.2f,
directionalStretchRatio: Float = 0.75f
) {
- val trackColor = Color(0x593C3C3E)
- val progressColor = Color.White
+ val trackColor = MaterialTheme.colorScheme.surfaceVariant
+ val progressColor = MaterialTheme.colorScheme.primary
var dragFraction by remember { mutableFloatStateOf(initialFraction) }
var isDragging by remember { mutableStateOf(false) }
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/AppDestinations.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/AppDestinations.kt
new file mode 100644
index 000000000..f0daad9e6
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/AppDestinations.kt
@@ -0,0 +1,70 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.presentation.m3
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Headphones
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.Tune
+import androidx.compose.material.icons.outlined.Headphones
+import androidx.compose.material.icons.outlined.Settings
+import androidx.compose.material.icons.outlined.Tune
+import androidx.compose.ui.graphics.vector.ImageVector
+
+/**
+ * Top-level destinations shown in the floating-toolbar bottom navigation (Caffeine-style).
+ * `route` is the navigation-compose route string for the tab's start screen.
+ */
+enum class AppDestinations(
+ val route: String,
+ val label: String,
+ val iconOutlined: ImageVector,
+ val iconFilled: ImageVector,
+) {
+ HOME("home", "Device", Icons.Outlined.Headphones, Icons.Filled.Headphones),
+ CONTROLS("controls", "Controls", Icons.Outlined.Tune, Icons.Filled.Tune),
+ SETTINGS("settings_tab", "Settings", Icons.Outlined.Settings, Icons.Filled.Settings),
+}
+
+val topLevelDestinations = listOf(
+ AppDestinations.HOME,
+ AppDestinations.CONTROLS,
+ AppDestinations.SETTINGS,
+)
+
+/**
+ * Resolve the toolbar tab that should appear selected for an arbitrary current route — including the
+ * pushed detail screens, which map back to the parent tab they were opened from (Caffeine-style), so
+ * the nav-bar pill never jumps to "Device" while you're inside a Controls/Settings subpage.
+ */
+fun destinationForRoute(route: String?): AppDestinations = when (route) {
+ AppDestinations.CONTROLS.route -> AppDestinations.CONTROLS
+ AppDestinations.SETTINGS.route -> AppDestinations.SETTINGS
+
+ // Detail screens opened from the Controls tab.
+ "transparency_customization", "adaptive_strength", "head_tracking", "accessibility",
+ "hearing_aid", "hearing_aid_adjustments", "camera_control", "hearing_protection",
+ "update_hearing_test", "long_press/{bud}" -> AppDestinations.CONTROLS
+
+ // Detail screens opened from the Settings tab.
+ "app_settings", "troubleshooting", "version_info", "open_source_licenses", "debug" -> AppDestinations.SETTINGS
+
+ // "rename" is reached from the Device (Home) tab; everything else falls back to Home.
+ else -> AppDestinations.HOME
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/AppHaptics.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/AppHaptics.kt
new file mode 100644
index 000000000..d37ad5628
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/AppHaptics.kt
@@ -0,0 +1,64 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.presentation.m3
+
+import android.view.HapticFeedbackConstants
+import android.view.View
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalView
+import kotlinx.coroutines.delay
+
+/**
+ * Centralised, view-backed haptics for the Material 3 Expressive UI. Using the platform
+ * [HapticFeedbackConstants] gives richer, OS-tuned feedback than Compose's generic haptics — fired
+ * on the key structural interactions (navigation, toggles, confirmations).
+ */
+@Stable
+class AppHaptics(
+ private val view: View,
+) {
+ /** Selecting a navigation destination / pushing a screen. */
+ fun navigation() = view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
+
+ /** Flipping a switch / toggle. */
+ fun toggle() = view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
+
+ /** A committed/positive action (primary button, apply). */
+ fun confirm() = view.performHapticFeedback(HapticFeedbackConstants.CONFIRM)
+
+ /** A light tick — segmented selection, slider detents. */
+ fun tick() = view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)
+
+ /** A short celebratory burst. */
+ suspend fun celebration() {
+ confirm()
+ delay(150)
+ tick()
+ delay(100)
+ tick()
+ }
+}
+
+@Composable
+fun rememberAppHaptics(): AppHaptics {
+ val view = LocalView.current
+ return remember(view) { AppHaptics(view) }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/AppNavBar.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/AppNavBar.kt
new file mode 100644
index 000000000..06e61c372
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/AppNavBar.kt
@@ -0,0 +1,164 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
+
+package me.kavishdevar.librepods.presentation.m3
+
+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.expandHorizontally
+import androidx.compose.animation.shrinkHorizontally
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.FloatingToolbarDefaults
+import androidx.compose.material3.HorizontalFloatingToolbar
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalRippleConfiguration
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.ToggleButton
+import androidx.compose.material3.ToggleButtonDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.boundsInParent
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+
+/**
+ * The floating bottom navigation bar — lifted from Caffeine Health: an M3 Expressive
+ * [HorizontalFloatingToolbar] of [ToggleButton]s with a bouncy sliding "pill" behind the selected
+ * tab (low-damping spring) and a label that expands open only on the active destination.
+ */
+@Composable
+fun AppNavBar(
+ current: AppDestinations,
+ onSelect: (AppDestinations) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val haptics = rememberAppHaptics()
+ val buttonBounds = remember { mutableStateMapOf() }
+ val currentIndex = topLevelDestinations.indexOf(current).coerceAtLeast(0)
+ val targetRect = buttonBounds[currentIndex]
+ val button0Rect = buttonBounds[0]
+ val pillRelativeX = (targetRect?.left ?: 0f) - (button0Rect?.left ?: 0f)
+
+ val pillAnimatedX by animateFloatAsState(
+ targetValue = pillRelativeX,
+ animationSpec = spring(dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessLow),
+ label = "pillX",
+ )
+ val pillAnimatedWidth by animateFloatAsState(
+ targetValue = targetRect?.width ?: 0f,
+ animationSpec = spring(dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessLow),
+ label = "pillWidth",
+ )
+ val pillColor = MaterialTheme.colorScheme.primary
+
+ HorizontalFloatingToolbar(
+ expanded = true,
+ colors = FloatingToolbarDefaults.vibrantFloatingToolbarColors(
+ toolbarContainerColor = MaterialTheme.colorScheme.primaryContainer,
+ toolbarContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ ),
+ modifier = modifier,
+ ) {
+ CompositionLocalProvider(LocalRippleConfiguration provides null) {
+ topLevelDestinations.forEachIndexed { index, destination ->
+ val selected = current == destination
+ ToggleButton(
+ checked = selected,
+ onCheckedChange = {
+ if (!selected) {
+ haptics.navigation()
+ onSelect(destination)
+ }
+ },
+ modifier = Modifier
+ .height(56.dp)
+ .onGloballyPositioned { coords -> buttonBounds[index] = coords.boundsInParent() }
+ .then(
+ if (index == 0) {
+ Modifier.drawWithContent {
+ if (pillAnimatedWidth > 0f) {
+ drawRoundRect(
+ color = pillColor,
+ topLeft = Offset(pillAnimatedX, 0f),
+ size = Size(pillAnimatedWidth, size.height),
+ cornerRadius = CornerRadius(size.height / 2f),
+ )
+ }
+ drawContent()
+ }
+ } else Modifier
+ ),
+ colors = ToggleButtonDefaults.toggleButtonColors(
+ checkedContainerColor = Color.Transparent,
+ checkedContentColor = MaterialTheme.colorScheme.onPrimary,
+ containerColor = Color.Transparent,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ ),
+ shapes = ToggleButtonDefaults.shapes(
+ shape = CircleShape,
+ pressedShape = CircleShape,
+ checkedShape = CircleShape,
+ ),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = if (selected) destination.iconFilled else destination.iconOutlined,
+ contentDescription = destination.label,
+ modifier = Modifier.size(24.dp),
+ )
+ AnimatedVisibility(
+ visible = selected,
+ enter = expandHorizontally(animationSpec = MaterialTheme.motionScheme.defaultSpatialSpec()),
+ exit = shrinkHorizontally(animationSpec = MaterialTheme.motionScheme.defaultSpatialSpec()),
+ ) {
+ Text(
+ text = destination.label,
+ modifier = Modifier.padding(start = ButtonDefaults.IconSpacing),
+ style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/AppShell.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/AppShell.kt
new file mode 100644
index 000000000..18737c572
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/AppShell.kt
@@ -0,0 +1,210 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+@file:OptIn(ExperimentalMaterial3Api::class)
+
+package me.kavishdevar.librepods.presentation.m3
+
+import androidx.compose.animation.AnimatedContentTransitionScope
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.only
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavHostController
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.currentBackStackEntryAsState
+import androidx.navigation.compose.rememberNavController
+import me.kavishdevar.librepods.presentation.m3.screens.ControlsScreen
+import me.kavishdevar.librepods.presentation.m3.screens.HomeScreen
+import me.kavishdevar.librepods.presentation.m3.screens.SettingsScreen
+import me.kavishdevar.librepods.presentation.screens.AccessibilitySettingsScreen
+import me.kavishdevar.librepods.presentation.screens.AdaptiveStrengthScreen
+import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen
+import me.kavishdevar.librepods.presentation.screens.CameraControlScreen
+import me.kavishdevar.librepods.presentation.screens.DebugScreen
+import me.kavishdevar.librepods.presentation.screens.HeadTrackingScreen
+import me.kavishdevar.librepods.presentation.screens.HearingAidAdjustmentsScreen
+import me.kavishdevar.librepods.presentation.screens.HearingAidScreen
+import me.kavishdevar.librepods.presentation.screens.HearingProtectionScreen
+import me.kavishdevar.librepods.presentation.screens.LongPress
+import me.kavishdevar.librepods.presentation.screens.OpenSourceLicensesScreen
+import me.kavishdevar.librepods.presentation.screens.RenameScreen
+import me.kavishdevar.librepods.presentation.screens.TransparencySettingsScreen
+import me.kavishdevar.librepods.presentation.screens.TroubleshootingScreen
+import me.kavishdevar.librepods.presentation.screens.UpdateHearingTestScreen
+import me.kavishdevar.librepods.presentation.screens.VersionScreen
+import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
+import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
+
+/**
+ * The single-activity shell: a [Scaffold] whose bottom bar is the Caffeine-style floating toolbar
+ * navigation, hosting three top-level tabs (Device / Controls / Settings) plus the pushed detail
+ * screens. Top-level switches are state-preserving; detail screens slide up and the nav bar tucks away.
+ */
+@Composable
+fun AppShell(viewModel: AirPodsViewModel) {
+ val navController = rememberNavController()
+ val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route
+ val current = destinationForRoute(currentRoute)
+
+ Scaffold(
+ modifier = Modifier.fillMaxSize(),
+ containerColor = MaterialTheme.colorScheme.background,
+ contentWindowInsets = WindowInsets(0.dp),
+ bottomBar = {
+ // The floating nav bar stays put across subpages (Caffeine-style) and pads only for the
+ // navigation-bar inset — NOT safeDrawing — so the keyboard (IME) never pushes it up.
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Bottom))
+ .padding(bottom = 16.dp),
+ contentAlignment = Alignment.BottomCenter,
+ ) {
+ AppNavBar(
+ current = current,
+ onSelect = { destination ->
+ navController.navigate(destination.route) {
+ popUpTo(navController.graph.startDestinationId) { saveState = true }
+ launchSingleTop = true
+ restoreState = true
+ }
+ },
+ )
+ }
+ },
+ ) { _ ->
+ NavHost(
+ navController = navController,
+ startDestination = AppDestinations.HOME.route,
+ // Opaque backdrop so the aggressive scale-down pop never flashes the raw window behind it.
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background),
+ // Tab switches cross-fade; pushing/popping a detail screen slides horizontally (Caffeine-style).
+ enterTransition = {
+ if (isTabSwitch()) {
+ fadeIn(tween(TAB_FADE_MS))
+ } else {
+ slideInHorizontally(initialOffsetX = { it }) + fadeIn(tween(DETAIL_FADE_MS))
+ }
+ },
+ exitTransition = {
+ if (isTabSwitch()) {
+ fadeOut(tween(TAB_FADE_MS))
+ } else {
+ slideOutHorizontally(targetOffsetX = { -it / 4 }) + fadeOut(tween(DETAIL_FADE_MS))
+ }
+ },
+ // Predictive back (manifest enableOnBackInvokedCallback=true) seeks these pop specs with the
+ // gesture on API 34+ (on 33 the pop just plays on back-press). Intentionally more aggressive
+ // than Caffeine: the dismissed page slides off AND scales down, while the revealed page eases
+ // back in from a slight inset.
+ popEnterTransition = {
+ if (isTabSwitch()) {
+ fadeIn(tween(TAB_FADE_MS))
+ } else {
+ slideInHorizontally(initialOffsetX = { -it / 4 }) + fadeIn(tween(DETAIL_FADE_MS)) +
+ scaleIn(initialScale = 0.94f)
+ }
+ },
+ popExitTransition = {
+ if (isTabSwitch()) {
+ fadeOut(tween(TAB_FADE_MS))
+ } else {
+ slideOutHorizontally(targetOffsetX = { it }) + fadeOut(tween(DETAIL_FADE_MS)) +
+ scaleOut(targetScale = 0.85f)
+ }
+ },
+ ) {
+ // --- Top-level tabs ---
+ composable(AppDestinations.HOME.route) { HomeScreen(viewModel, navController) }
+ composable(AppDestinations.CONTROLS.route) { ControlsScreen(viewModel, navController) }
+ composable(AppDestinations.SETTINGS.route) { SettingsScreen(viewModel, navController) }
+
+ // --- Detail screens (pushed) ---
+ detailRoutes(navController, viewModel)
+ }
+ }
+}
+
+/** Detail destinations pushed on top of the tabs. */
+private fun androidx.navigation.NavGraphBuilder.detailRoutes(
+ navController: NavHostController,
+ airPodsVm: AirPodsViewModel,
+) {
+ composable("debug") { DebugScreen(navController = navController) }
+ composable("long_press/{bud}") { entry ->
+ LongPress(
+ viewModel = airPodsVm,
+ name = entry.arguments?.getString("bud") ?: "",
+ navController = navController,
+ )
+ }
+ composable("rename") { RenameScreen(airPodsVm) }
+ composable("app_settings") {
+ val appSettingsViewModel: AppSettingsViewModel = viewModel()
+ AppSettingsScreen(navController, appSettingsViewModel)
+ }
+ composable("troubleshooting") { TroubleshootingScreen(navController) }
+ composable("head_tracking") { HeadTrackingScreen(airPodsVm, navController) }
+ composable("accessibility") { AccessibilitySettingsScreen(airPodsVm, navController) }
+ composable("transparency_customization") { TransparencySettingsScreen(airPodsVm) }
+ composable("hearing_aid") { HearingAidScreen(airPodsVm, navController) }
+ composable("hearing_aid_adjustments") { HearingAidAdjustmentsScreen(airPodsVm) }
+ composable("adaptive_strength") { AdaptiveStrengthScreen(airPodsVm, navController) }
+ composable("camera_control") { CameraControlScreen(airPodsVm) }
+ composable("open_source_licenses") { OpenSourceLicensesScreen(navController) }
+ composable("update_hearing_test") { UpdateHearingTestScreen(airPodsVm) }
+ composable("version_info") { VersionScreen(airPodsVm) }
+ composable("hearing_protection") { HearingProtectionScreen(airPodsVm, navController) }
+}
+
+private const val TAB_FADE_MS = 200
+private const val DETAIL_FADE_MS = 120
+
+/** True when navigating between two top-level tabs — those cross-fade instead of sliding. */
+private fun AnimatedContentTransitionScope.isTabSwitch(): Boolean =
+ initialState.destination.route.isTopLevelRoute() &&
+ targetState.destination.route.isTopLevelRoute()
+
+private fun String?.isTopLevelRoute(): Boolean =
+ topLevelDestinations.any { it.route == this }
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveControls.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveControls.kt
new file mode 100644
index 000000000..50edf4879
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveControls.kt
@@ -0,0 +1,158 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
+
+package me.kavishdevar.librepods.presentation.m3
+
+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.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material3.ButtonGroup
+import androidx.compose.material3.ButtonGroupDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.FilledIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
+import androidx.compose.material3.SwitchDefaults
+import androidx.compose.material3.Text
+import androidx.compose.material3.ToggleButton
+import androidx.compose.material3.ToggleButtonDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+
+/** M3 Expressive [Switch] — the thumb grows on press and shows a check when on. */
+@Composable
+fun ExpressiveSwitch(
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ enabled: Boolean = true,
+) {
+ Switch(
+ checked = checked,
+ onCheckedChange = onCheckedChange,
+ enabled = enabled,
+ thumbContent = if (checked) {
+ { Icon(Icons.Filled.Check, contentDescription = null, modifier = Modifier.size(SwitchDefaults.IconSize)) }
+ } else null,
+ )
+}
+
+/**
+ * An equal-width **connected button group** of M3 Expressive [ToggleButton]s — each segment takes an
+ * equal `weight`, the group uses [ButtonGroupDefaults] leading/middle/trailing shapes so it reads as
+ * one connected control, and the selected segment fills + morphs rounder. For a few fixed options
+ * (e.g. press-speed); use [ExpressiveSegmentedFilter] for many, scrollable options.
+ */
+@Composable
+fun ExpressiveSegmentedGroup(
+ options: List,
+ selected: T,
+ onSelect: (T) -> Unit,
+ label: (T) -> String,
+ modifier: Modifier = Modifier,
+ icon: ((T) -> androidx.compose.ui.graphics.vector.ImageVector?)? = null,
+) {
+ val haptics = rememberAppHaptics()
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
+ ) {
+ options.forEachIndexed { index, option ->
+ val isSelected = option == selected
+ ToggleButton(
+ checked = isSelected,
+ onCheckedChange = {
+ if (!isSelected) {
+ haptics.tick()
+ onSelect(option)
+ }
+ },
+ modifier = Modifier
+ .weight(1f)
+ .height(56.dp),
+ shapes = when {
+ options.size == 1 -> ButtonGroupDefaults.connectedMiddleButtonShapes()
+ index == 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes()
+ index == options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
+ else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
+ },
+ ) {
+ val vector = icon?.invoke(option)
+ if (vector != null) {
+ Icon(vector, contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(Modifier.width(ToggleButtonDefaults.IconSpacing))
+ }
+ Text(
+ text = label(option),
+ style = MaterialTheme.typography.labelLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+}
+
+/** A single action for [ExpressiveActionGroup]. */
+data class ExpressiveAction(
+ val label: String,
+ val onClick: () -> Unit,
+)
+
+/**
+ * Replaces stacks of standalone buttons with an M3 Expressive [ButtonGroup]: pressing a button
+ * expands it while neighbours compress, with overflow handled automatically.
+ */
+@Composable
+fun ExpressiveActionGroup(
+ actions: List,
+ modifier: Modifier = Modifier,
+) {
+ val haptics = rememberAppHaptics()
+ ButtonGroup(
+ // ≤3 actions never overflow, so the indicator is never shown; ignore the menu state.
+ overflowIndicator = {
+ FilledIconButton(onClick = {}) {
+ Icon(Icons.Filled.MoreVert, contentDescription = "More actions")
+ }
+ },
+ modifier = modifier,
+ ) {
+ actions.forEach { action ->
+ clickableItem(
+ onClick = {
+ haptics.confirm()
+ action.onClick()
+ },
+ label = action.label,
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveGroup.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveGroup.kt
new file mode 100644
index 000000000..6d1ef273e
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveGroup.kt
@@ -0,0 +1,77 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.presentation.m3
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+/**
+ * A small-caps-feeling section header that sits above an [ExpressiveGroup]. The whole design is
+ * container-first: titled groups of rows on tonal cards, the structural backbone of every screen.
+ */
+@Composable
+fun ExpressiveSectionHeader(
+ text: String,
+ modifier: Modifier = Modifier,
+) {
+ Text(
+ text = text.uppercase(),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = modifier.padding(start = 4.dp, end = 20.dp, top = 24.dp, bottom = 10.dp),
+ )
+}
+
+/**
+ * The container that defines the redesign: a large, soft, tonal card grouping a column of rows.
+ * Use [ExpressiveDivider] between rows.
+ */
+@Composable
+fun ExpressiveGroup(
+ modifier: Modifier = Modifier,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Surface(
+ shape = RoundedCornerShape(28.dp),
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ modifier = modifier.fillMaxWidth(),
+ ) {
+ Column(content = content)
+ }
+}
+
+/** Inset hairline divider between rows inside an [ExpressiveGroup]. */
+@Composable
+fun ExpressiveDivider(startInset: Boolean = true) {
+ HorizontalDivider(
+ thickness = 1.dp,
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
+ modifier = Modifier.padding(start = if (startInset) 68.dp else 20.dp, end = 20.dp),
+ )
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveInteractions.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveInteractions.kt
new file mode 100644
index 000000000..beac4f1eb
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveInteractions.kt
@@ -0,0 +1,51 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.presentation.m3
+
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.interaction.InteractionSource
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.graphicsLayer
+
+/**
+ * The signature M3 Expressive "squish": a clickable surface dips toward [pressedScale] while held
+ * and springs back with a low-damping spring. Drive it from the same
+ * [androidx.compose.foundation.interaction.MutableInteractionSource] the surface uses for clicks.
+ */
+@Composable
+fun Modifier.pressBounce(
+ interactionSource: InteractionSource,
+ pressedScale: Float = 0.95f,
+): Modifier {
+ val pressed by interactionSource.collectIsPressedAsState()
+ val scale by animateFloatAsState(
+ targetValue = if (pressed) pressedScale else 1f,
+ animationSpec = spring(dampingRatio = 0.42f, stiffness = Spring.StiffnessMedium),
+ label = "pressBounce",
+ )
+ return this.graphicsLayer {
+ scaleX = scale
+ scaleY = scale
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveListItem.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveListItem.kt
new file mode 100644
index 000000000..3dc56d8c1
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveListItem.kt
@@ -0,0 +1,230 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.presentation.m3
+
+import androidx.compose.foundation.interaction.MutableInteractionSource
+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.heightIn
+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.automirrored.filled.KeyboardArrowRight
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+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.graphics.vector.ImageVector
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+
+/** Leading icon in a soft tonal rounded square — the recurring visual motif of the redesign. */
+@Composable
+fun ExpressiveLeadingIcon(
+ icon: ImageVector,
+ containerColor: Color = MaterialTheme.colorScheme.primaryContainer,
+ contentColor: Color = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier: Modifier = Modifier,
+) {
+ Surface(
+ shape = RoundedCornerShape(14.dp),
+ color = containerColor,
+ contentColor = contentColor,
+ modifier = modifier.size(40.dp),
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(imageVector = icon, contentDescription = null, modifier = Modifier.size(22.dp))
+ }
+ }
+}
+
+/**
+ * A clickable row for an [ExpressiveGroup]: leading tonal icon, title + optional supporting text,
+ * an optional trailing value and/or chevron, with a springy press and haptic feedback.
+ */
+@Composable
+fun ExpressiveListItem(
+ title: String,
+ modifier: Modifier = Modifier,
+ subtitle: String? = null,
+ leadingIcon: ImageVector? = null,
+ iconContainerColor: Color = MaterialTheme.colorScheme.primaryContainer,
+ iconContentColor: Color = MaterialTheme.colorScheme.onPrimaryContainer,
+ value: String? = null,
+ showChevron: Boolean = false,
+ enabled: Boolean = true,
+ trailing: (@Composable () -> Unit)? = null,
+ onClick: (() -> Unit)? = null,
+) {
+ val haptics = rememberAppHaptics()
+ val interaction = remember { MutableInteractionSource() }
+
+ val rowContent: @Composable () -> Unit = {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 64.dp)
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (leadingIcon != null) {
+ ExpressiveLeadingIcon(
+ icon = leadingIcon,
+ containerColor = iconContainerColor,
+ contentColor = iconContentColor,
+ )
+ Spacer(Modifier.width(16.dp))
+ }
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ color = if (enabled) MaterialTheme.colorScheme.onSurface
+ else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
+ )
+ if (subtitle != null) {
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ if (value != null) {
+ Spacer(Modifier.width(12.dp))
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ if (trailing != null) {
+ Spacer(Modifier.width(12.dp))
+ trailing()
+ }
+ if (showChevron) {
+ Spacer(Modifier.width(4.dp))
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ }
+ }
+
+ if (onClick != null) {
+ Surface(
+ onClick = {
+ if (enabled) {
+ haptics.navigation()
+ onClick()
+ }
+ },
+ enabled = enabled,
+ interactionSource = interaction,
+ color = Color.Transparent,
+ modifier = modifier.pressBounce(interaction),
+ ) { rowContent() }
+ } else {
+ Surface(color = Color.Transparent, modifier = modifier) { rowContent() }
+ }
+}
+
+/**
+ * A labelled row hosting a trailing M3 [androidx.compose.material3.Switch]; tapping anywhere on the
+ * row flips it (with a toggle haptic).
+ */
+@Composable
+fun ExpressiveSwitchRow(
+ title: String,
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+ subtitle: String? = null,
+ leadingIcon: ImageVector? = null,
+ enabled: Boolean = true,
+) {
+ val haptics = rememberAppHaptics()
+ val interaction = remember { MutableInteractionSource() }
+
+ Surface(
+ onClick = {
+ if (enabled) {
+ haptics.toggle()
+ onCheckedChange(!checked)
+ }
+ },
+ enabled = enabled,
+ interactionSource = interaction,
+ color = Color.Transparent,
+ modifier = modifier.pressBounce(interaction),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 64.dp)
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ if (leadingIcon != null) {
+ ExpressiveLeadingIcon(icon = leadingIcon)
+ }
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ if (subtitle != null) {
+ Text(
+ text = subtitle,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ ExpressiveSwitch(
+ checked = checked,
+ onCheckedChange = {
+ if (enabled) {
+ haptics.toggle()
+ onCheckedChange(it)
+ }
+ },
+ enabled = enabled,
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveNoiseControl.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveNoiseControl.kt
new file mode 100644
index 000000000..65f0a0842
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveNoiseControl.kt
@@ -0,0 +1,342 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.presentation.m3
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateDpAsState
+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
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Block
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+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.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import me.kavishdevar.librepods.R
+import me.kavishdevar.librepods.presentation.m3.viz.AncVisualizer
+import me.kavishdevar.librepods.presentation.m3.viz.AncVizStyle
+
+/** Protocol listening-mode values (see `ControlCommandIdentifiers.LISTENING_MODE`). */
+private const val MODE_OFF = 1
+private const val MODE_NOISE_CANCELLATION = 2
+private const val MODE_TRANSPARENCY = 3
+private const val MODE_ADAPTIVE = 4
+
+private val TrackHeight = 58.dp
+private val CapsuleInset = 5.dp
+private val SegIconSize = 28.dp
+
+/**
+ * The noise-control switcher. The **active mode reads first** (a leading icon that swaps with a bouncy
+ * scale, the mode name, and a left-aligned description that reveals word-by-word) sitting *above* a
+ * refined **icon-only segmented control** whose tonal capsule springs to the active cell. Flat tonal,
+ * no gradients; motion reserved for interaction. Per-mode haptics mirror what the mode does. [modes]
+ * are the protocol listening-mode values in display order.
+ */
+@Composable
+fun ExpressiveNoiseControlSelector(
+ modes: List,
+ selected: Int,
+ onSelect: (Int) -> Unit,
+ modifier: Modifier = Modifier,
+ vizStyle: AncVizStyle = AncVizStyle.DEFAULT,
+) {
+ if (modes.isEmpty()) return
+ val haptics = rememberAppHaptics()
+ val cs = MaterialTheme.colorScheme
+
+ // Per-mode haptic that mirrors what the mode does (skipped on first composition / state hydration).
+ var hapticPrimed by remember { mutableStateOf(false) }
+ LaunchedEffect(selected) {
+ if (hapticPrimed) {
+ when (selected) {
+ MODE_NOISE_CANCELLATION -> haptics.confirm() // a solid "seal shut"
+ MODE_TRANSPARENCY -> haptics.toggle() // a light "open up"
+ MODE_ADAPTIVE -> haptics.celebration() // a little magic
+ else -> Unit // Off: stay quiet
+ }
+ } else {
+ hapticPrimed = true
+ }
+ }
+
+ Column(modifier = modifier.fillMaxWidth()) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(210.dp)
+ .clip(RoundedCornerShape(28.dp))
+ .background(cs.surfaceContainerLow),
+ ) {
+ AncVisualizer(
+ modeValue = selected,
+ style = vizStyle,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+
+ Spacer(Modifier.height(18.dp))
+
+ ActiveModeDetail(selected)
+
+ Spacer(Modifier.height(8.dp))
+
+ SegmentedControl(
+ modes = modes,
+ selected = selected,
+ onSelect = { mode ->
+ haptics.tick()
+ onSelect(mode)
+ },
+ )
+ }
+}
+
+/**
+ * The active-mode header: a leading icon tile (icon swaps with a bouncy scale), the mode name (a calm
+ * crossfade), and the description spanning the full width flush-left, revealed word-by-word.
+ */
+@Composable
+private fun ActiveModeDetail(selected: Int) {
+ val cs = MaterialTheme.colorScheme
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ modifier = Modifier
+ .size(44.dp)
+ .clip(RoundedCornerShape(14.dp))
+ .background(cs.primaryContainer),
+ contentAlignment = Alignment.Center,
+ ) {
+ AnimatedContent(
+ targetState = selected,
+ transitionSpec = {
+ (scaleIn(initialScale = 0.5f, animationSpec = spring(Spring.DampingRatioMediumBouncy, Spring.StiffnessLow)) + fadeIn(tween(200))) togetherWith
+ (scaleOut(targetScale = 0.5f, animationSpec = tween(150)) + fadeOut(tween(150)))
+ },
+ label = "activeIcon",
+ ) { mode ->
+ ModeGlyph(mode = mode, tint = cs.onPrimaryContainer, iconSize = 24.dp)
+ }
+ }
+ Spacer(Modifier.width(14.dp))
+ AnimatedContent(
+ targetState = selected,
+ transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(120)) },
+ label = "activeName",
+ ) { mode ->
+ Text(
+ text = modeLabel(mode),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.SemiBold,
+ color = cs.onSurface,
+ )
+ }
+ }
+
+ Spacer(Modifier.height(12.dp))
+
+ TypographyCaption(text = captionFor(selected))
+ }
+}
+
+/** A word-by-word fade-and-rise reveal — restarts whenever the active mode (and thus the text) changes. */
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun TypographyCaption(text: String) {
+ val cs = MaterialTheme.colorScheme
+ val words = remember(text) { text.split(" ") }
+ val progress = remember(text) { Animatable(0f) }
+ LaunchedEffect(text) {
+ progress.animateTo(1f, tween(durationMillis = 220 + words.size * 55, easing = FastOutSlowInEasing))
+ }
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(5.dp),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ val n = words.size.toFloat()
+ words.forEachIndexed { i, word ->
+ val t = ((progress.value * n) - i).coerceIn(0f, 1f)
+ Text(
+ text = word,
+ style = MaterialTheme.typography.bodyMedium,
+ color = cs.onSurfaceVariant.copy(alpha = t),
+ modifier = Modifier.graphicsLayer {
+ translationY = (1f - t) * 10.dp.toPx()
+ },
+ )
+ }
+ }
+}
+
+/** Icon-only segmented control with a single spring-sliding capsule marking the active mode. */
+@Composable
+private fun SegmentedControl(
+ modes: List,
+ selected: Int,
+ onSelect: (Int) -> Unit,
+) {
+ val cs = MaterialTheme.colorScheme
+ val density = LocalDensity.current
+ val selectedIndex = modes.indexOf(selected).coerceAtLeast(0)
+
+ var rowWidthPx by remember { mutableFloatStateOf(0f) }
+
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(TrackHeight)
+ .clip(CircleShape)
+ .background(cs.surfaceContainerHighest)
+ .onSizeChanged { rowWidthPx = it.width.toFloat() },
+ contentAlignment = Alignment.CenterStart,
+ ) {
+ if (rowWidthPx > 0f) {
+ val slotPx = rowWidthPx / modes.size
+ val insetPx = with(density) { CapsuleInset.toPx() }
+ val capStartPx = selectedIndex * slotPx + insetPx
+ val capWidth = with(density) { (slotPx - insetPx * 2f).toDp() }
+ val xOffset by animateDpAsState(
+ targetValue = with(density) { capStartPx.toDp() },
+ animationSpec = spring(dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessLow),
+ label = "capsuleX",
+ )
+ Box(
+ modifier = Modifier
+ .offset(x = xOffset)
+ .height(TrackHeight - CapsuleInset * 2)
+ .width(capWidth)
+ .clip(CircleShape)
+ .background(cs.primary),
+ )
+ }
+
+ Row(modifier = Modifier.fillMaxSize()) {
+ modes.forEach { mode ->
+ val isSelected = mode == selected
+ val tint by animateColorAsState(
+ targetValue = if (isSelected) cs.onPrimary else cs.onSurfaceVariant,
+ animationSpec = tween(220),
+ label = "cellTint",
+ )
+ val scale by animateFloatAsState(
+ targetValue = if (isSelected) 1.08f else 1f,
+ animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow),
+ label = "glyphScale",
+ )
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ .clip(CircleShape)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ ) { if (mode != selected) onSelect(mode) },
+ contentAlignment = Alignment.Center,
+ ) {
+ ModeGlyph(
+ mode = mode,
+ tint = tint,
+ iconSize = SegIconSize,
+ modifier = Modifier.graphicsLayer {
+ scaleX = scale
+ scaleY = scale
+ },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ModeGlyph(mode: Int, tint: Color, iconSize: Dp, modifier: Modifier = Modifier) {
+ val m = modifier.size(iconSize)
+ when (mode) {
+ MODE_TRANSPARENCY -> Icon(painterResource(R.drawable.transparency), modeLabel(mode), m, tint)
+ MODE_ADAPTIVE -> Icon(painterResource(R.drawable.adaptive), modeLabel(mode), m, tint)
+ MODE_NOISE_CANCELLATION -> Icon(painterResource(R.drawable.noise_cancellation), modeLabel(mode), m, tint)
+ else -> Icon(Icons.Filled.Block, modeLabel(mode), m, tint) // Off
+ }
+}
+
+private fun modeLabel(mode: Int): String = when (mode) {
+ MODE_OFF -> "Off"
+ MODE_NOISE_CANCELLATION -> "Noise Cancellation"
+ MODE_TRANSPARENCY -> "Transparency"
+ MODE_ADAPTIVE -> "Adaptive"
+ else -> ""
+}
+
+/** The one live line — what the active mode actually does. */
+private fun captionFor(mode: Int): String = when (mode) {
+ MODE_OFF -> "Noise control is off."
+ MODE_NOISE_CANCELLATION -> "Blocks outside noise so you hear only your audio."
+ MODE_TRANSPARENCY -> "Lets the world in while you listen."
+ MODE_ADAPTIVE -> "Automatically tunes noise control to your surroundings."
+ else -> ""
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveProgress.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveProgress.kt
new file mode 100644
index 000000000..8412105f3
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveProgress.kt
@@ -0,0 +1,147 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
+
+package me.kavishdevar.librepods.presentation.m3
+
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.background
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Bolt
+import androidx.compose.material3.CircularWavyProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LinearWavyProgressIndicator
+import androidx.compose.material3.LoadingIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+/** Battery accent colour — green when healthy, error when low. */
+@Composable
+fun batteryColor(level: Int): Color {
+ val healthy = if (isSystemInDarkTheme()) Color(0xFF2ED158) else Color(0xFF1FA94B)
+ return if (level > 25) healthy else MaterialTheme.colorScheme.error
+}
+
+/**
+ * Battery ring — an M3 Expressive [CircularWavyProgressIndicator] with a medium, thick squiggle on by
+ * default: the AirPods component silhouette ([iconRes]) tinted in the centre, a charging-bolt badge
+ * when powered, and the numeric level rendered by the caller beneath so it stays visible while charging.
+ */
+@Composable
+fun WavyBatteryRing(
+ level: Int,
+ charging: Boolean,
+ iconRes: Int,
+ modifier: Modifier = Modifier,
+ diameter: Dp = 86.dp,
+) {
+ val animatedProgress by animateFloatAsState(
+ targetValue = (level / 100f).coerceIn(0f, 1f),
+ animationSpec = spring(dampingRatio = Spring.DampingRatioLowBouncy, stiffness = Spring.StiffnessLow),
+ label = "batteryProgress",
+ )
+
+ val color = batteryColor(level)
+ val density = LocalDensity.current
+ val ringStroke = remember(density) {
+ Stroke(width = with(density) { 6.dp.toPx() }, cap = StrokeCap.Round)
+ }
+
+ Box(
+ modifier = modifier.size(diameter),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularWavyProgressIndicator(
+ progress = { animatedProgress },
+ modifier = Modifier.size(diameter),
+ color = color,
+ trackColor = MaterialTheme.colorScheme.surfaceContainerHighest,
+ stroke = ringStroke,
+ trackStroke = ringStroke,
+ amplitude = { 0.4f },
+ wavelength = 34.dp,
+ )
+ Icon(
+ painter = painterResource(iconRes),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.size(diameter * 0.38f),
+ )
+ if (charging) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .size(24.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primaryContainer),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Bolt,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.size(15.dp),
+ )
+ }
+ }
+ }
+}
+
+/** A squiggly horizontal level meter (e.g. EQ band / strength visualisation). */
+@Composable
+fun WavyLevel(
+ fraction: Float,
+ modifier: Modifier = Modifier,
+ color: Color = MaterialTheme.colorScheme.primary,
+) {
+ LinearWavyProgressIndicator(
+ progress = { fraction.coerceIn(0f, 1f) },
+ modifier = modifier,
+ color = color,
+ trackColor = MaterialTheme.colorScheme.surfaceContainerHighest,
+ )
+}
+
+/** A shape-morphing loading indicator (for connecting / busy states). */
+@Composable
+fun ExpressiveLoading(
+ modifier: Modifier = Modifier,
+ color: Color = MaterialTheme.colorScheme.primary,
+) {
+ LoadingIndicator(modifier = modifier, color = color)
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveReconnectHero.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveReconnectHero.kt
new file mode 100644
index 000000000..2e9c68502
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveReconnectHero.kt
@@ -0,0 +1,231 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
+
+package me.kavishdevar.librepods.presentation.m3
+
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.FastOutSlowInEasing
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.spring
+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.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.BluetoothSearching
+import androidx.compose.material.icons.rounded.HeadsetOff
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialShapes
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.ripple
+import androidx.compose.material3.toPath
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+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.drawWithContent
+import androidx.compose.ui.geometry.center
+import androidx.compose.ui.graphics.Matrix
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.graphics.shapes.Morph
+import kotlinx.coroutines.delay
+import kotlin.math.min
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * "AirPods not connected" hero: a large tonal disc whose inner shape morphs through
+ * [MaterialShapes] polygons, adapted from upstream LibrePods' M3 Expressive reconnect screen.
+ * The disc itself is the reconnect button — the shape squishes with a bouncy spring while
+ * pressed, and while a reconnect attempt is in flight it morphs continuously shape-to-shape,
+ * swaps to a searching icon, and animates from secondary to tertiary container colour. Flat
+ * tonal colours only — shape + motion carry the expression.
+ *
+ * The in-flight state auto-resets after five seconds so a failed attempt can be retried; on
+ * success the caller's screen leaves the not-connected state entirely, unmounting this.
+ *
+ * @param canReconnect whether a previously-connected device is known — gates the tap-to-reconnect
+ * affordance; when false the hero is a passive status display.
+ */
+@Composable
+fun ExpressiveReconnectHero(
+ canReconnect: Boolean,
+ onReconnect: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val haptics = rememberAppHaptics()
+ var reconnecting by remember { mutableStateOf(false) }
+
+ LaunchedEffect(reconnecting) {
+ if (reconnecting) {
+ delay(5.seconds)
+ reconnecting = false
+ }
+ }
+
+ val morphs = remember {
+ val polygons = listOf(
+ MaterialShapes.Cookie9Sided,
+ MaterialShapes.Clover4Leaf,
+ MaterialShapes.SoftBurst,
+ MaterialShapes.Sunny,
+ MaterialShapes.Pentagon,
+ MaterialShapes.Cookie4Sided,
+ MaterialShapes.Oval,
+ ).map { it.normalized() }
+ polygons.indices.map { i -> Morph(polygons[i], polygons[(i + 1) % polygons.size]) }
+ }
+ var morphIndex by remember { mutableIntStateOf(0) }
+ val morphProgress = remember { Animatable(0f) }
+
+ LaunchedEffect(reconnecting) {
+ if (!reconnecting) {
+ morphIndex = 0
+ morphProgress.snapTo(0f)
+ return@LaunchedEffect
+ }
+ while (true) {
+ morphProgress.snapTo(0f)
+ morphProgress.animateTo(
+ targetValue = 1f,
+ animationSpec = tween(durationMillis = 650, easing = FastOutSlowInEasing),
+ )
+ morphIndex = (morphIndex + 1) % morphs.size
+ }
+ }
+
+ val shapeColor by animateColorAsState(
+ targetValue = if (reconnecting) {
+ MaterialTheme.colorScheme.tertiaryContainer
+ } else {
+ MaterialTheme.colorScheme.secondaryContainer
+ },
+ label = "reconnectShapeColor",
+ )
+ val pressSpring = remember {
+ spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow)
+ }
+ val path = remember { Path() }
+ val scaleMatrix = remember { Matrix() }
+
+ Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
+ Box(
+ modifier = Modifier
+ .size(240.dp)
+ .background(MaterialTheme.colorScheme.surfaceContainerHigh, CircleShape)
+ .clickable(
+ interactionSource = null,
+ indication = ripple(bounded = false, radius = 120.dp),
+ enabled = canReconnect && !reconnecting,
+ onClick = {},
+ )
+ .pointerInput(canReconnect) {
+ if (!canReconnect) return@pointerInput
+ detectTapGestures(
+ onTap = {
+ if (!reconnecting) {
+ haptics.confirm()
+ // The press already morphed toward shape 1, so start the
+ // searching loop from there for a seamless hand-off.
+ morphIndex = 1
+ reconnecting = true
+ onReconnect()
+ }
+ },
+ onPress = {
+ if (!reconnecting) {
+ morphProgress.animateTo(1f, pressSpring)
+ tryAwaitRelease()
+ if (!reconnecting) {
+ morphProgress.animateTo(0f, pressSpring)
+ }
+ }
+ },
+ )
+ }
+ .drawWithContent {
+ val shapePath = morphs[morphIndex].toPath(
+ progress = morphProgress.value,
+ path = path,
+ )
+ val bounds = shapePath.getBounds()
+ val scale = min(size.width / bounds.width, size.height / bounds.height) * 0.8f
+ scaleMatrix.reset()
+ scaleMatrix.scale(x = scale, y = scale)
+ shapePath.transform(scaleMatrix)
+ shapePath.translate(size.center - shapePath.getBounds().center)
+ drawPath(path = shapePath, color = shapeColor)
+ drawContent()
+ },
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = if (reconnecting) {
+ Icons.AutoMirrored.Rounded.BluetoothSearching
+ } else {
+ Icons.Rounded.HeadsetOff
+ },
+ contentDescription = null,
+ modifier = Modifier.size(84.dp),
+ tint = if (reconnecting) {
+ MaterialTheme.colorScheme.onTertiaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSecondaryContainer
+ },
+ )
+ }
+
+ Spacer(Modifier.height(32.dp))
+
+ Text(
+ text = if (reconnecting) "Reconnecting…" else "AirPods not connected",
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = when {
+ reconnecting -> "Connecting to your last device…"
+ canReconnect -> "Pop in your AirPods, or tap the shape to reconnect."
+ else -> "Pop in your AirPods and they'll connect automatically."
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveScaffold.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveScaffold.kt
new file mode 100644
index 000000000..2abac5850
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveScaffold.kt
@@ -0,0 +1,115 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.presentation.m3
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.only
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawing
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowBack
+import androidx.compose.material3.FilledTonalIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+
+/**
+ * Per-screen scaffold for the new shell, modelled on Caffeine's page header: a fixed, **top-left
+ * aligned headline** sitting flush at the top of the content, with an optional tonal back button and
+ * trailing [actions]. Deliberately *not* a collapsing [androidx.compose.material3.LargeTopAppBar] —
+ * that pushed the title into the vertical middle of a tall bar and read as misaligned. The shell owns
+ * the bottom nav, so this only lays out the header + scrolling content beneath it.
+ *
+ * The [content] receives a zero [PaddingValues]: the header is laid out above the content (not as an
+ * inset), so callers can keep their existing `top = padding.calculateTopPadding()` usage as a no-op.
+ */
+@Composable
+fun ExpressiveScreenScaffold(
+ title: String,
+ modifier: Modifier = Modifier,
+ onBack: (() -> Unit)? = null,
+ large: Boolean = onBack == null,
+ actions: @Composable RowScope.() -> Unit = {},
+ content: @Composable (PaddingValues) -> Unit,
+) {
+ val haptics = rememberAppHaptics()
+
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (onBack != null) {
+ FilledTonalIconButton(
+ onClick = {
+ haptics.navigation()
+ onBack()
+ },
+ ) {
+ Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
+ }
+ Spacer(Modifier.width(8.dp))
+ }
+ Text(
+ text = title,
+ style = if (large) {
+ MaterialTheme.typography.headlineMedium
+ } else {
+ MaterialTheme.typography.headlineSmall
+ },
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ content = actions,
+ )
+ }
+
+ Box(modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()) {
+ content(PaddingValues(0.dp))
+ }
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveSearch.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveSearch.kt
new file mode 100644
index 000000000..c6e87e86c
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveSearch.kt
@@ -0,0 +1,185 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
+
+package me.kavishdevar.librepods.presentation.m3
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+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.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.ButtonGroupDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.ToggleButton
+import androidx.compose.material3.ToggleButtonDefaults
+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.SolidColor
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+
+/**
+ * A pill-shaped M3 search field (tonal container, leading search icon, clear button). Paired with
+ * [ExpressiveSegmentedFilter] this gives the "search + category filter" pattern.
+ */
+@Composable
+fun ExpressiveSearchField(
+ query: String,
+ onQueryChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ placeholder: String = "Search",
+) {
+ Surface(
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ modifier = modifier.fillMaxWidth(),
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 18.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Icon(
+ imageVector = Icons.Filled.Search,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(22.dp),
+ )
+ Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
+ if (query.isEmpty()) {
+ Text(
+ text = placeholder,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ BasicTextField(
+ value = query,
+ onValueChange = onQueryChange,
+ singleLine = true,
+ textStyle = MaterialTheme.typography.bodyLarge.copy(
+ color = MaterialTheme.colorScheme.onSurface,
+ ),
+ cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ if (query.isNotEmpty()) {
+ val haptics = rememberAppHaptics()
+ Icon(
+ imageVector = Icons.Filled.Close,
+ contentDescription = "Clear",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier
+ .size(22.dp)
+ .clickableNoRipple {
+ haptics.tick()
+ onQueryChange("")
+ },
+ )
+ }
+ }
+ }
+}
+
+/**
+ * A horizontally-scrolling **connected button group** (M3 Expressive [ToggleButton]s with
+ * [ButtonGroupDefaults] leading/middle/trailing shapes) for category filters — an icon + label per
+ * segment, lifted from Caffeine's add-consumption category filter. The selected segment fills with the
+ * checked color and morphs to a rounder shape; overflow scrolls horizontally.
+ */
+@Composable
+fun ExpressiveSegmentedFilter(
+ options: List,
+ selected: T,
+ onSelect: (T) -> Unit,
+ label: (T) -> String,
+ icon: (T) -> ImageVector,
+ modifier: Modifier = Modifier,
+) {
+ val haptics = rememberAppHaptics()
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .horizontalScroll(rememberScrollState()),
+ horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
+ ) {
+ options.forEachIndexed { index, option ->
+ val isSelected = option == selected
+ ToggleButton(
+ checked = isSelected,
+ onCheckedChange = {
+ if (!isSelected) {
+ haptics.tick()
+ onSelect(option)
+ }
+ },
+ shapes = when {
+ options.size == 1 -> ButtonGroupDefaults.connectedMiddleButtonShapes()
+ index == 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes()
+ index == options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
+ else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
+ },
+ ) {
+ Icon(
+ imageVector = icon(option),
+ contentDescription = null,
+ modifier = Modifier.size(18.dp),
+ )
+ Spacer(Modifier.size(ToggleButtonDefaults.IconSpacing))
+ Text(
+ text = label(option),
+ style = MaterialTheme.typography.labelLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun Modifier.clickableNoRipple(onClick: () -> Unit): Modifier {
+ val interaction = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }
+ return this.clickable(
+ interactionSource = interaction,
+ indication = null,
+ onClick = onClick,
+ )
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveSlider.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveSlider.kt
new file mode 100644
index 000000000..56bbef829
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/ExpressiveSlider.kt
@@ -0,0 +1,85 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.presentation.m3
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Slider
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.dp
+
+/**
+ * M3 Expressive [Slider] (thick track + large springy thumb) flanked by optional icons, with a tick
+ * haptic when the user releases.
+ */
+@Composable
+fun ExpressiveSlider(
+ value: Float,
+ onValueChange: (Float) -> Unit,
+ valueRange: ClosedFloatingPointRange,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ steps: Int = 0,
+ startIcon: ImageVector? = null,
+ endIcon: ImageVector? = null,
+ onValueChangeFinished: (() -> Unit)? = null,
+) {
+ val haptics = rememberAppHaptics()
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ if (startIcon != null) {
+ Icon(
+ imageVector = startIcon,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(22.dp),
+ )
+ }
+ Slider(
+ value = value,
+ onValueChange = onValueChange,
+ valueRange = valueRange,
+ steps = steps,
+ enabled = enabled,
+ modifier = Modifier.weight(1f),
+ onValueChangeFinished = {
+ haptics.tick()
+ onValueChangeFinished?.invoke()
+ },
+ )
+ if (endIcon != null) {
+ Icon(
+ imageVector = endIcon,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(22.dp),
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/screens/ControlsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/screens/ControlsScreen.kt
new file mode 100644
index 000000000..dd49343da
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/screens/ControlsScreen.kt
@@ -0,0 +1,307 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+@file:OptIn(ExperimentalMaterial3Api::class)
+
+package me.kavishdevar.librepods.presentation.m3.screens
+
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+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.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Accessibility
+import androidx.compose.material.icons.filled.Apps
+import androidx.compose.material.icons.filled.Block
+import androidx.compose.material.icons.filled.GraphicEq
+import androidx.compose.material.icons.filled.Hearing
+import androidx.compose.material.icons.filled.HearingDisabled
+import androidx.compose.material.icons.filled.Mic
+import androidx.compose.material.icons.filled.RecordVoiceOver
+import androidx.compose.material.icons.filled.Sensors
+import androidx.compose.material.icons.filled.TouchApp
+import androidx.compose.material.icons.filled.Tune
+import androidx.compose.material.icons.filled.VolumeUp
+import androidx.compose.material.icons.rounded.SentimentDissatisfied
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+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.vector.ImageVector
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavController
+import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
+import me.kavishdevar.librepods.bluetooth.ATTHandles
+import me.kavishdevar.librepods.data.Capability
+import me.kavishdevar.librepods.presentation.m3.ExpressiveDivider
+import me.kavishdevar.librepods.presentation.m3.ExpressiveGroup
+import me.kavishdevar.librepods.presentation.m3.ExpressiveListItem
+import me.kavishdevar.librepods.presentation.m3.ExpressiveScreenScaffold
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSearchField
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSectionHeader
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSegmentedFilter
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSwitchRow
+import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
+
+private enum class ControlCategory(val label: String, val icon: ImageVector) {
+ ALL("All", Icons.Filled.Apps),
+ AUDIO("Audio", Icons.Filled.GraphicEq),
+ GESTURES("Gestures", Icons.Filled.TouchApp),
+ HEARING("Hearing", Icons.Filled.Hearing),
+ CONNECTION("Connection", Icons.Filled.Sensors),
+}
+
+private sealed interface ControlKind {
+ data class Toggle(val checked: Boolean, val onChange: (Boolean) -> Unit) : ControlKind
+ data class Nav(val route: String) : ControlKind
+}
+
+private data class ControlEntry(
+ val title: String,
+ val subtitle: String?,
+ val icon: ImageVector,
+ val category: ControlCategory,
+ val kind: ControlKind,
+)
+
+@Composable
+fun ControlsScreen(viewModel: AirPodsViewModel, navController: NavController) {
+ val state by viewModel.uiState.collectAsState()
+ var query by remember { mutableStateOf("") }
+ var category by remember { mutableStateOf(ControlCategory.ALL) }
+
+ val caps = state.capabilities
+
+ fun controlBool(id: ControlCommandIdentifiers): Boolean =
+ state.controlStates[id]?.getOrNull(0) == 0x01.toByte()
+
+ val entries = buildList {
+ if (caps.contains(Capability.LOUD_SOUND_REDUCTION)) {
+ add(
+ ControlEntry(
+ "Off listening mode", "Include an Off option in noise control", Icons.Filled.Block,
+ ControlCategory.AUDIO,
+ ControlKind.Toggle(controlBool(ControlCommandIdentifiers.ALLOW_OFF_OPTION)) {
+ viewModel.setOffListeningMode(it)
+ },
+ )
+ )
+ }
+ if (caps.contains(Capability.ADAPTIVE_VOLUME)) {
+ add(
+ ControlEntry(
+ "Adaptive volume", "Auto-adjust volume to your surroundings", Icons.Filled.VolumeUp,
+ ControlCategory.AUDIO,
+ ControlKind.Toggle(controlBool(ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG)) {
+ viewModel.setControlCommandBoolean(ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG, it)
+ },
+ )
+ )
+ }
+ if (caps.contains(Capability.CONVERSATION_AWARENESS)) {
+ add(
+ ControlEntry(
+ "Conversational awareness", "Lower volume when you start speaking", Icons.Filled.RecordVoiceOver,
+ ControlCategory.AUDIO,
+ ControlKind.Toggle(controlBool(ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG)) {
+ viewModel.setControlCommandBoolean(ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG, it)
+ },
+ )
+ )
+ }
+ add(ControlEntry("Transparency tuning", "Customize ambient sound", Icons.Filled.Tune, ControlCategory.AUDIO, ControlKind.Nav("transparency_customization")))
+ add(ControlEntry("Adaptive audio strength", "How aggressively noise is managed", Icons.Filled.GraphicEq, ControlCategory.AUDIO, ControlKind.Nav("adaptive_strength")))
+ add(ControlEntry("Microphone", "Choose the active microphone", Icons.Filled.Mic, ControlCategory.AUDIO, ControlKind.Nav("accessibility")))
+
+ if (caps.contains(Capability.HEAD_GESTURES)) {
+ add(ControlEntry("Head gestures", "Nod or shake to answer calls", Icons.Filled.Sensors, ControlCategory.GESTURES, ControlKind.Nav("head_tracking")))
+ }
+ add(ControlEntry("Press & hold", "Assign stem press-and-hold actions", Icons.Filled.TouchApp, ControlCategory.GESTURES, ControlKind.Nav("accessibility")))
+
+ if (caps.contains(Capability.HEARING_AID)) {
+ add(ControlEntry("Hearing aid", "Use your AirPods as a hearing aid", Icons.Filled.Hearing, ControlCategory.HEARING, ControlKind.Nav("hearing_aid")))
+ }
+ if (caps.contains(Capability.PPE)) {
+ add(ControlEntry("Hearing protection", "Reduce loud environmental noise", Icons.Filled.HearingDisabled, ControlCategory.HEARING, ControlKind.Nav("hearing_protection")))
+ }
+ if (caps.contains(Capability.LOUD_SOUND_REDUCTION)) {
+ add(
+ ControlEntry(
+ "Loud sound reduction", "Soften sudden loud noises", Icons.Filled.HearingDisabled,
+ ControlCategory.HEARING,
+ ControlKind.Toggle(state.loudSoundReductionEnabled) {
+ viewModel.setATTCharacteristicValue(
+ ATTHandles.LOUD_SOUND_REDUCTION,
+ byteArrayOf(if (it) 0x01 else 0x00),
+ )
+ },
+ )
+ )
+ }
+
+ add(
+ ControlEntry(
+ "Automatic ear detection", "Pause audio when removed", Icons.Filled.Hearing,
+ ControlCategory.CONNECTION,
+ ControlKind.Toggle(state.automaticEarDetectionEnabled) { viewModel.setAutomaticEarDetectionEnabled(it) },
+ )
+ )
+ add(
+ ControlEntry(
+ "Automatic connection", "Connect automatically when in range", Icons.Filled.Sensors,
+ ControlCategory.CONNECTION,
+ ControlKind.Toggle(state.automaticConnectionEnabled) { viewModel.setAutomaticConnectionEnabled(it) },
+ )
+ )
+ add(ControlEntry("Accessibility", "Tone, balance and more", Icons.Filled.Accessibility, ControlCategory.CONNECTION, ControlKind.Nav("accessibility")))
+ }
+
+ val filtered = entries.filter {
+ (category == ControlCategory.ALL || it.category == category) &&
+ (query.isBlank() || it.title.contains(query, ignoreCase = true))
+ }
+ val grouped = filtered.groupBy { it.category }
+
+ ExpressiveScreenScaffold(title = "Controls") { padding ->
+ // No live socket means nothing here can actually reach the AirPods, so hide the controls
+ // entirely rather than letting the user toggle things into the void.
+ if (!state.isLocallyConnected) {
+ ControlsNotConnected(padding)
+ return@ExpressiveScreenScaffold
+ }
+
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(
+ top = padding.calculateTopPadding(),
+ bottom = 140.dp,
+ start = 16.dp,
+ end = 16.dp,
+ ),
+ ) {
+ item(key = "search") {
+ ExpressiveSearchField(query = query, onQueryChange = { query = it }, placeholder = "Search controls")
+ Spacer(Modifier.height(12.dp))
+ ExpressiveSegmentedFilter(
+ options = ControlCategory.entries.toList(),
+ selected = category,
+ onSelect = { category = it },
+ label = { it.label },
+ icon = { it.icon },
+ )
+ Spacer(Modifier.height(4.dp))
+ }
+
+ if (grouped.isEmpty()) {
+ item(key = "empty") {
+ Column(Modifier.animateItem()) {
+ Spacer(Modifier.height(48.dp))
+ Text(
+ text = "No controls match \"$query\"",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+ }
+
+ grouped.forEach { (cat, items) ->
+ item(key = cat.name) {
+ // animateItem(): groups slide/fade as filtering changes which categories appear;
+ // animateContentSize(): the card resizes smoothly as rows are added/removed by search.
+ Column(Modifier.animateItem()) {
+ ExpressiveSectionHeader(cat.label)
+ ExpressiveGroup(modifier = Modifier.animateContentSize()) {
+ items.forEachIndexed { index, entry ->
+ if (index > 0) ExpressiveDivider()
+ when (val kind = entry.kind) {
+ is ControlKind.Toggle -> ExpressiveSwitchRow(
+ title = entry.title,
+ subtitle = entry.subtitle,
+ leadingIcon = entry.icon,
+ checked = kind.checked,
+ onCheckedChange = kind.onChange,
+ )
+ is ControlKind.Nav -> ExpressiveListItem(
+ title = entry.title,
+ subtitle = entry.subtitle,
+ leadingIcon = entry.icon,
+ showChevron = true,
+ onClick = { navController.navigate(kind.route) },
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+/** Sad-face empty state shown on the Controls tab while the AirPods aren't connected. */
+@Composable
+private fun ControlsNotConnected(padding: PaddingValues) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(horizontal = 32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.SentimentDissatisfied,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(96.dp),
+ )
+ Spacer(Modifier.height(24.dp))
+ Text(
+ text = "AirPods not connected",
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ )
+ Spacer(Modifier.height(8.dp))
+ Text(
+ text = "Connect your AirPods to adjust their controls.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/screens/HomeScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/screens/HomeScreen.kt
new file mode 100644
index 000000000..ec75173fb
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/screens/HomeScreen.kt
@@ -0,0 +1,262 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+@file:OptIn(ExperimentalMaterial3Api::class)
+
+package me.kavishdevar.librepods.presentation.m3.screens
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+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.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.BatteryChargingFull
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.Hearing
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavController
+import me.kavishdevar.librepods.R
+import me.kavishdevar.librepods.bluetooth.AACPManager
+import me.kavishdevar.librepods.data.Battery
+import me.kavishdevar.librepods.data.BatteryComponent
+import me.kavishdevar.librepods.data.BatteryStatus
+import me.kavishdevar.librepods.data.Capability
+import me.kavishdevar.librepods.presentation.m3.ExpressiveGroup
+import me.kavishdevar.librepods.presentation.m3.ExpressiveDivider
+import me.kavishdevar.librepods.presentation.m3.ExpressiveListItem
+import me.kavishdevar.librepods.presentation.m3.ExpressiveNoiseControlSelector
+import me.kavishdevar.librepods.presentation.m3.ExpressiveReconnectHero
+import me.kavishdevar.librepods.presentation.m3.ExpressiveScreenScaffold
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSectionHeader
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSwitchRow
+import me.kavishdevar.librepods.presentation.m3.WavyBatteryRing
+import me.kavishdevar.librepods.presentation.m3.viz.AncVizPrefs
+import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
+
+@Composable
+fun HomeScreen(viewModel: AirPodsViewModel, navController: NavController) {
+ val state by viewModel.uiState.collectAsState()
+
+ ExpressiveScreenScaffold(title = state.deviceName) { padding ->
+ if (!state.isLocallyConnected) {
+ NotConnected(padding, viewModel, navController)
+ return@ExpressiveScreenScaffold
+ }
+
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(
+ top = padding.calculateTopPadding(),
+ bottom = 140.dp,
+ start = 16.dp,
+ end = 16.dp,
+ ),
+ ) {
+ // Battery hero — squiggly wavy rings
+ item {
+ ExpressiveSectionHeader("Battery")
+ BatteryHero(state.battery)
+ }
+
+ // Noise control — expressive segmented selector
+ if (state.capabilities.contains(Capability.LISTENING_MODE)) {
+ item {
+ ExpressiveSectionHeader("Noise control")
+ NoiseControlCard(state.offListeningMode, currentAncValue(state), onSelect = {
+ viewModel.setControlCommandInt(
+ AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE, it,
+ )
+ })
+ }
+ }
+
+ // Quick toggles
+ item {
+ ExpressiveSectionHeader("Quick settings")
+ ExpressiveGroup {
+ ExpressiveListItem(
+ title = "Name",
+ value = state.deviceName,
+ leadingIcon = Icons.Filled.Edit,
+ showChevron = true,
+ onClick = { navController.navigate("rename") },
+ )
+ ExpressiveDivider()
+ ExpressiveSwitchRow(
+ title = "Automatic ear detection",
+ leadingIcon = Icons.Filled.Hearing,
+ checked = state.automaticEarDetectionEnabled,
+ onCheckedChange = viewModel::setAutomaticEarDetectionEnabled,
+ )
+ ExpressiveDivider()
+ ExpressiveSwitchRow(
+ title = "Optimized charging",
+ subtitle = "Delay charging past 80% to extend battery life",
+ leadingIcon = Icons.Filled.BatteryChargingFull,
+ checked = state.dynamicEndOfCharge,
+ onCheckedChange = viewModel::setDynamicEndOfCharge,
+ )
+ }
+ }
+
+ item {
+ Spacer(Modifier.height(24.dp))
+ Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
+ TextButton(onClick = viewModel::disconnect) {
+ Text(
+ text = "Disconnect",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun currentAncValue(state: me.kavishdevar.librepods.presentation.viewmodel.AirPodsUiState): Int =
+ state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE]
+ ?.getOrNull(0)?.toInt() ?: 3
+
+@Composable
+private fun BatteryHero(battery: List) {
+ val present = battery.filter { it.status != BatteryStatus.DISCONNECTED && it.level > 0 }
+ ExpressiveGroup {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 20.dp),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ ) {
+ if (present.isEmpty()) {
+ Text(
+ text = "No battery data",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(16.dp),
+ )
+ }
+ present.forEach { component ->
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ WavyBatteryRing(
+ level = component.level,
+ charging = component.status == BatteryStatus.CHARGING ||
+ component.status == BatteryStatus.OPTIMIZED_CHARGING,
+ iconRes = batteryIconRes(component.component),
+ )
+ Spacer(Modifier.height(10.dp))
+ Text(
+ text = batteryLabel(component.component),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = "${component.level}%",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+ }
+ }
+}
+
+private fun batteryLabel(component: Int): String = when (component) {
+ BatteryComponent.LEFT -> "Left"
+ BatteryComponent.RIGHT -> "Right"
+ BatteryComponent.CASE -> "Case"
+ else -> ""
+}
+
+private fun batteryIconRes(component: Int): Int = when (component) {
+ BatteryComponent.LEFT -> R.drawable.airpods_pro_left_notification
+ BatteryComponent.RIGHT -> R.drawable.airpods_pro_right_notification
+ else -> R.drawable.airpods_pro_case_notification
+}
+
+@Composable
+private fun NoiseControlCard(showOff: Boolean, currentValue: Int, onSelect: (Int) -> Unit) {
+ val context = LocalContext.current
+ LaunchedEffect(Unit) { AncVizPrefs.loadIfNeeded(context) }
+ val vizStyle by AncVizPrefs.style.collectAsState()
+ // Protocol listening-mode values, in display order.
+ val modes = buildList {
+ if (showOff) add(1) // Off
+ add(3) // Transparency
+ add(4) // Adaptive
+ add(2) // Noise Cancellation
+ }
+ ExpressiveGroup {
+ ExpressiveNoiseControlSelector(
+ modes = modes,
+ selected = currentValue,
+ onSelect = onSelect,
+ vizStyle = vizStyle,
+ modifier = Modifier.padding(20.dp),
+ )
+ }
+}
+
+@Composable
+private fun NotConnected(
+ padding: PaddingValues,
+ viewModel: AirPodsViewModel,
+ navController: NavController,
+) {
+ val state by viewModel.uiState.collectAsState()
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(horizontal = 32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center,
+ ) {
+ ExpressiveReconnectHero(
+ canReconnect = state.connectionSuccessful,
+ onReconnect = viewModel::reconnectFromSavedMac,
+ )
+ Spacer(Modifier.height(28.dp))
+ OutlinedButton(onClick = { navController.navigate("troubleshooting") }) {
+ Text(
+ text = "Troubleshoot",
+ style = MaterialTheme.typography.labelLarge,
+ )
+ }
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/screens/SettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/screens/SettingsScreen.kt
new file mode 100644
index 000000000..afc337b57
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/screens/SettingsScreen.kt
@@ -0,0 +1,137 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+@file:OptIn(ExperimentalMaterial3Api::class)
+
+package me.kavishdevar.librepods.presentation.m3.screens
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Article
+import androidx.compose.material.icons.filled.Accessibility
+import androidx.compose.material.icons.filled.BugReport
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.Tune
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavController
+import me.kavishdevar.librepods.BuildConfig
+import me.kavishdevar.librepods.presentation.m3.ExpressiveDivider
+import me.kavishdevar.librepods.presentation.m3.ExpressiveGroup
+import me.kavishdevar.librepods.presentation.m3.ExpressiveListItem
+import me.kavishdevar.librepods.presentation.m3.ExpressiveScreenScaffold
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSectionHeader
+import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
+
+@Composable
+fun SettingsScreen(viewModel: AirPodsViewModel, navController: NavController) {
+ ExpressiveScreenScaffold(title = "Settings") { padding ->
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(
+ top = padding.calculateTopPadding(),
+ bottom = 140.dp,
+ start = 16.dp,
+ end = 16.dp,
+ ),
+ ) {
+ item {
+ ExpressiveSectionHeader("App")
+ ExpressiveGroup {
+ ExpressiveListItem(
+ title = "App settings",
+ subtitle = "Widget, takeover, conversational awareness",
+ leadingIcon = Icons.Filled.Tune,
+ showChevron = true,
+ onClick = { navController.navigate("app_settings") },
+ )
+ ExpressiveDivider()
+ ExpressiveListItem(
+ title = "Accessibility",
+ leadingIcon = Icons.Filled.Accessibility,
+ showChevron = true,
+ onClick = { navController.navigate("accessibility") },
+ )
+ }
+ }
+
+ item {
+ ExpressiveSectionHeader("Device")
+ ExpressiveGroup {
+ ExpressiveListItem(
+ title = "Rename",
+ leadingIcon = Icons.Filled.Edit,
+ showChevron = true,
+ onClick = { navController.navigate("rename") },
+ )
+ ExpressiveDivider()
+ ExpressiveListItem(
+ title = "Firmware & version",
+ leadingIcon = Icons.Filled.Info,
+ showChevron = true,
+ onClick = { navController.navigate("version_info") },
+ )
+ }
+ }
+
+ item {
+ ExpressiveSectionHeader("Support")
+ ExpressiveGroup {
+ ExpressiveListItem(
+ title = "Troubleshooting",
+ leadingIcon = Icons.Filled.Settings,
+ showChevron = true,
+ onClick = { navController.navigate("troubleshooting") },
+ )
+ if (!BuildConfig.PLAY_BUILD) {
+ ExpressiveDivider()
+ ExpressiveListItem(
+ title = "Debug",
+ subtitle = "Raw AACP packet log",
+ leadingIcon = Icons.Filled.BugReport,
+ showChevron = true,
+ onClick = { navController.navigate("debug") },
+ )
+ }
+ }
+ }
+
+ item {
+ ExpressiveSectionHeader("About")
+ ExpressiveGroup {
+ ExpressiveListItem(
+ title = "Open-source licenses",
+ subtitle = "LibrePods v${BuildConfig.VERSION_NAME}",
+ leadingIcon = Icons.AutoMirrored.Filled.Article,
+ showChevron = true,
+ onClick = { navController.navigate("open_source_licenses") },
+ )
+ }
+ Spacer(Modifier.height(8.dp))
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/viz/AncViz.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/viz/AncViz.kt
new file mode 100644
index 000000000..b6d1cb8de
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/viz/AncViz.kt
@@ -0,0 +1,305 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+// Noise-control centerpiece visualizations. Three interchangeable flat-tonal scenes (Orb / Waveform /
+// Radial EQ) painted on a Compose Canvas, driven entirely by four per-mode scalars (energy, ripple,
+// pulse, dome) that ease toward the active mode's targets each frame — the scenes never branch on
+// "which mode", so mode changes morph smoothly.
+//
+// Design language (Material 3 Expressive, flat tonal): NO gradients, NO glow, NO white-gloss specular,
+// NO dark vignette. Solid theme-colour shapes only; every stroke/size is dp-scaled (so weights stay
+// consistent across densities instead of shimmering sub-pixel hairlines); motion is choreographed sine
+// (deterministic, eased) rather than random jitter. Per-mode colour is pulled from the live Material 3
+// scheme (primary / secondary / tertiary + their containers) so it tracks dynamic colour and never
+// clashes with the app accent. The Orb (default) uses an aperture/iris metaphor — its pupil closes when
+// sound is sealed out (Noise Cancellation) and opens wide when the world is let in (Transparency).
+
+package me.kavishdevar.librepods.presentation.m3.viz
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.withFrameNanos
+import androidx.compose.ui.Modifier
+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.StrokeCap
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.lerp
+import androidx.compose.ui.unit.dp
+import kotlin.math.PI
+import kotlin.math.cos
+import kotlin.math.max
+import kotlin.math.min
+import kotlin.math.pow
+import kotlin.math.sin
+
+/** The interchangeable centerpiece visualizations (user-switchable in settings). */
+enum class AncVizStyle(val key: String, val label: String) {
+ ORB("orb", "Orb"),
+ WAVEFORM("waveform", "Waveform"),
+ RADIAL("radial", "Radial EQ");
+
+ companion object {
+ val DEFAULT = ORB
+ // Unknown / retired keys (e.g. the dropped "blob"/"particles") fall back to the default.
+ fun fromKey(key: String?): AncVizStyle = entries.firstOrNull { it.key == key } ?: DEFAULT
+ }
+}
+
+private const val TAU = (2.0 * PI).toFloat()
+private const val PIf = PI.toFloat()
+
+private fun lerpF(a: Float, b: Float, t: Float) = a + (b - a) * t
+
+// ── Mode model: each mode's four scene scalars (0..1). ──
+private class Scene(val energy: Float, val ripple: Float, val pulse: Float, val dome: Float)
+
+private fun sceneFor(mode: Int): Scene = when (mode) {
+ 2 -> Scene(energy = 0.12f, ripple = 0.0f, pulse = 0.06f, dome = 1.0f) // Noise Cancellation
+ 3 -> Scene(energy = 1.0f, ripple = 1.0f, pulse = 0.22f, dome = 0.0f) // Transparency
+ 4 -> Scene(energy = 0.52f, ripple = 0.32f, pulse = 1.0f, dome = 0.16f) // Adaptive
+ else -> Scene(energy = 0.9f, ripple = 0.10f, pulse = 0.16f, dome = 0.0f) // Off
+}
+
+// ── Per-mode colour, pulled from the live M3 scheme so it tracks dynamic colour and never clashes with
+// the app accent. `accent` is the vivid tone (discs/strokes); `soft` its muted container companion (the
+// low-energy tone the scenes lerp up from, and the Orb's "pupil"). The scheme already encodes light/dark. ──
+private fun modeAccent(mode: Int, scheme: ColorScheme): Color = when (mode) {
+ 2 -> scheme.primary // Noise Cancellation — the headline mode
+ 3 -> scheme.tertiary // Transparency — the most hue-distinct role
+ 4 -> scheme.secondary // Adaptive
+ else -> scheme.onSurfaceVariant // Off — neutral
+}
+
+private fun modeSoft(mode: Int, scheme: ColorScheme): Color = when (mode) {
+ 2 -> scheme.primaryContainer
+ 3 -> scheme.tertiaryContainer
+ 4 -> scheme.secondaryContainer
+ else -> scheme.surfaceVariant
+}
+
+/** Per-frame environment handed to each renderer (geometry + eased scalars + colour helpers). */
+private class VizEnv(
+ val w: Float, val h: Float, val cx: Float, val cy: Float, val minWH: Float,
+ val dt: Float, val t: Float,
+ val energy: Float, val ripple: Float, val dome: Float, val pulse: Float,
+ val accent: Color, val soft: Color,
+) {
+ fun a(alpha: Float) = accent.copy(alpha = alpha.coerceIn(0f, 1f))
+}
+
+/** Holds the eased live scalars/colours + animation clock between frames. */
+private class VizHolder {
+ var energy = 0f; var ripple = 0f; var dome = 0f; var pulse = 0f
+ var accent = Color.Transparent; var soft = Color.Transparent
+ private var tEnergy = 0f; private var tRipple = 0f; private var tDome = 0f; private var tPulse = 0f
+ private var tAccent = Color.Transparent; private var tSoft = Color.Transparent
+ var t = 0f; var dt = 0f
+ private var primed = false
+
+ fun setTarget(s: Scene, a: Color, sf: Color) {
+ tEnergy = s.energy; tRipple = s.ripple; tDome = s.dome; tPulse = s.pulse; tAccent = a; tSoft = sf
+ if (!primed) {
+ energy = s.energy; ripple = s.ripple; dome = s.dome; pulse = s.pulse; accent = a; soft = sf
+ primed = true
+ }
+ }
+
+ fun advance(frameDt: Float) {
+ dt = frameDt
+ val k = (1.0 - 0.0008.pow(frameDt.toDouble())).toFloat()
+ energy = lerpF(energy, tEnergy, k); ripple = lerpF(ripple, tRipple, k)
+ dome = lerpF(dome, tDome, k); pulse = lerpF(pulse, tPulse, k)
+ accent = lerp(accent, tAccent, k); soft = lerp(soft, tSoft, k)
+ t += frameDt
+ }
+}
+
+/**
+ * The animated centerpiece. Runs a [withFrameNanos] clock, eases the four scalars (+ accent/soft
+ * colours) toward the active mode's targets, and paints the chosen [style] onto a Canvas every frame.
+ * Crash-guarded (a bad frame can never take down the UI).
+ */
+@Composable
+fun AncVisualizer(
+ modeValue: Int,
+ style: AncVizStyle,
+ modifier: Modifier = Modifier,
+) {
+ val scheme = MaterialTheme.colorScheme
+ val holder = remember { VizHolder() }
+
+ // Re-target whenever the mode or the resolved scheme colours change (mode switch, dark-mode toggle,
+ // or a dynamic-colour palette change); the holder eases between them each frame.
+ val accent = modeAccent(modeValue, scheme)
+ val soft = modeSoft(modeValue, scheme)
+ LaunchedEffect(modeValue, accent, soft) {
+ holder.setTarget(sceneFor(modeValue), accent, soft)
+ }
+
+ val tick = remember { mutableLongStateOf(0L) }
+ LaunchedEffect(Unit) {
+ var last = 0L
+ while (true) {
+ withFrameNanos { now ->
+ val dt = if (last == 0L) 0f else ((now - last) / 1_000_000_000f).coerceAtMost(0.05f)
+ last = now
+ holder.advance(dt)
+ tick.longValue = now
+ }
+ }
+ }
+
+ Canvas(modifier) {
+ tick.longValue // subscribe to the frame clock so we repaint each frame
+ val w = size.width
+ val h = size.height
+ if (w < 2f || h < 2f) return@Canvas
+ val env = VizEnv(
+ w = w, h = h, cx = w / 2f, cy = h / 2f, minWH = max(1f, min(w, h)),
+ dt = holder.dt, t = holder.t,
+ energy = holder.energy, ripple = holder.ripple, dome = holder.dome, pulse = holder.pulse,
+ accent = holder.accent, soft = holder.soft,
+ )
+ try {
+ when (style) {
+ AncVizStyle.ORB -> renderOrb(env)
+ AncVizStyle.WAVEFORM -> renderWaveform(env)
+ AncVizStyle.RADIAL -> renderRadialEq(env)
+ }
+ } catch (_: Throwable) {
+ // never let a single bad frame crash the UI
+ }
+ }
+}
+
+// ── shared scene piece ───────────────────────────────────────────────────────
+/**
+ * Crisp concentric rings that grow outward and fade — the "letting sound through" motif (driven by
+ * [VizEnv.ripple], so they only appear in Transparency/Adaptive). Flat strokes only: clean rings whose
+ * alpha steps down as they expand, never a radial-gradient haze.
+ */
+private fun DrawScope.expandingRings(env: VizEnv, count: Int, maxRFrac: Float) {
+ if (env.ripple <= 0.01f) return
+ val maxR = env.minWH * maxRFrac
+ val stroke = 2.dp.toPx()
+ for (i in 0 until count) {
+ val ph = ((env.t * 0.3f) + i.toFloat() / count) % 1f
+ drawCircle(
+ color = env.a(env.ripple * (1f - ph) * 0.5f),
+ radius = max(0f, ph * maxR), center = Offset(env.cx, env.cy), style = Stroke(stroke),
+ )
+ }
+}
+
+// ── 1 · Orb (default) — an aperture/iris ─────────────────────────────────────
+private fun DrawScope.renderOrb(env: VizEnv) {
+ val center = Offset(env.cx, env.cy)
+ val breathe = 0.5f + 0.5f * sin(env.t * 1.7f)
+
+ // Aperture radius: grows with energy, contracts a touch when sealed (dome), gently breathes (pulse).
+ val r = max(2f, env.minWH * (0.20f + 0.12f * env.energy) *
+ (1f - 0.18f * env.dome) * (1f + 0.05f * breathe * (0.4f + env.pulse)))
+
+ expandingRings(env, count = 3, maxRFrac = 0.46f)
+
+ // Outer halo — a single crisp stroke ring that pulses (no glow).
+ drawCircle(
+ color = env.a(0.20f + 0.30f * env.pulse * breathe),
+ radius = r * (1.30f + 0.12f * breathe * env.pulse), center = center, style = Stroke(2.dp.toPx()),
+ )
+
+ // Main disc: solid accent.
+ drawCircle(color = env.accent, radius = r, center = center)
+
+ // Inner "pupil": solid soft/container disc whose size = how OPEN the aperture is.
+ // Closed for Noise Cancellation (dome high) → reads sealed; wide for Transparency/Off → reads open.
+ val openness = ((0.30f + 0.50f * env.energy) * (1f - 0.90f * env.dome)).coerceIn(0.06f, 0.78f)
+ drawCircle(color = env.soft, radius = r * openness, center = center)
+
+ // Tiny accent centre — keeps the iris legible when the pupil is wide.
+ drawCircle(color = env.accent, radius = r * openness * 0.34f, center = center)
+}
+
+// ── 2 · Waveform — solid tonal bars ──────────────────────────────────────────
+private fun DrawScope.renderWaveform(env: VizEnv) {
+ val n = 33
+ val gap = 4.dp.toPx()
+ val bw = max(3.dp.toPx(), (env.w - gap * (n - 1)) / n)
+ val totalW = n * bw + (n - 1) * gap
+ var x = env.cx - totalW / 2f
+ val maxH = env.h * 0.40f
+ val minBar = 3.dp.toPx()
+ val stillness = 1f - env.dome * 0.80f
+
+ for (i in 0 until n) {
+ val frac = i.toFloat() / (n - 1)
+ // Deterministic, choreographed height — two detuned sines per bar, windowed into a smooth arch
+ // (tall in the middle). No randomness, so the motion reads as designed rather than jittery.
+ val window = 0.40f + 0.60f * sin(frac * PIf)
+ val phase = frac * 7f
+ val wave = 0.5f + 0.5f * (0.65f * sin(env.t * 2.2f + phase) + 0.35f * sin(env.t * 3.7f + phase * 1.7f))
+ val amp = maxH * env.energy * window * (0.35f + 0.65f * wave * stillness) + minBar
+ val f = min(1f, amp / maxH)
+ drawRoundRect(
+ color = lerp(env.soft, env.accent, f),
+ topLeft = Offset(x, env.cy - amp), size = Size(bw, amp * 2f),
+ cornerRadius = CornerRadius(bw / 2f),
+ )
+ x += bw + gap
+ }
+}
+
+// ── 3 · Radial EQ — solid tonal spokes around a flat core ────────────────────
+private fun DrawScope.renderRadialEq(env: VizEnv) {
+ val center = Offset(env.cx, env.cy)
+ val n = 56
+ val baseR = env.minWH * 0.20f
+ val maxLen = env.minWH * 0.20f
+ val rot = env.t * 0.10f
+ val stillness = 1f - env.dome * 0.80f
+ val stroke = 3.dp.toPx()
+ val minLen = 1.5.dp.toPx()
+
+ // Flat core: a solid container disc with one crisp accent ring (no gradient core).
+ drawCircle(color = env.soft, radius = baseR, center = center)
+ drawCircle(color = env.a(0.50f), radius = baseR, center = center, style = Stroke(1.5.dp.toPx()))
+
+ for (i in 0 until n) {
+ val frac = i.toFloat() / n
+ val ang = frac * TAU + rot
+ val phase = frac * TAU * 3f
+ val wave = 0.5f + 0.5f * (0.60f * sin(env.t * 2.0f + phase) + 0.40f * sin(env.t * 3.3f - phase * 1.3f))
+ val len = max(minLen, maxLen * env.energy * (0.30f + 0.70f * wave * stillness))
+ val ix = env.cx + cos(ang) * baseR; val iy = env.cy + sin(ang) * baseR
+ val ox = env.cx + cos(ang) * (baseR + len); val oy = env.cy + sin(ang) * (baseR + len)
+ val f = min(1f, len / maxLen)
+ drawLine(
+ color = lerp(env.soft, env.accent, f),
+ start = Offset(ix, iy), end = Offset(ox, oy), strokeWidth = stroke, cap = StrokeCap.Round,
+ )
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/viz/AncVizPrefs.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/viz/AncVizPrefs.kt
new file mode 100644
index 000000000..79a4bc303
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/m3/viz/AncVizPrefs.kt
@@ -0,0 +1,51 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.presentation.m3.viz
+
+import android.content.Context
+import androidx.core.content.edit
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/**
+ * The user's chosen noise-control centerpiece [AncVizStyle], persisted in the app's `settings`
+ * SharedPreferences and exposed as a [StateFlow] so the home card and the settings screen stay in sync.
+ */
+object AncVizPrefs {
+ private const val PREFS = "settings"
+ private const val KEY = "anc_viz_style"
+
+ private val _style = MutableStateFlow(AncVizStyle.DEFAULT)
+ val style: StateFlow = _style.asStateFlow()
+
+ @Volatile private var loaded = false
+
+ fun loadIfNeeded(context: Context) {
+ if (loaded) return
+ loaded = true
+ val key = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY, null)
+ _style.value = AncVizStyle.fromKey(key)
+ }
+
+ fun set(context: Context, style: AncVizStyle) {
+ context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit { putString(KEY, style.key) }
+ _style.value = style
+ }
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/IslandWindow.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/IslandWindow.kt
deleted file mode 100644
index bf5eff89e..000000000
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/IslandWindow.kt
+++ /dev/null
@@ -1,758 +0,0 @@
-/*
- LibrePods - AirPods liberated from Apple’s ecosystem
- Copyright (C) 2025 LibrePods contributors
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License
- along with this program. If not, see .
-*/
-
-@file:OptIn(ExperimentalEncodingApi::class)
-
-package me.kavishdevar.librepods.presentation.overlays
-
-import android.animation.Animator
-import android.animation.AnimatorListenerAdapter
-import android.animation.ObjectAnimator
-import android.animation.PropertyValuesHolder
-import android.animation.ValueAnimator
-import android.annotation.SuppressLint
-import android.content.BroadcastReceiver
-import android.content.Context
-import android.content.Intent
-import android.content.IntentFilter
-import android.content.res.Resources
-import android.graphics.PixelFormat
-import android.graphics.drawable.GradientDrawable
-import android.media.AudioManager
-import android.os.Build
-import android.os.Handler
-import android.os.Looper
-import android.util.Log.e
-import android.view.Gravity
-import android.view.LayoutInflater
-import android.view.MotionEvent
-import android.view.VelocityTracker
-import android.view.View
-import android.view.WindowManager
-import android.view.animation.AccelerateInterpolator
-import android.view.animation.AnticipateOvershootInterpolator
-import android.view.animation.DecelerateInterpolator
-import android.view.animation.OvershootInterpolator
-import android.widget.FrameLayout
-import android.widget.ImageButton
-import android.widget.LinearLayout
-import android.widget.ProgressBar
-import android.widget.TextView
-import android.widget.VideoView
-import androidx.core.net.toUri
-import androidx.dynamicanimation.animation.DynamicAnimation
-import androidx.dynamicanimation.animation.SpringAnimation
-import androidx.dynamicanimation.animation.SpringForce
-import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.data.AirPodsNotifications
-import me.kavishdevar.librepods.data.Battery
-import me.kavishdevar.librepods.data.BatteryComponent
-import me.kavishdevar.librepods.data.BatteryStatus
-import me.kavishdevar.librepods.services.ServiceManager
-import kotlin.io.encoding.ExperimentalEncodingApi
-import kotlin.math.abs
-
-enum class IslandType {
- CONNECTED,
- TAKING_OVER,
- MOVED_TO_REMOTE,
- MOVED_TO_OTHER_DEVICE,
-}
-
-class IslandWindow(private val context: Context) {
- private val windowManager: WindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
- @SuppressLint("InflateParams")
- private val islandView: View = LayoutInflater.from(context).inflate(R.layout.island_window, null)
- private var isClosing = false
- private var params: WindowManager.LayoutParams? = null
-
- private var initialY = 0f
- private var initialTouchY = 0f
- private var lastTouchY = 0f
- private var velocityTracker: VelocityTracker? = null
- private var isBeingDragged = false
- private var autoCloseHandler: Handler? = null
- private var autoCloseRunnable: Runnable? = null
- private var initialHeight = 0
- private var screenHeight = 0
- private var isDraggingDown = false
- private var lastMoveTime = 0L
- private var yMovement = 0f
- private var dragDistance = 0f
-
- private var initialConnectedTextY = 0f
- private var initialDeviceTextY = 0f
- private var initialBatteryViewY = 0f
- private var initialVideoViewY = 0f
- private var initialTextSeparation = 0f
-
- private val containerView = FrameLayout(context)
-
- private lateinit var springAnimation: SpringAnimation
- private val flingAnimator = ValueAnimator()
-
- private val batteryReceiver = object : BroadcastReceiver() {
- override fun onReceive(context: Context?, intent: Intent?) {
- if (intent?.action == AirPodsNotifications.BATTERY_DATA) {
- val batteryList = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- intent.getParcelableArrayListExtra("data", Battery::class.java)
- } else {
- @Suppress("DEPRECATION")
- intent.getParcelableArrayListExtra("data")
- }
- updateBatteryDisplay(batteryList)
- } else if (intent?.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
- try {
- context?.unregisterReceiver(this)
- } catch (e: Exception) {
- e.printStackTrace()
- }
- }
- }
- }
-
- val isVisible: Boolean
- get() = containerView.parent != null && containerView.visibility == View.VISIBLE
-
- @SuppressLint("SetTextI18n")
- private fun updateBatteryDisplay(batteryList: ArrayList?) {
- if (batteryList == null || batteryList.isEmpty()) return
-
- val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT }
- val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT }
-
- val leftLevel = leftBattery?.level ?: 0
- val rightLevel = rightBattery?.level ?: 0
- leftBattery?.status ?: BatteryStatus.DISCONNECTED
- rightBattery?.status ?: BatteryStatus.DISCONNECTED
-
- val batteryText = islandView.findViewById(R.id.island_battery_text)
- val batteryProgressBar = islandView.findViewById(R.id.island_battery_progress)
-
- val displayBatteryLevel = when {
- leftLevel > 0 && rightLevel > 0 -> minOf(leftLevel, rightLevel)
- leftLevel > 0 -> leftLevel
- rightLevel > 0 -> rightLevel
- else -> null
- }
-
- if (displayBatteryLevel != null) {
- batteryText.text = "$displayBatteryLevel%"
- batteryProgressBar.progress = displayBatteryLevel
- batteryProgressBar.isIndeterminate = false
- } else {
- batteryText.text = "?"
- batteryProgressBar.progress = 0
- batteryProgressBar.isIndeterminate = false
- }
- }
-
- @SuppressLint("SetTextI18s", "ClickableViewAccessibility", "UnspecifiedRegisterReceiverFlag",
- "SetTextI18n"
- )
- fun show(name: String, batteryPercentage: Int, context: Context, type: IslandType = IslandType.CONNECTED, reversed: Boolean = false, otherDeviceName: String? = null) {
- if (ServiceManager.getService()?.islandOpen == true) return
- else ServiceManager.getService()?.islandOpen = true
-
- val displayMetrics = Resources.getSystem().displayMetrics
- val width = (displayMetrics.widthPixels * 0.95).toInt()
- screenHeight = displayMetrics.heightPixels
-
- val batteryList = ServiceManager.getService()?.getBattery()
- val batteryText = islandView.findViewById(R.id.island_battery_text)
- val batteryProgressBar = islandView.findViewById(R.id.island_battery_progress)
-
- val displayBatteryLevel = if (batteryList != null) {
- val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT }
- val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT }
-
- when {
- (leftBattery?.level ?: 0) > 0 && (rightBattery?.level ?: 0) > 0 ->
- minOf(leftBattery!!.level, rightBattery!!.level)
- (leftBattery?.level ?: 0) > 0 -> leftBattery!!.level
- (rightBattery?.level ?: 0) > 0 -> rightBattery!!.level
- batteryPercentage > 0 -> batteryPercentage
- else -> null
- }
- } else if (batteryPercentage > 0) {
- batteryPercentage
- } else {
- null
- }
-
- if (displayBatteryLevel != null) {
- batteryText.text = "$displayBatteryLevel%"
- batteryProgressBar.progress = displayBatteryLevel
- } else {
- batteryText.text = "?"
- batteryProgressBar.progress = 0
- }
-
- batteryProgressBar.isIndeterminate = false
- islandView.findViewById(R.id.island_device_name).text = name
-
- val actionButton = islandView.findViewById(R.id.island_action_button)
- val batteryBg = islandView.findViewById(R.id.island_battery_bg)
- if (type == IslandType.MOVED_TO_OTHER_DEVICE && !reversed) {
- actionButton.visibility = View.VISIBLE
- actionButton.setOnClickListener {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- ServiceManager.getService()?.takeOver("reverse")
- }
- close()
- }
- batteryText.visibility = View.GONE
- batteryProgressBar.visibility = View.GONE
- batteryBg.visibility = View.GONE
- } else {
- actionButton.visibility = View.GONE
- batteryText.visibility = View.VISIBLE
- batteryProgressBar.visibility = View.VISIBLE
- batteryBg.visibility = View.VISIBLE
- }
-
- val batteryIntentFilter = IntentFilter(AirPodsNotifications.BATTERY_DATA)
- batteryIntentFilter.addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- context.registerReceiver(batteryReceiver, batteryIntentFilter, Context.RECEIVER_EXPORTED)
- } else {
- context.registerReceiver(batteryReceiver, batteryIntentFilter)
- }
-
- ServiceManager.getService()?.sendBatteryBroadcast()
-
- containerView.removeAllViews()
- val containerParams = FrameLayout.LayoutParams(
- FrameLayout.LayoutParams.MATCH_PARENT,
- FrameLayout.LayoutParams.WRAP_CONTENT
- )
-
- containerView.addView(islandView, containerParams)
-
- params = WindowManager.LayoutParams(
- width,
- WindowManager.LayoutParams.WRAP_CONTENT,
- WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
- WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
- PixelFormat.TRANSLUCENT
- ).apply {
- gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
- }
-
- islandView.visibility = View.VISIBLE
- containerView.visibility = View.VISIBLE
-
- containerView.setOnTouchListener { _, event ->
- when (event.action) {
- MotionEvent.ACTION_DOWN -> {
- autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return@setOnTouchListener false)
- flingAnimator.cancel()
-
- velocityTracker?.recycle()
- velocityTracker = VelocityTracker.obtain()
- velocityTracker?.addMovement(event)
-
- initialY = containerView.translationY
- initialTouchY = event.rawY
- lastTouchY = event.rawY
- initialHeight = islandView.height
- isBeingDragged = false
- isDraggingDown = false
- lastMoveTime = System.currentTimeMillis()
- dragDistance = 0f
-
- captureInitialPositions()
-
- true
- }
- MotionEvent.ACTION_MOVE -> {
- velocityTracker?.addMovement(event)
- val deltaY = event.rawY - initialTouchY
- val moveDelta = event.rawY - lastTouchY
- dragDistance += abs(moveDelta)
-
- isDraggingDown = moveDelta > 0
-
- val currentTime = System.currentTimeMillis()
- val timeDelta = currentTime - lastMoveTime
- if (timeDelta > 0) {
- yMovement = moveDelta / timeDelta * 10
- }
- lastMoveTime = currentTime
-
- if (abs(deltaY) > 5 || isBeingDragged) {
- isBeingDragged = true
-
- // Cancel auto close timer when dragging starts
- autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return@setOnTouchListener false)
-
- val dampedDeltaY = if (deltaY > 0) {
- initialY + (deltaY * 0.6f)
- } else {
- initialY + (deltaY * 0.9f)
- }
- containerView.translationY = dampedDeltaY
-
- if (isDraggingDown && deltaY > 0) {
- val stretchAmount = (deltaY * 0.5f).coerceAtMost(200f)
- applyCustomStretchEffect(stretchAmount)
- }
- }
-
- lastTouchY = event.rawY
- true
- }
- MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
- velocityTracker?.addMovement(event)
- velocityTracker?.computeCurrentVelocity(1000)
- val yVelocity = velocityTracker?.yVelocity ?: 0f
-
- if (isBeingDragged) {
- val currentTranslationY = containerView.translationY
- abs(yVelocity) > 800
- val significantDrag = abs(dragDistance) > 80
-
- when {
- yVelocity < -1200 || (currentTranslationY < -80 && !isDraggingDown) -> {
- animateDismissWithInertia(yVelocity)
- }
- yVelocity > 1200 || (isDraggingDown && significantDrag) -> {
- animateExpandWithStretch(yVelocity)
- }
- else -> {
- springBackWithInertia(yVelocity)
- }
- }
- } else if (dragDistance < 10) {
- resetAutoCloseTimer()
- }
-
- velocityTracker?.recycle()
- velocityTracker = null
- isBeingDragged = false
- true
- }
- else -> false
- }
- }
-
- when (type) {
- IslandType.CONNECTED -> {
- islandView.findViewById(R.id.island_connected_text).text = context.getString(R.string.island_connected_text)
- }
- IslandType.TAKING_OVER -> {
- islandView.findViewById(R.id.island_connected_text).text = context.getString(R.string.island_taking_over_text)
- }
- IslandType.MOVED_TO_REMOTE -> {
- islandView.findViewById(R.id.island_connected_text).text = context.getString(R.string.island_moved_to_remote_text)
- }
- IslandType.MOVED_TO_OTHER_DEVICE -> {
- if (otherDeviceName == null || otherDeviceName.isEmpty()) {
- e("IslandWindow", "Other device name is null or empty for MOVED_TO_OTHER_DEVICE type")
- }
- if (reversed) {
- islandView.findViewById(R.id.island_connected_text).text = context.getString(R.string.island_moved_to_other_device_reversed_text)
- } else {
- islandView.findViewById(R.id.island_connected_text).text = context.getString(R.string.island_moved_to_other_device_text, otherDeviceName)
- }
- }
- }
-
- val videoView = islandView.findViewById(R.id.island_video_view)
- val videoUri = "android.resource://me.kavishdevar.librepods/${R.raw.island}".toUri()
- videoView.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE)
- videoView.setVideoURI(videoUri)
- videoView.setOnPreparedListener { mediaPlayer ->
- mediaPlayer.isLooping = true
- videoView.start()
- }
-
- try {
- windowManager.addView(containerView, params)
- } catch (e: Exception) {
- e.printStackTrace()
- }
-
- islandView.post {
- initialHeight = islandView.height
- captureInitialPositions()
- }
-
- springAnimation = SpringAnimation(containerView, DynamicAnimation.TRANSLATION_Y, 0f).apply {
- spring = SpringForce(0f)
- .setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY)
- .setStiffness(SpringForce.STIFFNESS_MEDIUM)
- }
-
- val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 0.5f, 1f)
- val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 0.5f, 1f)
- val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, -200f, 0f)
- ObjectAnimator.ofPropertyValuesHolder(containerView, scaleX, scaleY, translationY).apply {
- duration = 700
- interpolator = AnticipateOvershootInterpolator()
- start()
- }
-
- resetAutoCloseTimer()
- }
-
- private fun captureInitialPositions() {
- val connectedText = islandView.findViewById(R.id.island_connected_text)
- val deviceText = islandView.findViewById(R.id.island_device_name)
- val batteryView = islandView.findViewById(R.id.island_battery_container)
- val videoView = islandView.findViewById(R.id.island_video_view)
-
- connectedText.post {
- initialConnectedTextY = connectedText.y
- initialDeviceTextY = deviceText.y
- initialTextSeparation = deviceText.y - (connectedText.y + connectedText.height)
-
- if (batteryView != null) initialBatteryViewY = batteryView.y
- initialVideoViewY = videoView.y
- }
- }
-
- private fun applyCustomStretchEffect(stretchAmount: Float) {
- try {
- val mainLayout = islandView.findViewById(R.id.island_window_layout)
- islandView.findViewById(R.id.island_connected_text)
- val deviceText = islandView.findViewById(R.id.island_device_name)
- islandView.findViewById(R.id.island_battery_container)
- islandView.findViewById(R.id.island_video_view)
-
- val stretchFactor = 1f + (stretchAmount / 300f).coerceAtMost(4.0f)
- val newMinHeight = (initialHeight * stretchFactor).toInt()
- mainLayout.minimumHeight = newMinHeight
-
- val textMarginIncrease = (stretchAmount * 0.8f).toInt()
-
- val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams
- deviceTextParams.topMargin = textMarginIncrease
- deviceText.layoutParams = deviceTextParams
-
- val background = mainLayout.background
- if (background is GradientDrawable) {
- val cornerRadius = 56f
- background.cornerRadius = cornerRadius
- }
-
- if (params != null) {
- params!!.height = screenHeight
-
- val containerParams = containerView.layoutParams
- containerParams.height = screenHeight
- containerView.layoutParams = containerParams
-
- try {
- windowManager.updateViewLayout(containerView, params)
- } catch (e: Exception) {
- e.printStackTrace()
- }
- }
- } catch (e: Exception) {
- e.printStackTrace()
- }
- }
-
- private fun resetAutoCloseTimer() {
- autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return)
- autoCloseHandler = Handler(Looper.getMainLooper())
- autoCloseRunnable = Runnable { close() }
- autoCloseHandler?.postDelayed(autoCloseRunnable!!, 4500)
- }
-
- private fun springBackWithInertia(velocity: Float) {
- springAnimation.cancel()
- flingAnimator.cancel()
-
- springAnimation.setStartVelocity(velocity)
-
- val baseStiffness = SpringForce.STIFFNESS_MEDIUM
- val dynamicStiffness = baseStiffness * (1f + (abs(velocity) / 3000f))
- springAnimation.spring = SpringForce(0f)
- .setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY)
- .setStiffness(dynamicStiffness)
-
- resetStretchEffects()
-
- if (params != null) {
- params!!.height = WindowManager.LayoutParams.WRAP_CONTENT
- try {
- windowManager.updateViewLayout(containerView, params)
- } catch (e: Exception) {
- e.printStackTrace()
- }
- }
-
- springAnimation.start()
- }
-
- private fun resetStretchEffects() {
- try {
- val mainLayout = islandView.findViewById(R.id.island_window_layout)
- val deviceText = islandView.findViewById(R.id.island_device_name)
-
- val heightAnimator = ValueAnimator.ofInt(mainLayout.minimumHeight, initialHeight)
- heightAnimator.duration = 300
- heightAnimator.interpolator = OvershootInterpolator(1.5f)
- heightAnimator.addUpdateListener { animation ->
- mainLayout.minimumHeight = animation.animatedValue as Int
- }
-
- val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams
- val textMarginAnimator = ValueAnimator.ofInt(deviceTextParams.topMargin, 0)
- textMarginAnimator.duration = 300
- textMarginAnimator.interpolator = OvershootInterpolator(1.5f)
- textMarginAnimator.addUpdateListener { animation ->
- deviceTextParams.topMargin = animation.animatedValue as Int
- deviceText.layoutParams = deviceTextParams
- }
-
- heightAnimator.start()
- textMarginAnimator.start()
- } catch (e: Exception) {
- e.printStackTrace()
- }
- }
-
- private fun animateDismissWithInertia(velocity: Float) {
- springAnimation.cancel()
- flingAnimator.cancel()
-
- val baseDistance = -screenHeight
- val velocityFactor = (abs(velocity) / 2000f).coerceIn(0.5f, 2.0f)
- val targetDistance = baseDistance * velocityFactor
-
- val baseDuration = 400L
- val velocityDurationFactor = (1500f / (abs(velocity) + 1500f))
- val duration = (baseDuration * velocityDurationFactor).toLong().coerceIn(200L, 500L)
-
- flingAnimator.setFloatValues(containerView.translationY, targetDistance)
- flingAnimator.duration = duration
- flingAnimator.addUpdateListener { animation ->
- containerView.translationY = animation.animatedValue as Float
-
- val progress = animation.animatedFraction
- containerView.scaleX = 1f - (progress * 0.5f)
- containerView.scaleY = 1f - (progress * 0.5f)
-
- containerView.alpha = 1f - progress
- }
- flingAnimator.addListener(object : AnimatorListenerAdapter() {
- override fun onAnimationEnd(animation: Animator) {
- forceClose()
- }
- })
-
- flingAnimator.interpolator = DecelerateInterpolator(1.2f)
- flingAnimator.start()
- }
-
- private fun animateExpandWithStretch(velocity: Float) {
- springAnimation.cancel()
- flingAnimator.cancel()
-
- val baseDuration = 600L
- val velocityFactor = (1800f / (abs(velocity) + 1800f)).coerceIn(0.5f, 1.5f)
- val expandDuration = (baseDuration * velocityFactor).toLong().coerceIn(300L, 700L)
-
- if (params != null) {
- params!!.height = screenHeight
- try {
- windowManager.updateViewLayout(containerView, params)
- } catch (e: Exception) {
- e.printStackTrace()
- }
- }
-
- val containerAnimator = ValueAnimator.ofFloat(containerView.translationY, screenHeight * 0.6f)
- containerAnimator.duration = expandDuration
- containerAnimator.interpolator = DecelerateInterpolator(0.8f)
- containerAnimator.addUpdateListener { animation ->
- containerView.translationY = animation.animatedValue as Float
- }
-
- val stretchAnimator = ValueAnimator.ofFloat(0f, 1f)
- stretchAnimator.duration = expandDuration
- stretchAnimator.interpolator = OvershootInterpolator(0.5f)
- stretchAnimator.addUpdateListener { animation ->
- val progress = animation.animatedValue as Float
- animateCustomStretch(progress)
- }
-
- val normalizeAnimator = ValueAnimator.ofFloat(1.0f, 0.0f)
- normalizeAnimator.duration = 300
- normalizeAnimator.startDelay = expandDuration - 150
- normalizeAnimator.interpolator = AccelerateInterpolator(1.2f)
- normalizeAnimator.addUpdateListener { animation ->
- val progress = animation.animatedValue as Float
- containerView.alpha = progress
-
- if (progress < 0.7f) {
- islandView.findViewById(R.id.island_video_view).visibility = View.GONE
- }
- }
- normalizeAnimator.addListener(object : AnimatorListenerAdapter() {
- override fun onAnimationEnd(animation: Animator) {
- ServiceManager.getService()?.startMainActivity()
- forceClose()
- }
- })
-
- containerAnimator.start()
- stretchAnimator.start()
- normalizeAnimator.start()
- }
-
- private fun animateCustomStretch(progress: Float) {
- try {
- val mainLayout = islandView.findViewById(R.id.island_window_layout)
- val connectedText = islandView.findViewById(R.id.island_connected_text)
- val deviceText = islandView.findViewById(R.id.island_device_name)
-
- val targetHeight = (screenHeight * 0.7f).toInt()
- val currentHeight = initialHeight + ((targetHeight - initialHeight) * progress)
- mainLayout.minimumHeight = currentHeight.toInt()
-
- val mainLayoutParams = mainLayout.layoutParams
- mainLayoutParams.height = LinearLayout.LayoutParams.MATCH_PARENT
- mainLayout.layoutParams = mainLayoutParams
-
- val targetMargin = (400 * progress).toInt()
- val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams
- deviceTextParams.topMargin = targetMargin
- deviceText.layoutParams = deviceTextParams
-
- val baseTextSize = 24f
- deviceText.textSize = baseTextSize + (progress * 8f)
-
- val baseSubTextSize = 16f
- connectedText.textSize = baseSubTextSize + (progress * 4f)
- } catch (e: Exception) {
- e.printStackTrace()
- }
- }
-
- fun close() {
- if (Looper.myLooper() != Looper.getMainLooper()) {
- Handler(Looper.getMainLooper()).post { close() }
- return
- }
- try {
- if (isClosing) return
- isClosing = true
-
- try {
- context.unregisterReceiver(batteryReceiver)
- } catch (e: Exception) {
-// e.printStackTrace()
- }
-
- ServiceManager.getService()?.islandOpen = false
- autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return)
-
- resetStretchEffects()
-
- val videoView = islandView.findViewById(R.id.island_video_view)
- try {
- videoView.stopPlayback()
- } catch (e: Exception) {
- e.printStackTrace()
- }
-
- val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, containerView.scaleX, 0.5f)
- val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, containerView.scaleY, 0.5f)
- val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, containerView.translationY, -200f)
- ObjectAnimator.ofPropertyValuesHolder(containerView, scaleX, scaleY, translationY).apply {
- duration = 700
- interpolator = AnticipateOvershootInterpolator()
- addListener(object : AnimatorListenerAdapter() {
- override fun onAnimationEnd(animation: Animator) {
- cleanupAndRemoveView()
- }
- })
- start()
- }
- } catch (e: Exception) {
- e.printStackTrace()
- // Even if animation fails, ensure we cleanup
- cleanupAndRemoveView()
- }
- }
-
- private fun cleanupAndRemoveView() {
- if (Looper.myLooper() != Looper.getMainLooper()) {
- Handler(Looper.getMainLooper()).post { cleanupAndRemoveView() }
- return
- }
- try {
- containerView.visibility = View.GONE
- } catch (e: Exception) {
- e("IslandWindow", "Error setting visibility: $e")
- }
- try {
- if (containerView.parent != null) {
- windowManager.removeView(containerView)
- }
- } catch (e: Exception) {
- e("IslandWindow", "Error removing view: $e")
- }
- isClosing = false
- // Make sure all animations are canceled
- try {
- springAnimation.cancel()
- } catch (e: Exception) {
- e("IslandWindow", "Error cancelling spring animation $e")
- }
- try {
- flingAnimator.cancel()
- } catch (e: Exception) {
- e("IslandWindow", "Error cancelling fling animation $e")
- }
- }
-
- fun forceClose() {
- if (Looper.myLooper() != Looper.getMainLooper()) {
- Handler(Looper.getMainLooper()).post { forceClose() }
- return
- }
- try {
- if (isClosing) return
- isClosing = true
-
- try {
- context.unregisterReceiver(batteryReceiver)
- } catch (e: Exception) {
- e.printStackTrace()
- }
-
- ServiceManager.getService()?.islandOpen = false
- autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return)
-
- // Cancel all ongoing animations
- springAnimation.cancel()
- flingAnimator.cancel()
-
- // Immediately remove the view without animations
- cleanupAndRemoveView()
- } catch (e: Exception) {
- e.printStackTrace()
- isClosing = false
- }
- }
-}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/PopupWindow.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/PopupWindow.kt
deleted file mode 100644
index 4247ea47a..000000000
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/overlays/PopupWindow.kt
+++ /dev/null
@@ -1,290 +0,0 @@
-/*
- LibrePods - AirPods liberated from Apple’s ecosystem
- Copyright (C) 2025 LibrePods contributors
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License
- along with this program. If not, see .
-*/
-
-
-package me.kavishdevar.librepods.presentation.overlays
-
-import android.animation.Animator
-import android.animation.AnimatorListenerAdapter
-import android.animation.ObjectAnimator
-import android.animation.PropertyValuesHolder
-import android.annotation.SuppressLint
-import android.content.BroadcastReceiver
-import android.content.Context
-import android.content.Intent
-import android.content.IntentFilter
-import android.graphics.PixelFormat
-import android.media.AudioManager
-import android.os.Build
-import android.os.Handler
-import android.os.Looper
-import android.util.Log
-import android.view.Gravity
-import android.view.LayoutInflater
-import android.view.MotionEvent
-import android.view.View
-import android.view.WindowManager
-import android.view.animation.AccelerateInterpolator
-import android.view.animation.DecelerateInterpolator
-import android.widget.ImageButton
-import android.widget.LinearLayout
-import android.widget.TextView
-import android.widget.VideoView
-import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.data.AirPodsNotifications
-import me.kavishdevar.librepods.data.Battery
-import me.kavishdevar.librepods.data.BatteryComponent
-import me.kavishdevar.librepods.data.BatteryStatus
-
-@SuppressLint("InflateParams", "ClickableViewAccessibility")
-class PopupWindow(
- private val context: Context,
- private val onCloseCallback: () -> Unit = {}
-) {
- private val mView: View
- private var isClosing = false
- private var autoCloseHandler = Handler(Looper.getMainLooper())
- private var autoCloseRunnable: Runnable? = null
- private var batteryUpdateReceiver: BroadcastReceiver? = null
-
- @Suppress("DEPRECATION")
- private val mParams: WindowManager.LayoutParams = WindowManager.LayoutParams().apply {
- height = WindowManager.LayoutParams.WRAP_CONTENT
- val displayMetrics = context.resources.displayMetrics
- val screenWidthDp = displayMetrics.widthPixels / displayMetrics.density
- width = if (screenWidthDp >= 600) {
- (400 * displayMetrics.density).toInt()
- } else {
- WindowManager.LayoutParams.MATCH_PARENT
- }
- type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
- format = PixelFormat.TRANSLUCENT
- gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
- dimAmount = 0.3f
- flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS or
- WindowManager.LayoutParams.FLAG_FULLSCREEN or
- WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
- WindowManager.LayoutParams.FLAG_DIM_BEHIND or
- WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
- }
-
- private val mWindowManager: WindowManager
-
- init {
- val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater
- mView = layoutInflater.inflate(R.layout.popup_window, null)
- mParams.x = 0
- mParams.y = 0
-
- mView.setOnClickListener {
- close()
- }
-
- mView.findViewById(R.id.close_button).setOnClickListener {
- close()
- }
-
- val ll = mView.findViewById(R.id.linear_layout)
- ll.setOnClickListener {
- close()
- }
-
- @Suppress("DEPRECATION")
- mView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
- View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
- View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
- View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
- View.SYSTEM_UI_FLAG_FULLSCREEN or
- View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
-
- mView.setOnTouchListener { _, event ->
- if (event.action == MotionEvent.ACTION_DOWN) {
- val touchY = event.rawY
- val popupTop = mView.top
- if (touchY < popupTop) {
- close()
- true
- } else {
- false
- }
- } else {
- false
- }
- }
- mWindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
- }
-
- @SuppressLint("InlinedApi", "SetTextI18s")
- fun open(name: String = "AirPods Pro", batteryNotification: AirPodsNotifications.BatteryNotification) {
- try {
- if (mView.windowToken == null && mView.parent == null && !isClosing) {
- mView.findViewById(R.id.name).text = name
-
- updateBatteryStatus(batteryNotification)
-
- val vid = mView.findViewById(R.id.video)
- vid.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE)
- vid.setVideoPath("android.resource://me.kavishdevar.librepods/" + R.raw.connected)
- vid.resolveAdjustedSize(vid.width, vid.height)
- vid.start()
- vid.setOnCompletionListener {
- vid.start()
- }
-
- try {
- mWindowManager.addView(mView, mParams)
- } catch (e: Exception) {
- e.printStackTrace()
- }
-
- val displayMetrics = mView.context.resources.displayMetrics
- val screenHeight = displayMetrics.heightPixels
-
- mView.translationY = screenHeight.toFloat()
- mView.alpha = 1f
-
- val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, screenHeight.toFloat(), 0f)
-
- ObjectAnimator.ofPropertyValuesHolder(mView, translationY).apply {
- duration = 500
- interpolator = DecelerateInterpolator()
- start()
- }
-
- registerBatteryUpdateReceiver()
-
- autoCloseRunnable = Runnable { close() }
- autoCloseHandler.postDelayed(autoCloseRunnable!!, 12000)
- }
- } catch (e: Exception) {
- Log.e("PopupWindow", "Error opening popup: ${e.message}")
- onCloseCallback()
- }
- }
-
- @SuppressLint("UnspecifiedRegisterReceiverFlag")
- private fun registerBatteryUpdateReceiver() {
- batteryUpdateReceiver = object : BroadcastReceiver() {
- override fun onReceive(context: Context?, intent: Intent?) {
- if (intent?.action == AirPodsNotifications.BATTERY_DATA) {
- val batteryList = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- intent.getParcelableArrayListExtra("data", Battery::class.java)
- } else {
- @Suppress("DEPRECATION")
- intent.getParcelableArrayListExtra("data")
- }
- if (batteryList != null) {
- updateBatteryStatusFromList(batteryList)
- }
- }
- }
- }
-
- val filter = IntentFilter(AirPodsNotifications.BATTERY_DATA)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- context.registerReceiver(batteryUpdateReceiver, filter, Context.RECEIVER_EXPORTED)
- } else {
- context.registerReceiver(batteryUpdateReceiver, filter)
- }
- }
-
- private fun unregisterBatteryUpdateReceiver() {
- batteryUpdateReceiver?.let {
- try {
- context.unregisterReceiver(it)
- batteryUpdateReceiver = null
- } catch (e: Exception) {
- Log.e("PopupWindow", "Error unregistering battery receiver: ${e.message}")
- }
- }
- }
-
- private fun updateBatteryStatusFromList(batteryList: List) {
- val batteryLeftText = mView.findViewById(R.id.left_battery)
- val batteryRightText = mView.findViewById(R.id.right_battery)
- val batteryCaseText = mView.findViewById(R.id.case_battery)
-
- batteryLeftText.text = batteryList.find { it.component == BatteryComponent.LEFT }?.let {
- if (it.status != BatteryStatus.DISCONNECTED) {
- "\uDBC3\uDC8E ${it.level}%"
- } else {
- ""
- }
- } ?: ""
-
- batteryRightText.text = batteryList.find { it.component == BatteryComponent.RIGHT }?.let {
- if (it.status != BatteryStatus.DISCONNECTED) {
- "\uDBC3\uDC8D ${it.level}%"
- } else {
- ""
- }
- } ?: ""
-
- batteryCaseText.text = batteryList.find { it.component == BatteryComponent.CASE }?.let {
- if (it.status != BatteryStatus.DISCONNECTED) {
- "\uDBC3\uDE6C ${it.level}%"
- } else {
- ""
- }
- } ?: ""
- }
-
- @SuppressLint("SetTextI18s")
- fun updateBatteryStatus(batteryNotification: AirPodsNotifications.BatteryNotification) {
- val batteryStatus = batteryNotification.getBattery()
- updateBatteryStatusFromList(batteryStatus)
- }
-
- fun close() {
- try {
- if (isClosing) return
- isClosing = true
-
- autoCloseRunnable?.let { autoCloseHandler.removeCallbacks(it) }
- unregisterBatteryUpdateReceiver()
-
- val vid = mView.findViewById(R.id.video)
- vid.stopPlayback()
-
- ObjectAnimator.ofFloat(mView, "translationY", mView.height.toFloat()).apply {
- duration = 500
- interpolator = AccelerateInterpolator()
- addListener(object : AnimatorListenerAdapter() {
- override fun onAnimationEnd(animation: Animator) {
- try {
- mView.visibility = View.GONE
- if (mView.parent != null) {
- mWindowManager.removeView(mView)
- }
- } catch (e: Exception) {
- Log.e("PopupWindow", "Error removing view: ${e.message}")
- } finally {
- isClosing = false
- onCloseCallback()
- }
- }
- })
- start()
- }
- } catch (e: Exception) {
- Log.e("PopupWindow", "Error closing popup: ${e.message}")
- isClosing = false
- onCloseCallback()
- }
- }
-}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AccessibilitySettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AccessibilitySettingsScreen.kt
index 86d341939..6491b58b6 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AccessibilitySettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AccessibilitySettingsScreen.kt
@@ -21,58 +21,32 @@ package me.kavishdevar.librepods.presentation.screens
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint
import android.util.Log
-import androidx.compose.foundation.background
-import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
-import androidx.compose.foundation.gestures.detectTapGestures
-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.PaddingValues
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.rememberScrollState
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.verticalScroll
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.VolumeMute
+import androidx.compose.material.icons.automirrored.filled.VolumeUp
+import androidx.compose.material.icons.filled.HearingDisabled
+import androidx.compose.material.icons.filled.NotificationsActive
+import androidx.compose.material.icons.filled.SwipeVertical
+import androidx.compose.material.icons.filled.Timer
+import androidx.compose.material.icons.filled.TouchApp
+import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableLongStateOf
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.Color
-import androidx.compose.ui.hapticfeedback.HapticFeedbackType
-import androidx.compose.ui.input.pointer.PointerEventPass
-import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.layout.onGloballyPositioned
-import androidx.compose.ui.layout.positionInParent
-import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.platform.LocalHapticFeedback
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 com.kyant.backdrop.backdrops.layerBackdrop
-import com.kyant.backdrop.backdrops.rememberLayerBackdrop
-import dev.chrisbanes.haze.HazeState
-import dev.chrisbanes.haze.hazeSource
-import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -82,12 +56,13 @@ import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.data.Capability
-import me.kavishdevar.librepods.presentation.components.NavigationButton
-import me.kavishdevar.librepods.presentation.components.StyledButton
-import me.kavishdevar.librepods.presentation.components.StyledDropdown
-import me.kavishdevar.librepods.presentation.components.StyledScaffold
-import me.kavishdevar.librepods.presentation.components.StyledSlider
-import me.kavishdevar.librepods.presentation.components.StyledToggle
+import me.kavishdevar.librepods.presentation.m3.ExpressiveGroup
+import me.kavishdevar.librepods.presentation.m3.ExpressiveListItem
+import me.kavishdevar.librepods.presentation.m3.ExpressiveScreenScaffold
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSectionHeader
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSegmentedGroup
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSlider
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSwitchRow
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -96,15 +71,11 @@ private var toneVolumeDebounceJob: Job? = null
//private const val TAG = "AccessibilitySettings"
@SuppressLint("DefaultLocale")
-@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavController) {
val state by viewModel.uiState.collectAsState()
- val isDarkTheme = isSystemInDarkTheme()
- val textColor = if (isDarkTheme) Color.White else Color.Black
-
val hearingAidEnabled =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(
1
@@ -113,270 +84,245 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
0
)?.toInt() == 1
- val backdrop = rememberLayerBackdrop()
-
- StyledScaffold(
- title = stringResource(R.string.accessibility)
- ) { topPadding, hazeState, bottomPadding ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .hazeSource(hazeState)
- .layerBackdrop(backdrop)
- .verticalScroll(rememberScrollState())
- .padding(horizontal = 16.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- Spacer(modifier = Modifier.height(topPadding))
+ // Press-speed / press-and-hold options share a Byte -> label mapping.
+ val pressSpeedOptions = listOf(
+ 0.toByte() to stringResource(R.string.default_option),
+ 1.toByte() to stringResource(R.string.slower),
+ 2.toByte() to stringResource(R.string.slowest),
+ )
+ val pressAndHoldDurationOptions = listOf(
+ 0.toByte() to stringResource(R.string.default_option),
+ 1.toByte() to stringResource(R.string.slower),
+ 2.toByte() to stringResource(R.string.slowest),
+ )
+ val volumeSwipeSpeedOptions = listOf(
+ 1.toByte() to stringResource(R.string.default_option),
+ 2.toByte() to stringResource(R.string.longer),
+ 3.toByte() to stringResource(R.string.longest),
+ )
-// val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
-// val phoneEQEnabled = remember { mutableStateOf(false) }
-// val mediaEQEnabled = remember { mutableStateOf(false) }
+ val selectedPressSpeed =
+ state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL]?.getOrNull(0)
+ ?: 0.toByte()
+ val selectedPressAndHoldDuration =
+ state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull(0)
+ ?: 0.toByte()
+ val selectedVolumeSwipeSpeed =
+ state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL]?.getOrNull(0)
+ ?: 1.toByte()
- val pressSpeedOptions = mapOf(
- 0.toByte() to stringResource(R.string.default_option),
- 1.toByte() to stringResource(R.string.slower),
- 2.toByte() to stringResource(R.string.slowest)
- )
+ val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
+ val phoneEQEnabled = remember { mutableStateOf(false) }
+ val mediaEQEnabled = remember { mutableStateOf(false) }
- val selectedPressSpeedValue =
- state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL]?.getOrNull(
- 0
+ LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
+ phoneMediaDebounceJob?.cancel()
+ phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
+ delay(150)
+ try {
+ val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
+ val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
+ Log.d(
+ "AccessibilitySettingsScreen",
+ "Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})"
)
- var selectedPressSpeed by remember {
- mutableStateOf(
- pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]
+ viewModel.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
+ } catch (e: Exception) {
+ Log.w(
+ "AccessibilitySettingsScreen",
+ "Error sending phone/media EQ: ${e.message}"
)
}
+ }
+ }
- val pressAndHoldDurationOptions = mapOf(
- 0.toByte() to stringResource(R.string.default_option),
- 1.toByte() to stringResource(R.string.slower),
- 2.toByte() to stringResource(R.string.slowest)
- )
+ val toneVolumeValue =
+ state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(0)
+ ?.toFloat() ?: 75f
- val selectedPressAndHoldDurationValue =
- state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull(
- 0
- )
- var selectedPressAndHoldDuration by remember {
- mutableStateOf(
- pressAndHoldDurationOptions[selectedPressAndHoldDurationValue]
- ?: pressAndHoldDurationOptions[0]
- )
- }
+ val oneBudAncChecked =
+ state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull(0) ==
+ 0x01.toByte()
- val volumeSwipeSpeedOptions = mapOf(
- 1.toByte() to stringResource(R.string.default_option),
- 2.toByte() to stringResource(R.string.longer),
- 3.toByte() to stringResource(R.string.longest)
- )
- val selectedVolumeSwipeSpeedValue =
- state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL]?.getOrNull(
- 0
- )
- var selectedVolumeSwipeSpeed by remember {
- mutableStateOf(
- volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue]
- ?: volumeSwipeSpeedOptions[1]
- )
+ val showLoudSoundReduction =
+ state.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) && state.vendorIdHook
+ val showTransparencyCustomization = !hearingAidEnabled && state.vendorIdHook
+ val showVolumeSwipe = state.capabilities.contains(Capability.SWIPE_FOR_VOLUME)
+ val volumeSwipeEnabled =
+ state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE]?.getOrNull(0)
+ ?.toInt() == 0x01
+
+ ExpressiveScreenScaffold(
+ title = stringResource(R.string.accessibility),
+ onBack = { navController.popBackStack() },
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(
+ top = padding.calculateTopPadding(),
+ bottom = 120.dp,
+ start = 16.dp,
+ end = 16.dp,
+ ),
+ ) {
+ // --- Press behaviour ---
+ item(key = "press") {
+ ExpressiveSectionHeader(stringResource(R.string.press_speed))
+ ExpressiveGroup {
+ ExpressiveListItem(
+ title = stringResource(R.string.press_speed),
+ subtitle = stringResource(R.string.press_speed_description),
+ leadingIcon = Icons.Filled.TouchApp,
+ enabled = state.isPremium,
+ )
+ ExpressiveSegmentedGroup(
+ options = pressSpeedOptions.map { it.first },
+ selected = selectedPressSpeed,
+ onSelect = { value ->
+ viewModel.setControlCommandByte(
+ identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
+ value = value,
+ )
+ },
+ label = { byte -> pressSpeedOptions.first { it.first == byte }.second },
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
+ )
+ }
}
- val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
- val phoneEQEnabled = remember { mutableStateOf(false) }
- val mediaEQEnabled = remember { mutableStateOf(false) }
+ item(key = "press_hold") {
+ ExpressiveSectionHeader(stringResource(R.string.press_and_hold_duration))
+ ExpressiveGroup {
+ ExpressiveListItem(
+ title = stringResource(R.string.press_and_hold_duration),
+ subtitle = stringResource(R.string.press_and_hold_duration_description),
+ leadingIcon = Icons.Filled.Timer,
+ enabled = state.isPremium,
+ )
+ ExpressiveSegmentedGroup(
+ options = pressAndHoldDurationOptions.map { it.first },
+ selected = selectedPressAndHoldDuration,
+ onSelect = { value ->
+ viewModel.setControlCommandByte(
+ identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
+ value = value,
+ )
+ },
+ label = { byte -> pressAndHoldDurationOptions.first { it.first == byte }.second },
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
+ )
+ }
+ }
- LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
- phoneMediaDebounceJob?.cancel()
- phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
- delay(150)
- try {
- val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
- val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
- Log.d(
- "AccessibilitySettingsScreen",
- "Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})"
+ // --- Noise control & transparency ---
+ item(key = "noise") {
+ ExpressiveSectionHeader(stringResource(R.string.noise_control))
+ ExpressiveGroup {
+ ExpressiveSwitchRow(
+ title = stringResource(R.string.noise_cancellation_single_airpod),
+ subtitle = stringResource(R.string.noise_cancellation_single_airpod_description),
+ leadingIcon = Icons.Filled.HearingDisabled,
+ checked = oneBudAncChecked,
+ onCheckedChange = {
+ viewModel.setControlCommandBoolean(
+ AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it
+ )
+ },
+ enabled = state.isPremium,
+ )
+ if (showLoudSoundReduction) {
+ me.kavishdevar.librepods.presentation.m3.ExpressiveDivider()
+ ExpressiveSwitchRow(
+ title = stringResource(R.string.loud_sound_reduction),
+ subtitle = stringResource(R.string.loud_sound_reduction_description),
+ leadingIcon = Icons.Filled.HearingDisabled,
+ checked = state.loudSoundReductionEnabled,
+ onCheckedChange = {
+ viewModel.setATTCharacteristicValue(
+ ATTHandles.LOUD_SOUND_REDUCTION,
+ if (it) byteArrayOf(0x01) else byteArrayOf(0x00)
+ )
+ },
+ enabled = state.isPremium,
)
- viewModel.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
- } catch (e: Exception) {
- Log.w(
- "AccessibilitySettingsScreen",
- "Error sending phone/media EQ: ${e.message}"
+ }
+ if (showTransparencyCustomization) {
+ me.kavishdevar.librepods.presentation.m3.ExpressiveDivider()
+ ExpressiveListItem(
+ title = stringResource(R.string.customize_transparency_mode),
+ leadingIcon = Icons.Filled.Tune,
+ showChevron = true,
+ enabled = state.isPremium,
+ onClick = { navController.navigate("transparency_customization") },
)
}
}
}
- Box(
- modifier = Modifier.then(
- if (!state.isPremium) {
- Modifier.pointerInput(Unit) {
- awaitPointerEventScope {
- while (true) {
- val event = awaitPointerEvent(PointerEventPass.Initial)
- event.changes.forEach { it.consume() }
- }
- }
- }
- } else Modifier)) {
- DropdownMenuComponent(
- label = stringResource(R.string.press_speed),
- description = stringResource(R.string.press_speed_description),
- options = pressSpeedOptions.values.toList(),
- selectedOption = selectedPressSpeed ?: stringResource(R.string.default_option),
- onOptionSelected = { newValue ->
- selectedPressSpeed = newValue
- viewModel.setControlCommandByte(
- identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
- value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
- ?: 0.toByte()
- )
- },
- textColor = textColor,
- hazeState = hazeState,
- independent = true
- )
- }
- Box(
- modifier = Modifier.then(
- if (!state.isPremium) {
- Modifier.pointerInput(Unit) {
- awaitPointerEventScope {
- while (true) {
- val event = awaitPointerEvent(PointerEventPass.Initial)
- event.changes.forEach { it.consume() }
- }
- }
+ // --- Tone volume ---
+ item(key = "tone") {
+ ExpressiveSectionHeader(stringResource(R.string.tone_volume))
+ ExpressiveGroup {
+ ExpressiveListItem(
+ title = stringResource(R.string.tone_volume),
+ subtitle = stringResource(R.string.tone_volume_description),
+ leadingIcon = Icons.Filled.NotificationsActive,
+ enabled = state.isPremium,
+ )
+ ExpressiveSlider(
+ value = toneVolumeValue,
+ onValueChange = {
+ viewModel.setControlCommandValue(
+ AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME,
+ byteArrayOf(it.toInt().toByte(), 0x50)
+ )
+ },
+ valueRange = 0f..100f,
+ startIcon = Icons.AutoMirrored.Filled.VolumeMute,
+ endIcon = Icons.AutoMirrored.Filled.VolumeUp,
+ enabled = state.isPremium,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ )
}
- } else Modifier)) {
- DropdownMenuComponent(
- label = stringResource(R.string.press_and_hold_duration),
- description = stringResource(R.string.press_and_hold_duration_description),
- options = pressAndHoldDurationOptions.values.toList(),
- selectedOption = selectedPressAndHoldDuration
- ?: stringResource(R.string.default_option),
- onOptionSelected = { newValue ->
- selectedPressAndHoldDuration = newValue
- viewModel.setControlCommandByte(
- identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
- value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull()
- ?: 0.toByte()
- )
- },
- textColor = textColor,
- hazeState = hazeState,
- independent = true
- )
}
- StyledToggle(
- title = stringResource(R.string.noise_control),
- label = stringResource(R.string.noise_cancellation_single_airpod),
- description = stringResource(R.string.noise_cancellation_single_airpod_description),
- independent = true,
- checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull(
- 0
- ) == 0x01.toByte(),
- onCheckedChange = {
- viewModel.setControlCommandBoolean(
- AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it
- )
- },
- enabled = state.isPremium
- )
- if (state.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) && state.vendorIdHook) {
- StyledToggle(
- label = stringResource(R.string.loud_sound_reduction),
- description = stringResource(R.string.loud_sound_reduction_description),
- checked = state.loudSoundReductionEnabled,
- onCheckedChange = {
- viewModel.setATTCharacteristicValue(
- ATTHandles.LOUD_SOUND_REDUCTION,
- if (it) byteArrayOf(0x01) else byteArrayOf(0x00)
+ // --- Volume swipe ---
+ if (showVolumeSwipe) {
+ item(key = "volume_swipe") {
+ ExpressiveSectionHeader(stringResource(R.string.volume_control))
+ ExpressiveGroup {
+ ExpressiveSwitchRow(
+ title = stringResource(R.string.volume_control),
+ subtitle = stringResource(R.string.volume_control_description),
+ leadingIcon = Icons.Filled.SwipeVertical,
+ checked = volumeSwipeEnabled,
+ onCheckedChange = {
+ viewModel.setControlCommandBoolean(
+ AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, it
+ )
+ },
+ enabled = state.isPremium,
)
- },
- enabled = state.isPremium
- )
- }
-
- if (!hearingAidEnabled && state.vendorIdHook) {
- NavigationButton(
- to = "transparency_customization",
- name = stringResource(R.string.customize_transparency_mode),
- navController = navController,
- enabled = state.isPremium
- )
- }
-
- val toneVolumeValue =
- state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(
- 0
- )?.toFloat() ?: 75f
- StyledSlider(
- label = stringResource(R.string.tone_volume),
- description = stringResource(R.string.tone_volume_description),
- value = toneVolumeValue,
- onValueChange = {
- viewModel.setControlCommandValue(
- AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME,
- byteArrayOf(it.toInt().toByte(), 0x50)
- )
- },
- valueRange = 0f..100f,
- snapPoints = listOf(75f),
- startIcon = "\uDBC0\uDEA1",
- endIcon = "\uDBC0\uDEA9",
- independent = true,
- enabled = state.isPremium
- )
-
- if (state.capabilities.contains(Capability.SWIPE_FOR_VOLUME)) {
- val volumeSwipeEnabled =
- state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE]?.getOrNull(
- 0
- )?.toInt() == 0x01
- StyledToggle(
- label = stringResource(R.string.volume_control),
- description = stringResource(R.string.volume_control_description),
- checked = volumeSwipeEnabled,
- onCheckedChange = {
- viewModel.setControlCommandBoolean(
- AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, it
+ me.kavishdevar.librepods.presentation.m3.ExpressiveDivider()
+ ExpressiveListItem(
+ title = stringResource(R.string.volume_swipe_speed),
+ subtitle = stringResource(R.string.volume_swipe_speed_description),
+ leadingIcon = Icons.Filled.Timer,
+ enabled = state.isPremium,
+ )
+ ExpressiveSegmentedGroup(
+ options = volumeSwipeSpeedOptions.map { it.first },
+ selected = selectedVolumeSwipeSpeed,
+ onSelect = { value ->
+ viewModel.setControlCommandByte(
+ identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
+ value = value,
+ )
+ },
+ label = { byte -> volumeSwipeSpeedOptions.first { it.first == byte }.second },
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
)
- },
- enabled = state.isPremium
- )
-
- Box(
- modifier = Modifier.then(
- if (!state.isPremium) {
- Modifier.pointerInput(Unit) {
- awaitPointerEventScope {
- while (true) {
- val event = awaitPointerEvent(PointerEventPass.Initial)
- event.changes.forEach { it.consume() }
- }
- }
}
- } else Modifier)) {
- DropdownMenuComponent(
- label = stringResource(R.string.volume_swipe_speed),
- description = stringResource(R.string.volume_swipe_speed_description),
- options = volumeSwipeSpeedOptions.values.toList(),
- selectedOption = selectedVolumeSwipeSpeed
- ?: stringResource(R.string.default_option),
- onOptionSelected = { newValue ->
- selectedVolumeSwipeSpeed = newValue
- viewModel.setControlCommandByte(
- identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
- value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull()
- ?: 1.toByte()
- )
- },
- textColor = textColor,
- hazeState = hazeState,
- independent = true
- )
}
}
@@ -613,210 +559,6 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
// }
// }
// }
- Spacer(modifier = Modifier.height(bottomPadding))
- }
- }
-}
-
-@ExperimentalHazeMaterialsApi
-@Composable
-private fun DropdownMenuComponent(
- label: String,
- options: List,
- selectedOption: String,
- onOptionSelected: (String) -> Unit,
- textColor: Color,
- hazeState: HazeState,
- description: String? = null,
- independent: Boolean = true
-) {
- val density = LocalDensity.current
- val itemHeightPx = with(density) { 48.dp.toPx() }
-
- var expanded by remember { mutableStateOf(false) }
- var touchOffset by remember { mutableStateOf(null) }
- var boxPosition by remember { mutableStateOf(Offset.Zero) }
- var lastDismissTime by remember { mutableLongStateOf(0L) }
- var parentHoveredIndex by remember { mutableStateOf(null) }
- var parentDragActive by remember { mutableStateOf(false) }
- var previousIdx by remember { mutableStateOf(null) }
- val haptics = LocalHapticFeedback.current
- val scope = rememberCoroutineScope()
-
- Column(modifier = Modifier.fillMaxWidth()) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .then(
- if (independent) {
- if (description != null) {
- Modifier.padding(top = 8.dp, bottom = 4.dp)
- } else {
- Modifier.padding(vertical = 8.dp)
- }
- } else Modifier
- )
- .background(
- if (independent) (if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(
- 0xFFFFFFFF
- )) else Color.Transparent,
- if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp)
- ) then (if (independent) Modifier.padding(horizontal = 4.dp) else Modifier).clip(
- if (independent) RoundedCornerShape(28.dp) else RoundedCornerShape(0.dp)
- )
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(start = 12.dp, end = 12.dp)
- .height(58.dp)
- .pointerInput(Unit) {
- detectTapGestures { offset ->
- val now = System.currentTimeMillis()
- if (expanded) {
- expanded = false
- lastDismissTime = now
- } else {
- if (now - lastDismissTime > 250L) {
- touchOffset = offset
- expanded = true
- }
- }
- }
- }
- .pointerInput(Unit) {
- detectDragGesturesAfterLongPress(onDragStart = { offset ->
- val now = System.currentTimeMillis()
- touchOffset = offset
- if (!expanded && now - lastDismissTime > 250L) {
- expanded = true
- }
- lastDismissTime = now
- parentDragActive = true
- parentHoveredIndex = 0
- }, onDrag = { change, _ ->
- val current = change.position
- val touch = touchOffset ?: current
- val posInPopupY = current.y - touch.y
- val idx = (posInPopupY / itemHeightPx).toInt()
- if (idx != previousIdx) {
- scope.launch {
- haptics.performHapticFeedback(
- HapticFeedbackType.SegmentTick
- )
- }
- }
- parentHoveredIndex = idx
- previousIdx = idx
- }, onDragEnd = {
- parentDragActive = false
- parentHoveredIndex?.let { idx ->
- if (idx in options.indices) {
- onOptionSelected(options[idx])
- expanded = false
- lastDismissTime = System.currentTimeMillis()
- }
- }
- if (parentHoveredIndex != null && parentHoveredIndex in options.indices) {
- scope.launch {
- haptics.performHapticFeedback(
- HapticFeedbackType.GestureEnd
- )
- }
- }
- parentHoveredIndex = null
- }, onDragCancel = {
- parentDragActive = false
- parentHoveredIndex = null
- })
- },
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- Column(
- modifier = Modifier.weight(1f)
- ) {
- Text(
- text = label,
- fontSize = 16.sp,
- color = textColor,
- modifier = Modifier.padding(bottom = 4.dp)
- )
- if (!independent && description != null) {
- Text(
- text = description, style = TextStyle(
- fontSize = 12.sp,
- fontWeight = FontWeight.Light,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ), modifier = Modifier.padding(16.dp, top = 0.dp, bottom = 2.dp)
- )
- }
- }
- Box(
- modifier = Modifier.onGloballyPositioned { coordinates ->
- boxPosition = coordinates.positionInParent()
- }) {
- Row(
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = selectedOption, style = TextStyle(
- fontSize = 16.sp,
- color = textColor.copy(alpha = 0.8f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- Text(
- text = "", style = TextStyle(
- fontSize = 16.sp,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ), modifier = Modifier.padding(start = 6.dp)
- )
- }
-
- StyledDropdown(
- expanded = expanded,
- onDismissRequest = {
- expanded = false
- lastDismissTime = System.currentTimeMillis()
- },
- options = options,
- selectedOption = selectedOption,
- touchOffset = touchOffset,
- boxPosition = boxPosition,
- externalHoveredIndex = parentHoveredIndex,
- externalDragActive = parentDragActive,
- onOptionSelected = { option ->
- onOptionSelected(option)
- expanded = false
- },
- hazeState = hazeState
- )
- }
- }
- }
- if (independent && description != null) {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.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))
- )
- )
- }
}
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AdaptiveStrengthScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AdaptiveStrengthScreen.kt
index 53a7370a1..a04662668 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AdaptiveStrengthScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AdaptiveStrengthScreen.kt
@@ -18,14 +18,20 @@
package me.kavishdevar.librepods.presentation.screens
-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.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.VolumeMute
+import androidx.compose.material.icons.filled.VolumeUp
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -36,70 +42,98 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
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 com.kyant.backdrop.backdrops.layerBackdrop
-import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
-import me.kavishdevar.librepods.presentation.components.StyledButton
-import me.kavishdevar.librepods.presentation.components.StyledScaffold
-import me.kavishdevar.librepods.presentation.components.StyledSlider
+import me.kavishdevar.librepods.presentation.m3.ExpressiveGroup
+import me.kavishdevar.librepods.presentation.m3.ExpressiveScreenScaffold
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSectionHeader
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSlider
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navController: NavController) {
val state by viewModel.uiState.collectAsState()
- val backdrop = rememberLayerBackdrop()
- StyledScaffold(
- title = stringResource(R.string.customize_adaptive_audio)
- ) { spacerHeight ->
+ val sliderValue = remember {
+ mutableFloatStateOf(
+ 100f - (state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH]
+ ?.getOrNull(0)?.toFloat() ?: 50f)
+ )
+ }
+ var job by remember { mutableStateOf(null) }
+ val scope = rememberCoroutineScope()
+
+ ExpressiveScreenScaffold(
+ title = stringResource(R.string.customize_adaptive_audio),
+ onBack = { navController.popBackStack() },
+ ) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
- .layerBackdrop(backdrop)
- .padding(horizontal = 16.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp)
+ .verticalScroll(rememberScrollState())
+ .padding(
+ top = padding.calculateTopPadding(),
+ start = 16.dp,
+ end = 16.dp,
+ ),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
) {
- Spacer(modifier = Modifier.height(spacerHeight))
- val sliderValue = remember {
- mutableFloatStateOf(100f - (state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH]?.getOrNull(0)?.toFloat() ?: 50f))
- }
- var job by remember { mutableStateOf(null) }
- val scope = rememberCoroutineScope()
- StyledSlider(
- label = stringResource(R.string.customize_adaptive_audio),
- value = sliderValue.floatValue,
- onValueChange = {
- sliderValue.floatValue = it
- job?.cancel()
- job = scope.launch {
- delay(150)
- viewModel.setControlCommandValue(
- AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
- byteArrayOf((100 - it).toInt().toByte())
+ ExpressiveSectionHeader(stringResource(R.string.customize_adaptive_audio))
+ ExpressiveGroup {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp, vertical = 20.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ ExpressiveSlider(
+ value = sliderValue.floatValue,
+ onValueChange = {
+ sliderValue.floatValue = it
+ job?.cancel()
+ job = scope.launch {
+ delay(150)
+ viewModel.setControlCommandValue(
+ AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
+ byteArrayOf((100 - it).toInt().toByte())
+ )
+ }
+ },
+ valueRange = 0f..100f,
+ startIcon = Icons.Filled.VolumeMute,
+ endIcon = Icons.Filled.VolumeUp,
+ enabled = state.isPremium,
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ text = "More noise",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = "Less noise",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
- },
- valueRange = 0f..100f,
- snapPoints = listOf(0f, 50f, 100f),
- startIcon = "",
- endIcon = "",
- independent = true,
- description = stringResource(R.string.adaptive_audio_description),
- enabled = state.isPremium
+ }
+ }
+ Text(
+ text = stringResource(R.string.adaptive_audio_description),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 8.dp),
)
+ Spacer(Modifier.height(120.dp))
}
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt
deleted file mode 100644
index 0c124c7f5..000000000
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AirPodsSettingsScreen.kt
+++ /dev/null
@@ -1,554 +0,0 @@
-/*
- LibrePods - AirPods liberated from Apple’s ecosystem
- Copyright (C) 2025 LibrePods contributors
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License
- along with this program. If not, see .
-*/
-
-@file:OptIn(ExperimentalEncodingApi::class)
-
-package me.kavishdevar.librepods.presentation.screens
-
-// import me.kavishdevar.librepods.utils.RadareOffsetFinder
-import android.annotation.SuppressLint
-import android.content.Context.MODE_PRIVATE
-import android.content.Intent
-import android.content.SharedPreferences
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.gestures.detectTapGestures
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.heightIn
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.SnackbarHostState
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.getValue
-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.clip
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.input.pointer.PointerEventPass
-import androidx.compose.ui.input.pointer.pointerInput
-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.text.input.TextFieldValue
-import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import androidx.core.net.toUri
-import androidx.navigation.NavController
-import com.kyant.backdrop.backdrops.rememberLayerBackdrop
-import com.kyant.backdrop.drawBackdrop
-import com.kyant.backdrop.highlight.Highlight
-import dev.chrisbanes.haze.HazeState
-import dev.chrisbanes.haze.hazeSource
-import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
-import kotlinx.coroutines.delay
-import me.kavishdevar.librepods.BuildConfig
-import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.bluetooth.AACPManager
-import me.kavishdevar.librepods.bluetooth.ATTHandles
-import me.kavishdevar.librepods.data.AirPodsPro3
-import me.kavishdevar.librepods.data.Capability
-import me.kavishdevar.librepods.presentation.components.AboutCard
-import me.kavishdevar.librepods.presentation.components.AudioSettings
-import me.kavishdevar.librepods.presentation.components.BatteryView
-import me.kavishdevar.librepods.presentation.components.CallControlSettings
-import me.kavishdevar.librepods.presentation.components.ConnectionSettings
-import me.kavishdevar.librepods.presentation.components.HearingHealthSettings
-import me.kavishdevar.librepods.presentation.components.MicrophoneSettings
-import me.kavishdevar.librepods.presentation.components.NavigationButton
-import me.kavishdevar.librepods.presentation.components.NoiseControlSettings
-import me.kavishdevar.librepods.presentation.components.PressAndHoldSettings
-import me.kavishdevar.librepods.presentation.components.StyledButton
-import me.kavishdevar.librepods.presentation.components.StyledIconButton
-import me.kavishdevar.librepods.presentation.components.StyledScaffold
-import me.kavishdevar.librepods.presentation.components.StyledToggle
-import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
-import java.util.concurrent.TimeUnit
-import kotlin.io.encoding.ExperimentalEncodingApi
-
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
-@SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
-@Composable
-fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavController) {
- val state by viewModel.uiState.collectAsState()
- val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
- var deviceName by remember {
- mutableStateOf(
- TextFieldValue(
- sharedPreferences.getString("name", state.deviceName).toString()
- )
- )
- }
-
- val nameChangeListener = remember {
- SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
- if (key == "name") {
- deviceName =
- TextFieldValue(sharedPreferences.getString("name", "AirPods Pro").toString())
- }
- }
- }
-
- DisposableEffect(Unit) {
- sharedPreferences.registerOnSharedPreferenceChangeListener(nameChangeListener)
- onDispose {
- sharedPreferences.unregisterOnSharedPreferenceChangeListener(nameChangeListener)
- }
- }
-
- val snackbarHostState = remember { SnackbarHostState() }
-
- LaunchedEffect(Unit) {
- viewModel.refreshInitialData()
- }
-
- val hazeStateS = remember { mutableStateOf(HazeState()) }
-
- StyledScaffold(
- title = deviceName.text, actionButtons = listOf(
- { scaffoldBackdrop ->
- StyledIconButton(
- onClick = { navController.navigate("app_settings") },
- icon = "",
- backdrop = scaffoldBackdrop
- )
- }), snackbarHostState = snackbarHostState
- ) { topPadding, hazeState, bottomPadding ->
- hazeStateS.value = hazeState
- var blockTouches by remember { mutableStateOf(false) }
-
- LaunchedEffect(Unit) {
- viewModel.demoActivated.collect {
- blockTouches = true
- delay(1000)
- blockTouches = false
- }
- }
-
- if (state.isLocallyConnected) {
- val capabilities = state.capabilities
- LazyColumn(
- modifier = Modifier
- .fillMaxSize()
- .hazeSource(hazeState)
- .padding(horizontal = 16.dp)
- .then(if (blockTouches) Modifier.pointerInput(Unit) {
- awaitPointerEventScope {
- while (true) {
- val event = awaitPointerEvent(PointerEventPass.Initial)
- event.changes.forEach { it.consume() }
- }
- }
- } else Modifier)) {
- item(key = "spacer_top") { Spacer(modifier = Modifier.height(topPadding)) }
-
- item(key = "battery") {
- BatteryView(
- batteryList = state.battery,
- budsRes = state.instance?.model?.budsRes ?: R.drawable.airpods_pro_2_case,
- caseRes = state.instance?.model?.caseRes ?: R.drawable.airpods_pro_2_case
- )
- }
- item(key = "spacer_battery") { Spacer(modifier = Modifier.height(32.dp)) }
-
- item(key = "name") {
- NavigationButton(
- to = "rename",
- name = stringResource(R.string.name),
- currentState = deviceName.text,
- navController = navController,
- independent = true
- )
- }
-
- val hasHearingAidCapability =
- state.instance?.model?.capabilities?.contains(Capability.HEARING_AID) == true
- val hasPPECapability =
- state.instance?.model?.capabilities?.contains(Capability.PPE) == true
-
- if (hasHearingAidCapability || hasPPECapability) {
- if (hasPPECapability || (state.vendorIdHook && hasHearingAidCapability)) item(
- key = "spacer_hearing_health"
- ) { Spacer(modifier = Modifier.height(24.dp)) }
- item(key = "hearing_health") {
- HearingHealthSettings(
- navController = navController,
- hasPPECapability = hasPPECapability,
- hasHearingAidCapability = hasHearingAidCapability,
- vendorIdHook = state.vendorIdHook
- )
- }
- }
-
- if (capabilities.contains(Capability.LISTENING_MODE)) {
- item(key = "spacer_noise") { Spacer(modifier = Modifier.height(16.dp)) }
- item(key = "noise_control") {
- NoiseControlSettings(
- showOffListeningMode = state.offListeningMode,
- noiseControlModeValue = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE]?.getOrNull(
- 0
- )?.toInt() ?: 3,
- onNoiseControlModeChanged = {
- viewModel.setControlCommandInt(
- AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE,
- it
- )
- },
- )
- }
- }
-
- if (capabilities.contains(Capability.STEM_CONFIG)) {
- item(key = "spacer_press_hold") { Spacer(modifier = Modifier.height(16.dp)) }
- item(key = "press_hold") {
- PressAndHoldSettings(
- navController = navController,
- leftAction = state.leftAction,
- rightAction = state.rightAction
- )
- }
- }
-
- item(key = "spacer_call") { Spacer(modifier = Modifier.height(16.dp)) }
- item(key = "call_control") {
- val bytes = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG]?.take(2)?.toByteArray() ?: byteArrayOf(0x00, 0x00)
- val flipped = try { bytes[1] == 0x02.toByte() } catch (e: Exception) { false }
- CallControlSettings(
- hazeState = hazeState,
- flipped = flipped,
- onCallControlValueChanged = {
- viewModel.setControlCommandValue(
- AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
- if (it) byteArrayOf(0x00, 0x02) else byteArrayOf(0x00, 0x03)
- )
- })
- }
-
-// if (capabilities.contains(Capability.STEM_CONFIG) && !BuildConfig.PLAY_BUILD) {
-// item(key = "spacer_camera") { Spacer(modifier = Modifier.height(16.dp)) }
-// item(key = "camera_control") {
-// NavigationButton(
-// to = "camera_control",
-// name = stringResource(R.string.camera_remote),
-// description = stringResource(R.string.camera_control_description),
-// title = stringResource(R.string.camera_control),
-// navController = navController
-// )
-// }
-// }
-
- item(key = "spacer_audio") { Spacer(modifier = Modifier.height(16.dp)) }
- item(key = "audio") {
- val model = state.instance?.model ?: AirPodsPro3()
- val adaptiveVolumeCapability =
- model.capabilities.contains(Capability.ADAPTIVE_VOLUME)
- val conversationalAwarenessCapability =
- model.capabilities.contains(Capability.CONVERSATION_AWARENESS)
- val loudSoundReductionCapability =
- model.capabilities.contains(Capability.LOUD_SOUND_REDUCTION)
- val adaptiveAudioCapability =
- model.capabilities.contains(Capability.ADAPTIVE_VOLUME)
-
- val adaptiveVolumeChecked =
- state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG]?.getOrNull(
- 0
- ) == 0x01.toByte()
- val conversationalAwarenessChecked =
- state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG]?.getOrNull(
- 0
- ) == 0x01.toByte()
-
- AudioSettings(
- navController = navController,
- adaptiveVolumeCapability = adaptiveVolumeCapability,
- conversationalAwarenessCapability = conversationalAwarenessCapability,
- loudSoundReductionCapability = loudSoundReductionCapability,
- adaptiveAudioCapability = adaptiveAudioCapability,
- adaptiveVolumeChecked = adaptiveVolumeChecked,
- onAdaptiveVolumeCheckedChange = { checked ->
- viewModel.setControlCommandBoolean(
- AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
- checked
- )
- },
- conversationalAwarenessChecked = conversationalAwarenessChecked && state.isPremium,
- onConversationalAwarenessCheckedChange = { checked ->
- viewModel.setControlCommandBoolean(
- AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
- checked
- )
- },
- loudSoundReductionChecked = state.loudSoundReductionEnabled,
- onLoudSoundReductionCheckedChange = {
- viewModel.setATTCharacteristicValue(
- ATTHandles.LOUD_SOUND_REDUCTION,
- byteArrayOf(if (it) 0x01.toByte() else 0x00.toByte())
- )
- },
- vendorIdHook = state.vendorIdHook,
- isPremium = state.isPremium
- )
- }
-
- item(key = "spacer_connection") { Spacer(modifier = Modifier.height(16.dp)) }
- item(key = "connection") {
- ConnectionSettings(
- automaticEarDetectionEnabled = state.automaticEarDetectionEnabled,
- onAutomaticEarDetectionChanged = {
- viewModel.setAutomaticEarDetectionEnabled(it)
- },
- automaticConnectionEnabled = state.automaticConnectionEnabled,
- onAutomaticConnectionChanged = { viewModel.setAutomaticConnectionEnabled(it) })
- }
-
- item(key = "spacer_microphone") { Spacer(modifier = Modifier.height(16.dp)) }
- item(key = "microphone") {
- val id = AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE
- MicrophoneSettings(
- hazeState = hazeState,
- micModeValue = state.controlStates[id]?.getOrNull(0) ?: 0x00.toByte(),
- onMicModeValueChanged = { viewModel.setControlCommandByte(id, it) })
- }
-
- if (capabilities.contains(Capability.SLEEP_DETECTION)) {
- item(key = "spacer_sleep") { Spacer(modifier = Modifier.height(16.dp)) }
- item(key = "sleep_detection") {
- val id =
- AACPManager.Companion.ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG
- StyledToggle(
- label = stringResource(R.string.sleep_detection),
- checked = state.controlStates[id]?.getOrNull(0) == 0x01.toByte(),
- onCheckedChange = {
- viewModel.setControlCommandBoolean(id, it)
- },
- enabled = state.isPremium
- )
- }
- }
-
- if (capabilities.contains(Capability.HEAD_GESTURES)) {
- item(key = "spacer_head_tracking") { Spacer(modifier = Modifier.height(16.dp)) }
- item(key = "head_tracking") {
- NavigationButton(
- to = "head_tracking",
- name = stringResource(R.string.head_gestures),
- navController = navController,
- currentState = if (sharedPreferences.getBoolean(
- "head_gestures", false
- )
- ) stringResource(R.string.on) else stringResource(R.string.off)
- )
- }
- }
-
- item(key = "spacer_dynamic_end_of_charge") { Spacer(modifier = Modifier.height(16.dp)) }
- item(key = "dynamic_end_of_charge") {
- StyledToggle(
- label = stringResource(R.string.optimized_charging),
- description = stringResource(R.string.optimized_charging_description),
- checked = state.dynamicEndOfCharge,
- onCheckedChange = viewModel::setDynamicEndOfCharge
- )
- }
-
- item(key = "spacer_accessibility") { Spacer(modifier = Modifier.height(16.dp)) }
- item(key = "accessibility") {
- NavigationButton(
- to = "accessibility",
- name = stringResource(R.string.accessibility),
- navController = navController
- )
- }
-
- if (capabilities.contains(Capability.LOUD_SOUND_REDUCTION)) {
- item(key = "spacer_off_listening") { Spacer(modifier = Modifier.height(16.dp)) }
- item(key = "off_listening") {
- val id = AACPManager.Companion.ControlCommandIdentifiers.ALLOW_OFF_OPTION
- StyledToggle(
- label = stringResource(R.string.off_listening_mode),
- description = stringResource(R.string.off_listening_mode_description),
- checked = state.controlStates[id]?.getOrNull(0) == 0x01.toByte(),
- onCheckedChange = viewModel::setOffListeningMode
- )
- }
- }
-
- item(key = "spacer_about") { Spacer(modifier = Modifier.height(32.dp)) }
- item(key = "about") {
- AboutCard(
- navController = navController,
- modelName = state.modelName,
- actualModel = state.actualModel,
- serialNumbers = state.serialNumbers,
- version = state.version3,
- )
- }
-
- item(key = "spacer_disconnect") { Spacer(modifier = Modifier.height(28.dp)) }
- item(key = "disconnect_button") {
- StyledButton(
- onClick = viewModel::disconnect,
- backdrop = rememberLayerBackdrop(),
- isInteractive = false,
- modifier = Modifier
- .fillMaxWidth()
- .heightIn(min = 56.dp)
- ) {
- Text(
- text = stringResource(R.string.disconnect),
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Normal,
- color = if (isSystemInDarkTheme()) Color(0xFF0091FF) else Color(0xFF0088FF),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- textAlign = TextAlign.Start,
- modifier = Modifier.fillMaxWidth()
- )
- }
- }
-
-// item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) }
-// item(key = "debug") { NavigationButton("debug", "Debug", navController) }
- item(key = "spacer_bottom") { Spacer(Modifier.height(bottomPadding)) }
- }
- } else {
- val backdrop = rememberLayerBackdrop()
- Column(
- modifier = Modifier
- .fillMaxSize()
- .drawBackdrop(
- backdrop = rememberLayerBackdrop(),
- exportedBackdrop = backdrop,
- shape = { RoundedCornerShape(0.dp) },
- highlight = {
- Highlight.Ambient.copy(alpha = 0f)
- },
- effects = {})
- .hazeSource(hazeState)
- .padding(horizontal = 8.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center
- ) {
- val tapCount = remember { mutableIntStateOf(0) }
- val lastTapTime = remember { mutableLongStateOf(0L) }
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .pointerInput(Unit) {
- detectTapGestures(
- onTap = {
- val now = System.currentTimeMillis()
-
- if (now - lastTapTime.longValue > 400) {
- tapCount.intValue = 0
- }
-
- tapCount.intValue++
- lastTapTime.longValue = now
-
- if (tapCount.intValue >= 5) {
- tapCount.intValue = 0
- viewModel.activateDemoMode()
- }
- })
- }) {
- Text(
- text = stringResource(R.string.airpods_not_connected), style = TextStyle(
- fontSize = 24.sp,
- fontWeight = FontWeight.Medium,
- color = if (isSystemInDarkTheme()) Color.White else Color.Black,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ), textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth()
- )
- Spacer(Modifier.height(24.dp))
- Text(
- text = stringResource(R.string.airpods_not_connected_description),
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Light,
- color = if (isSystemInDarkTheme()) Color.White else Color.Black,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- textAlign = TextAlign.Center,
- modifier = Modifier.fillMaxWidth(),
- )
- }
-
- Spacer(Modifier.height(32.dp))
- if (!BuildConfig.PLAY_BUILD) {
- StyledButton(
- onClick = { navController.navigate("troubleshooting") },
- backdrop = backdrop,
- modifier = Modifier
- .fillMaxWidth(0.9f)
- ) {
- Text(
- text = stringResource(R.string.troubleshooting),
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Medium,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- color = if (isSystemInDarkTheme()) Color.White else Color.Black
- )
- )
- }
- Spacer(Modifier.height(16.dp))
- }
- if (state.connectionSuccessful) {
- StyledButton(
- onClick = {
- viewModel.reconnectFromSavedMac()
- }, backdrop = backdrop, modifier = Modifier.fillMaxWidth(0.9f)
- ) {
- Text(
- text = stringResource(R.string.reconnect_to_last_device),
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Medium,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- color = if (isSystemInDarkTheme()) Color.White else Color.Black
- )
- )
- }
- }
- }
- }
- }
-}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt
index 80fb78232..47475f2c5 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/AppSettingsScreen.kt
@@ -16,6 +16,8 @@
along with this program. If not, see .
*/
+@file:OptIn(ExperimentalMaterial3Api::class)
+
package me.kavishdevar.librepods.presentation.screens
import android.content.Intent
@@ -24,75 +26,72 @@ import android.net.Uri
import android.os.Build
import android.widget.Toast
import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-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.PaddingValues
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.rememberScrollState
+import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.foundation.text.input.TextFieldState
-import androidx.compose.foundation.text.input.clearText
-import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Chat
+import androidx.compose.material.icons.filled.BugReport
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Description
+import androidx.compose.material.icons.filled.Email
+import androidx.compose.material.icons.filled.Hearing
+import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.RecordVoiceOver
+import androidx.compose.material.icons.filled.SwapHoriz
+import androidx.compose.material.icons.filled.Tune
+import androidx.compose.material.icons.filled.VolumeUp
+import androidx.compose.material.icons.filled.Widgets
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
-import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.ui.Alignment
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
-import androidx.compose.ui.focus.FocusRequester
-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.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
-import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.lerp
-import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
-import com.kyant.backdrop.backdrops.layerBackdrop
-import com.kyant.backdrop.backdrops.rememberLayerBackdrop
-import dev.chrisbanes.haze.hazeSource
import me.kavishdevar.librepods.BuildConfig
import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.presentation.components.AppInfoCard
-import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
-import me.kavishdevar.librepods.presentation.components.NavigationButton
-import me.kavishdevar.librepods.presentation.components.StyledBottomSheet
-import me.kavishdevar.librepods.presentation.components.StyledButton
-import me.kavishdevar.librepods.presentation.components.StyledIconButton
-import me.kavishdevar.librepods.presentation.components.StyledInputField
-import me.kavishdevar.librepods.presentation.components.StyledScaffold
-import me.kavishdevar.librepods.presentation.components.StyledSlider
-import me.kavishdevar.librepods.presentation.components.StyledToggle
+import me.kavishdevar.librepods.presentation.m3.ExpressiveActionGroup
+import me.kavishdevar.librepods.presentation.m3.ExpressiveAction
+import me.kavishdevar.librepods.presentation.m3.ExpressiveDivider
+import me.kavishdevar.librepods.presentation.m3.ExpressiveGroup
+import me.kavishdevar.librepods.presentation.m3.ExpressiveListItem
+import me.kavishdevar.librepods.presentation.m3.ExpressiveScreenScaffold
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSectionHeader
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSlider
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSwitchRow
+import me.kavishdevar.librepods.presentation.m3.viz.AncVisualizer
+import me.kavishdevar.librepods.presentation.m3.viz.AncVizPrefs
+import me.kavishdevar.librepods.presentation.m3.viz.AncVizStyle
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
import me.kavishdevar.librepods.utils.XposedState
-import java.util.concurrent.TimeUnit
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -100,624 +99,516 @@ fun AppSettingsScreen(
navController: NavController, viewModel: AppSettingsViewModel = viewModel()
) {
val context = LocalContext.current
- val scrollState = rememberScrollState()
val state by viewModel.uiState.collectAsState()
- val backdrop = rememberLayerBackdrop()
-
- val contactBottomSheet = remember { mutableStateOf(false) }
- val subjectState = remember { TextFieldState() }
- val descriptionState = remember { TextFieldState() }
- val subjectFocusRequester = remember { FocusRequester() }
- val descriptionFocusRequester = remember { FocusRequester() }
-
- StyledScaffold(
- title = stringResource(R.string.settings)
- ) { topPadding, hazeState, bottomPadding ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .layerBackdrop(backdrop)
- .hazeSource(state = hazeState)
- .verticalScroll(scrollState)
- .padding(horizontal = 16.dp)
+ var contactSheetVisible by remember { mutableStateOf(false) }
+ val contactSheetState = rememberModalBottomSheetState()
+
+ ExpressiveScreenScaffold(
+ title = stringResource(R.string.settings),
+ onBack = { navController.popBackStack() },
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(
+ top = padding.calculateTopPadding(),
+ bottom = 140.dp,
+ start = 16.dp,
+ end = 16.dp,
+ ),
) {
- Spacer(modifier = Modifier.height(topPadding))
-
- val isDarkTheme = isSystemInDarkTheme()
- val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
- val textColor = if (isDarkTheme) Color.White else Color.Black
-
- if (state.connectionSuccessful) {
- StyledToggle(
- title = stringResource(R.string.widget),
- label = stringResource(R.string.show_phone_battery_in_widget),
- description = stringResource(R.string.show_phone_battery_in_widget_description),
- checked = state.showPhoneBatteryInWidget,
- onCheckedChange = viewModel::setShowPhoneBatteryInWidget,
- enabled = state.isPremium
- )
-
- Text(
- text = stringResource(R.string.popup_animations), style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
- )
-
- Spacer(modifier = Modifier.height(2.dp))
-
- Column(
+ item {
+ LaunchedEffect(Unit) { AncVizPrefs.loadIfNeeded(context) }
+ val vizStyle by AncVizPrefs.style.collectAsState()
+ ExpressiveSectionHeader("Noise control visualization")
+ Box(
modifier = Modifier
.fillMaxWidth()
- .background(
- backgroundColor, RoundedCornerShape(28.dp)
- )
- .padding(vertical = 4.dp)
+ .height(150.dp)
+ .clip(RoundedCornerShape(28.dp))
+ .background(MaterialTheme.colorScheme.surfaceContainerLow),
) {
- StyledToggle(
- label = stringResource(R.string.show_bottom_sheet_popup),
- description = stringResource(R.string.show_bottom_sheet_popup_description),
- checked = state.showBottomSheetPopup,
- onCheckedChange = viewModel::setShowBottomSheetPopup,
- independent = false
- )
-
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier.padding(horizontal = 12.dp)
- )
-
- StyledToggle(
- label = stringResource(R.string.show_island_popup),
- description = stringResource(R.string.show_island_popup_description),
- checked = state.showIslandPopup,
- onCheckedChange = viewModel::setShowIslandPopup,
- independent = false
- )
+ AncVisualizer(modeValue = 4, style = vizStyle, modifier = Modifier.fillMaxSize())
}
+ Spacer(Modifier.height(12.dp))
+ ExpressiveGroup {
+ AncVizStyle.entries.forEachIndexed { i, st ->
+ if (i > 0) ExpressiveDivider()
+ ExpressiveListItem(
+ title = st.label,
+ onClick = { AncVizPrefs.set(context, st) },
+ trailing = if (st == vizStyle) {
+ { Icon(Icons.Filled.Check, contentDescription = "Selected", tint = MaterialTheme.colorScheme.primary) }
+ } else null,
+ )
+ }
+ }
+ }
- Text(
- text = stringResource(R.string.conversational_awareness), style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
- )
-
- Spacer(modifier = Modifier.height(2.dp))
+ if (state.connectionSuccessful) {
+ item {
+ ExpressiveSectionHeader(stringResource(R.string.widget))
+ ExpressiveGroup {
+ ExpressiveSwitchRow(
+ title = stringResource(R.string.show_phone_battery_in_widget),
+ subtitle = stringResource(R.string.show_phone_battery_in_widget_description),
+ leadingIcon = Icons.Filled.Widgets,
+ checked = state.showPhoneBatteryInWidget,
+ onCheckedChange = viewModel::setShowPhoneBatteryInWidget,
+ enabled = state.isPremium,
+ )
+ }
+ }
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .background(
- backgroundColor, RoundedCornerShape(28.dp)
+ item {
+ ExpressiveSectionHeader(stringResource(R.string.conversational_awareness))
+ ExpressiveGroup {
+ ExpressiveSwitchRow(
+ title = stringResource(R.string.conversational_awareness_pause_music),
+ subtitle = stringResource(R.string.conversational_awareness_pause_music_description),
+ leadingIcon = Icons.Filled.RecordVoiceOver,
+ checked = state.conversationalAwarenessPauseMusicEnabled,
+ onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled,
+ enabled = state.isPremium,
)
- .padding(vertical = 4.dp)
- ) {
- StyledToggle(
- label = stringResource(R.string.conversational_awareness_pause_music),
- description = stringResource(R.string.conversational_awareness_pause_music_description),
- checked = state.conversationalAwarenessPauseMusicEnabled,
- onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled,
- independent = false,
- enabled = state.isPremium
- )
+ ExpressiveDivider()
+ ExpressiveSwitchRow(
+ title = stringResource(R.string.relative_conversational_awareness_volume),
+ subtitle = stringResource(R.string.relative_conversational_awareness_volume_description),
+ leadingIcon = Icons.Filled.VolumeUp,
+ checked = state.relativeConversationalAwarenessVolumeEnabled,
+ onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled,
+ enabled = state.isPremium,
+ )
+ }
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier.padding(horizontal = 12.dp)
- )
+ val conversationalAwarenessVolume = state.conversationalAwarenessVolume
+ LaunchedEffect(conversationalAwarenessVolume) {
+ viewModel.setConversationalAwarenessVolume(conversationalAwarenessVolume)
+ }
- StyledToggle(
- label = stringResource(R.string.relative_conversational_awareness_volume),
- description = stringResource(R.string.relative_conversational_awareness_volume_description),
- checked = state.relativeConversationalAwarenessVolumeEnabled,
- onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled,
- independent = false,
+ Spacer(Modifier.height(12.dp))
+ Text(
+ text = stringResource(R.string.conversational_awareness_volume),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
+ )
+ ExpressiveSlider(
+ value = conversationalAwarenessVolume,
+ onValueChange = { newValue ->
+ viewModel.setConversationalAwarenessVolume(newValue)
+ },
+ valueRange = 10f..85f,
enabled = state.isPremium,
+ startIcon = Icons.Filled.VolumeUp,
+ endIcon = Icons.Filled.VolumeUp,
+ modifier = Modifier.padding(horizontal = 8.dp),
)
}
- Spacer(modifier = Modifier.height(16.dp))
+ if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) {
+ item {
+ ExpressiveSectionHeader(stringResource(R.string.ear_detection))
+ ExpressiveGroup {
+ ExpressiveSwitchRow(
+ title = stringResource(R.string.disconnect_when_not_wearing),
+ subtitle = stringResource(R.string.disconnect_when_not_wearing_description),
+ leadingIcon = Icons.Filled.Hearing,
+ checked = state.disconnectWhenNotWearing,
+ onCheckedChange = viewModel::setDisconnectWhenNotWearing,
+ enabled = state.isPremium,
+ )
+ }
+ }
+ }
- val conversationalAwarenessVolume = state.conversationalAwarenessVolume
- LaunchedEffect(conversationalAwarenessVolume) {
- viewModel.setConversationalAwarenessVolume(conversationalAwarenessVolume)
+ item {
+ ExpressiveSectionHeader(stringResource(R.string.takeover_airpods_state))
+ ExpressiveGroup {
+ ExpressiveSwitchRow(
+ title = stringResource(R.string.takeover_disconnected),
+ subtitle = stringResource(R.string.takeover_disconnected_desc),
+ checked = state.takeoverWhenDisconnected,
+ onCheckedChange = viewModel::setTakeoverWhenDisconnected,
+ enabled = state.isPremium,
+ )
+ ExpressiveDivider(startInset = false)
+ ExpressiveSwitchRow(
+ title = stringResource(R.string.takeover_idle),
+ subtitle = stringResource(R.string.takeover_idle_desc),
+ checked = state.takeoverWhenIdle,
+ onCheckedChange = viewModel::setTakeoverWhenIdle,
+ enabled = state.isPremium,
+ )
+ ExpressiveDivider(startInset = false)
+ ExpressiveSwitchRow(
+ title = stringResource(R.string.takeover_music),
+ subtitle = stringResource(R.string.takeover_music_desc),
+ checked = state.takeoverWhenMusic,
+ onCheckedChange = viewModel::setTakeoverWhenMusic,
+ enabled = state.isPremium,
+ )
+ ExpressiveDivider(startInset = false)
+ ExpressiveSwitchRow(
+ title = stringResource(R.string.takeover_call),
+ subtitle = stringResource(R.string.takeover_call_desc),
+ checked = state.takeoverWhenCall,
+ onCheckedChange = viewModel::setTakeoverWhenCall,
+ enabled = state.isPremium,
+ )
+ }
}
- StyledSlider(
- label = stringResource(R.string.conversational_awareness_volume),
- value = conversationalAwarenessVolume,
- valueRange = 10f..85f,
- snapPoints = listOf(44f),
- startLabel = "10%",
- endLabel = "85%",
- onValueChange = { newValue ->
- viewModel.setConversationalAwarenessVolume(
- newValue
+ item {
+ ExpressiveSectionHeader(stringResource(R.string.takeover_phone_state))
+ ExpressiveGroup {
+ ExpressiveSwitchRow(
+ title = stringResource(R.string.takeover_ringing_call),
+ subtitle = stringResource(R.string.takeover_ringing_call_desc),
+ checked = state.takeoverWhenRingingCall,
+ onCheckedChange = viewModel::setTakeoverWhenRingingCall,
+ enabled = state.isPremium,
)
- },
- independent = true,
- enabled = state.isPremium
- )
+ ExpressiveDivider(startInset = false)
+ ExpressiveSwitchRow(
+ title = stringResource(R.string.takeover_media_start),
+ subtitle = stringResource(R.string.takeover_media_start_desc),
+ checked = state.takeoverWhenMediaStart,
+ onCheckedChange = viewModel::setTakeoverWhenMediaStart,
+ enabled = state.isPremium,
+ )
+ }
+ }
-// if (!BuildConfig.PLAY_BUILD) {
-// Spacer(modifier = Modifier.height(16.dp))
-//
-// NavigationButton(
-// to = "",
-// title = stringResource(R.string.camera_control),
-// name = stringResource(R.string.set_custom_camera_package),
-// navController = navController,
-// onClick = {
-// if (state.isPremium) viewModel.setShowCameraDialog(true)
-// },
-// independent = true,
-// description = stringResource(R.string.camera_control_app_description)
-// )
-// }
-
- Spacer(modifier = Modifier.height(16.dp))
- if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) {
- StyledToggle(
- title = stringResource(R.string.ear_detection),
- label = stringResource(R.string.disconnect_when_not_wearing),
- description = stringResource(R.string.disconnect_when_not_wearing_description),
- checked = state.disconnectWhenNotWearing,
- onCheckedChange = viewModel::setDisconnectWhenNotWearing,
- enabled = state.isPremium
+ item {
+ ExpressiveSectionHeader(stringResource(R.string.advanced_options))
+ ExpressiveGroup {
+ ExpressiveSwitchRow(
+ title = stringResource(R.string.use_alternate_head_tracking_packets),
+ subtitle = stringResource(R.string.use_alternate_head_tracking_packets_description),
+ leadingIcon = Icons.Filled.Tune,
+ checked = state.useAlternateHeadTrackingPackets,
+ onCheckedChange = viewModel::setUseAlternateHeadTrackingPackets,
+ enabled = state.isPremium,
+ )
+ }
+ }
+ } else {
+ item {
+ Spacer(Modifier.height(16.dp))
+ Text(
+ text = stringResource(R.string.customizations_unavailable),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 16.dp),
)
}
+ }
- Text(
- text = stringResource(R.string.takeover_airpods_state), style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
- )
-
- Spacer(modifier = Modifier.height(4.dp))
+ if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
+ item {
+ val restartBluetoothText = stringResource(R.string.found_offset_restart_bluetooth)
+ ExpressiveSectionHeader(stringResource(R.string.requires_xposed))
+ ExpressiveGroup {
+ ExpressiveSwitchRow(
+ title = stringResource(R.string.act_as_an_apple_device),
+ subtitle = stringResource(R.string.act_as_an_apple_device_description),
+ leadingIcon = Icons.Filled.SwapHoriz,
+ checked = state.vendorIdHook,
+ onCheckedChange = { enabled ->
+ Toast.makeText(context, restartBluetoothText, Toast.LENGTH_SHORT).show()
+ viewModel.setVendorIdHook(enabled)
+ },
+ enabled = state.isPremium,
+ )
+ }
+ }
+ }
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .background(
- backgroundColor, RoundedCornerShape(28.dp)
+ if (!BuildConfig.PLAY_BUILD) {
+ item {
+ ExpressiveSectionHeader(stringResource(R.string.troubleshooting))
+ ExpressiveGroup {
+ ExpressiveListItem(
+ title = stringResource(R.string.troubleshooting),
+ subtitle = stringResource(R.string.troubleshooting_description),
+ leadingIcon = Icons.Filled.BugReport,
+ showChevron = true,
+ onClick = { navController.navigate("troubleshooting") },
)
- .padding(vertical = 4.dp)
- ) {
- StyledToggle(
- label = stringResource(R.string.takeover_disconnected),
- description = stringResource(R.string.takeover_disconnected_desc),
- checked = state.takeoverWhenDisconnected,
- onCheckedChange = viewModel::setTakeoverWhenDisconnected,
- independent = false,
- enabled = state.isPremium
+ }
+ }
+ }
+
+ item {
+ ExpressiveSectionHeader(stringResource(R.string.contact))
+ ExpressiveGroup {
+ ExpressiveListItem(
+ title = stringResource(R.string.email),
+ leadingIcon = Icons.Filled.Email,
+ showChevron = true,
+ onClick = { contactSheetVisible = true },
)
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier.padding(horizontal = 12.dp)
+ ExpressiveDivider()
+ ExpressiveListItem(
+ title = stringResource(R.string.discord),
+ leadingIcon = Icons.AutoMirrored.Filled.Chat,
+ showChevron = true,
+ onClick = {
+ val intent =
+ Intent(Intent.ACTION_VIEW, "https://discord.gg/Ts4wupXcmc".toUri())
+ context.startActivity(intent)
+ },
)
+ ExpressiveDivider()
+ ExpressiveListItem(
+ title = stringResource(R.string.github_issues),
+ leadingIcon = Icons.Filled.BugReport,
+ showChevron = true,
+ onClick = {
+ val appVersion = Uri.encode("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
+ val device = Uri.encode("${Build.MANUFACTURER} ${Build.MODEL}")
+ val androidVersion = Uri.encode("${Build.ID} (${Build.DISPLAY})")
+ val appSource = Uri.encode(
+ when {
+ BuildConfig.PLAY_BUILD -> "Play"
+ else -> "GitHub"
+ }
+ )
+ val url = "https://github.com/kavishdevar/librepods/issues/new" +
+ "?template=01-bug-report-android.yml" +
+ "&app-source=$appSource" +
+ "&app-version=$appVersion" +
+ "&device=$device" +
+ "&android-version=$androidVersion"
+
+ val intent = Intent(Intent.ACTION_VIEW, url.toUri())
+ context.startActivity(intent)
+ },
+ )
+ }
+ }
- StyledToggle(
- label = stringResource(R.string.takeover_idle),
- description = stringResource(R.string.takeover_idle_desc),
- checked = state.takeoverWhenIdle,
- onCheckedChange = viewModel::setTakeoverWhenIdle,
- independent = false,
- enabled = state.isPremium
+ item {
+ ExpressiveSectionHeader(stringResource(R.string.device_info))
+ ExpressiveGroup {
+ ExpressiveListItem(
+ title = stringResource(R.string.manufacturer),
+ value = Build.MANUFACTURER,
)
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier.padding(horizontal = 12.dp)
+ ExpressiveDivider(startInset = false)
+ ExpressiveListItem(
+ title = stringResource(R.string.model_number),
+ value = Build.MODEL,
)
-
- StyledToggle(
- label = stringResource(R.string.takeover_music),
- description = stringResource(R.string.takeover_music_desc),
- checked = state.takeoverWhenMusic,
- onCheckedChange = viewModel::setTakeoverWhenMusic,
- independent = false,
- enabled = state.isPremium
+ ExpressiveDivider(startInset = false)
+ ExpressiveListItem(
+ title = stringResource(R.string.build_id),
+ value = Build.DISPLAY,
)
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier.padding(horizontal = 12.dp)
+ ExpressiveDivider(startInset = false)
+ ExpressiveListItem(
+ title = stringResource(R.string.version),
+ value = Build.ID + " (${Build.VERSION.SDK_INT_FULL})",
)
-
- StyledToggle(
- label = stringResource(R.string.takeover_call),
- description = stringResource(R.string.takeover_call_desc),
- checked = state.takeoverWhenCall,
- onCheckedChange = viewModel::setTakeoverWhenCall,
- independent = false,
- enabled = state.isPremium
+ ExpressiveDivider(startInset = false)
+ ExpressiveListItem(
+ title = stringResource(R.string.xposed_available),
+ value = if (XposedState.isAvailable) stringResource(R.string.yes) else stringResource(R.string.no),
+ )
+ ExpressiveDivider(startInset = false)
+ ExpressiveListItem(
+ title = stringResource(R.string.app_enabled_in_xposed),
+ value = if (XposedState.bluetoothScopeEnabled) stringResource(R.string.yes) else stringResource(R.string.no),
)
}
+ }
- Spacer(modifier = Modifier.height(16.dp))
-
- Text(
- text = stringResource(R.string.takeover_phone_state), style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ), modifier = Modifier.padding(horizontal = 16.dp)
- )
- Spacer(modifier = Modifier.height(4.dp))
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .background(
- backgroundColor, RoundedCornerShape(28.dp)
- )
- .padding(vertical = 4.dp)
- ) {
- StyledToggle(
- label = stringResource(R.string.takeover_ringing_call),
- description = stringResource(R.string.takeover_ringing_call_desc),
- checked = state.takeoverWhenRingingCall,
- onCheckedChange = viewModel::setTakeoverWhenRingingCall,
- independent = false,
- enabled = state.isPremium
+ item {
+ ExpressiveSectionHeader(stringResource(R.string.about))
+ ExpressiveGroup {
+ ExpressiveListItem(
+ title = stringResource(R.string.version),
+ leadingIcon = Icons.Filled.Info,
+ value = BuildConfig.VERSION_NAME,
)
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier.padding(horizontal = 12.dp)
+ ExpressiveDivider(startInset = false)
+ ExpressiveListItem(
+ title = stringResource(R.string.version_code),
+ value = BuildConfig.VERSION_CODE.toString(),
)
-
- StyledToggle(
- label = stringResource(R.string.takeover_media_start),
- description = stringResource(R.string.takeover_media_start_desc),
- checked = state.takeoverWhenMediaStart,
- onCheckedChange = viewModel::setTakeoverWhenMediaStart,
- independent = false,
- enabled = state.isPremium
+ ExpressiveDivider(startInset = false)
+ ExpressiveListItem(
+ title = stringResource(R.string.flavor),
+ value = BuildConfig.FLAVOR,
+ )
+ ExpressiveDivider(startInset = false)
+ ExpressiveListItem(
+ title = stringResource(R.string.build_type),
+ value = BuildConfig.BUILD_TYPE,
+ )
+ ExpressiveDivider()
+ ExpressiveListItem(
+ title = stringResource(R.string.open_source_licenses),
+ leadingIcon = Icons.Filled.Description,
+ showChevron = true,
+ onClick = { navController.navigate("open_source_licenses") },
)
}
+ }
+ }
+ if (state.showCameraDialog) {
+ AlertDialog(onDismissRequest = { viewModel.setShowCameraDialog(false) }, title = {
Text(
- text = stringResource(R.string.advanced_options), style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
+ stringResource(R.string.set_custom_camera_package),
+ style = MaterialTheme.typography.titleLarge
)
-
- Spacer(modifier = Modifier.height(2.dp))
-
- StyledToggle(
- label = stringResource(R.string.use_alternate_head_tracking_packets),
- description = stringResource(R.string.use_alternate_head_tracking_packets_description),
- checked = state.useAlternateHeadTrackingPackets,
- onCheckedChange = viewModel::setUseAlternateHeadTrackingPackets,
- independent = true,
- enabled = state.isPremium
- )
- Spacer(modifier = Modifier.height(16.dp))
- } else {
- Box(
- modifier = Modifier
- .background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
- .padding(horizontal = 16.dp)
- .padding(top = 16.dp, bottom = 2.dp)
- ) {
+ }, text = {
+ Column {
Text(
- text = stringResource(R.string.customizations_unavailable),
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Normal,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- color = textColor.copy(alpha = 0.6f),
+ stringResource(R.string.enter_custom_camera_package),
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+
+ OutlinedTextField(
+ value = state.cameraPackageValue,
+ onValueChange = {
+ viewModel.setCameraPackageValue(it)
+ viewModel.setCameraPackageError(null)
+ },
+ modifier = Modifier.fillMaxWidth(),
+ isError = state.cameraPackageError != null,
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Ascii,
+ capitalization = KeyboardCapitalization.None
),
- modifier = Modifier
+ supportingText = {
+ if (state.cameraPackageError != null) {
+ Text(
+ state.cameraPackageError ?: "",
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ },
+ label = { Text(stringResource(R.string.custom_camera_package)) })
+ }
+ }, confirmButton = {
+ val successText = stringResource(R.string.custom_camera_package_set_success)
+ TextButton(
+ onClick = {
+ viewModel.saveCameraPackage()
+ Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
+ }) {
+ Text(
+ "Save",
+ style = MaterialTheme.typography.labelLarge
)
}
- }
+ }, dismissButton = {
+ TextButton(
+ onClick = { viewModel.setShowCameraDialog(false) }) {
+ Text(
+ "Cancel",
+ style = MaterialTheme.typography.labelLarge
+ )
+ }
+ })
+ }
- if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
- val restartBluetoothText =
- stringResource(R.string.found_offset_restart_bluetooth)
- StyledToggle(
- label = stringResource(R.string.act_as_an_apple_device) + " (${
- stringResource(
- R.string.requires_xposed
+ if (contactSheetVisible) {
+ ContactBottomSheet(
+ sheetState = contactSheetState,
+ onDismiss = { contactSheetVisible = false },
+ onSend = { subject, description ->
+ contactSheetVisible = false
+ val intent = Intent(Intent.ACTION_SENDTO).apply {
+ data = "mailto:".toUri()
+ putExtra(Intent.EXTRA_EMAIL, arrayOf("contact@kavish.xyz"))
+ putExtra(Intent.EXTRA_SUBJECT, "LibrePods: $subject")
+ putExtra(
+ Intent.EXTRA_TEXT,
+ description +
+ "\n\n----------" +
+ "\nPhone details:" +
+ "\nMANUFACTURER: ${Build.MANUFACTURER}" +
+ "\nMODEL: ${Build.MODEL} (${Build.PRODUCT})" +
+ "\nDISPLAY_VERSION: ${Build.DISPLAY}" +
+ "\nID: ${Build.ID} (SDK ${Build.VERSION.SDK_INT_FULL})" +
+ "\nXposed enabled/active: ${XposedState.isAvailable}/${XposedState.bluetoothScopeEnabled}" +
+ "\n\nApp details:" +
+ "\nVERSION: ${BuildConfig.VERSION_NAME}" +
+ "\nVERSION_CODE: ${BuildConfig.VERSION_CODE}" +
+ "\nFLAVOR: ${BuildConfig.FLAVOR}" +
+ "\nBUILD_TYPE: ${BuildConfig.BUILD_TYPE}"
)
- })",
- description = stringResource(R.string.act_as_an_apple_device_description),
- checked = state.vendorIdHook,
- onCheckedChange = { enabled ->
- Toast.makeText(context, restartBluetoothText, Toast.LENGTH_SHORT).show()
- viewModel.setVendorIdHook(enabled)
- },
- independent = true,
- enabled = state.isPremium
- )
- }
+ }
+ context.startActivity(intent)
+ },
+ )
+ }
+ }
+}
- if (!BuildConfig.PLAY_BUILD) {
- Spacer(modifier = Modifier.height(16.dp))
- NavigationButton(
- to = "troubleshooting",
- name = stringResource(R.string.troubleshooting),
- navController = navController,
- independent = true,
- description = stringResource(R.string.troubleshooting_description)
- )
- }
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ContactBottomSheet(
+ sheetState: androidx.compose.material3.SheetState,
+ onDismiss: () -> Unit,
+ onSend: (subject: String, description: String) -> Unit,
+) {
+ var subject by remember { mutableStateOf("") }
+ var description by remember { mutableStateOf("") }
+ val canSend = subject.isNotEmpty() && description.isNotEmpty()
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 32.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.describe_your_issue),
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(bottom = 16.dp),
+ )
- Spacer(modifier = Modifier.height(8.dp))
+ OutlinedTextField(
+ value = subject,
+ onValueChange = { subject = it },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ label = { Text(stringResource(R.string.subject)) },
+ )
- Box(
- modifier = Modifier
- .background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
- .padding(start = 16.dp, bottom = 2.dp, top = 24.dp, end = 4.dp)
- ) {
- Text(
- text = stringResource(R.string.contact), style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- }
+ Spacer(Modifier.height(12.dp))
- Spacer(modifier = Modifier.height(4.dp))
- Column(
+ OutlinedTextField(
+ value = description,
+ onValueChange = { description = it },
modifier = Modifier
.fillMaxWidth()
- .background(
- backgroundColor, RoundedCornerShape(28.dp)
- )
- .clip(RoundedCornerShape(28.dp))
- ) {
- NavigationButton(
- to = "",
- name = stringResource(R.string.email),
- navController = navController,
- onClick = { contactBottomSheet.value = true },
- independent = false
- )
-
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier.padding(horizontal = 12.dp)
- )
-
- NavigationButton(
- to = "",
- name = stringResource(R.string.discord),
- navController = navController,
- onClick = {
- val intent =
- Intent(Intent.ACTION_VIEW, "https://discord.gg/Ts4wupXcmc".toUri())
- context.startActivity(intent)
- },
- independent = false
- )
+ .height(160.dp),
+ label = { Text(stringResource(R.string.describe_your_issue)) },
+ )
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier.padding(horizontal = 12.dp)
- )
+ Spacer(Modifier.height(20.dp))
- NavigationButton(
- to = "",
- name = stringResource(R.string.github_issues),
- navController = navController,
- onClick = {
- val appVersion = Uri.encode("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
- val device = Uri.encode("${Build.MANUFACTURER} ${Build.MODEL}")
- val androidVersion = Uri.encode("${Build.ID} (${Build.DISPLAY})")
- val appSource = Uri.encode(
- when {
- BuildConfig.PLAY_BUILD -> "Play"
- else -> "GitHub"
- }
- )
- val url = "https://github.com/kavishdevar/librepods/issues/new" +
- "?template=01-bug-report-android.yml" +
- "&app-source=$appSource" +
- "&app-version=$appVersion" +
- "&device=$device" +
- "&android-version=$androidVersion"
-
- val intent = Intent(Intent.ACTION_VIEW, url.toUri())
- context.startActivity(intent)
+ ExpressiveActionGroup(
+ actions = listOf(
+ ExpressiveAction(label = "Send") {
+ if (canSend) onSend(subject, description)
},
- independent = false
- )
- }
-
- Spacer(modifier = Modifier.height(20.dp))
- DeviceInfoCard()
- Spacer(modifier = Modifier.height(16.dp))
- AppInfoCard()
-
- Spacer(modifier = Modifier.height(16.dp))
-
- NavigationButton(
- to = "open_source_licenses",
- name = stringResource(R.string.open_source_licenses),
- navController = navController,
- independent = true
+ ),
)
-
- Spacer(modifier = Modifier.height(bottomPadding))
-
- if (state.showCameraDialog) {
- AlertDialog(onDismissRequest = { viewModel.setShowCameraDialog(false) }, title = {
- Text(
- stringResource(R.string.set_custom_camera_package),
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- fontWeight = FontWeight.Medium
- )
- }, text = {
- Column {
- Text(
- stringResource(R.string.enter_custom_camera_package),
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- modifier = Modifier.padding(bottom = 8.dp)
- )
-
- OutlinedTextField(
- value = state.cameraPackageValue,
- onValueChange = {
- viewModel.setCameraPackageValue(it)
- viewModel.setCameraPackageError(null)
- },
- modifier = Modifier.fillMaxWidth(),
- isError = state.cameraPackageError != null,
- keyboardOptions = KeyboardOptions(
- keyboardType = KeyboardType.Ascii,
- capitalization = KeyboardCapitalization.None
- ),
- colors = OutlinedTextFieldDefaults.colors(
- focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(
- 0xFF3C6DF5
- ),
- unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
- ),
- supportingText = {
- if (state.cameraPackageError != null) {
- Text(
- state.cameraPackageError ?: "",
- color = MaterialTheme.colorScheme.error
- )
- }
- },
- label = { Text(stringResource(R.string.custom_camera_package)) })
- }
- }, confirmButton = {
- val successText = stringResource(R.string.custom_camera_package_set_success)
- TextButton(
- onClick = {
- viewModel.saveCameraPackage()
- Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
- }) {
- Text(
- "Save",
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- fontWeight = FontWeight.Medium
- )
- }
- }, dismissButton = {
- TextButton(
- onClick = { viewModel.setShowCameraDialog(false) }) {
- Text(
- "Cancel",
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- fontWeight = FontWeight.Medium
- )
- }
- })
- }
- }
- StyledBottomSheet(
- visible = contactBottomSheet.value,
- onDismiss = { contactBottomSheet.value = false },
- backdrop = backdrop
- ) { innerBackdrop, progress ->
- val animatedPadding = lerp(16.dp, 2.dp, progress)
-
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = animatedPadding)
- .padding(bottom = 16.dp),
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(bottom = 16.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- StyledIconButton(
- icon = "\uDBC0\uDD84",
- backdrop = innerBackdrop,
- onClick = { contactBottomSheet.value = false }
- )
- Text (
- text = stringResource(R.string.describe_your_issue),
- style = TextStyle(
- fontSize = 18.sp,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- fontWeight = FontWeight.Bold,
- textAlign = TextAlign.Center,
- color = if (isSystemInDarkTheme()) Color.White else Color.Black
- )
- )
- StyledIconButton(
- icon = "\uDBC0\uDE1F",
- backdrop = innerBackdrop,
- surfaceColor = if (isSystemInDarkTheme()) Color(0xFF0091FF) else Color(0xFF0088FF),
- iconTint = if (subjectState.text.isNotEmpty() && descriptionState.text.isNotEmpty()) Color.White else Color.Gray,
- enabled = subjectState.text.isNotEmpty() && descriptionState.text.isNotEmpty(),
- onClick = {
- contactBottomSheet.value = false
- val intent = Intent(Intent.ACTION_SENDTO).apply {
- data = "mailto:".toUri()
- putExtra(Intent.EXTRA_EMAIL, arrayOf("contact@kavish.xyz"))
- putExtra(Intent.EXTRA_SUBJECT, "LibrePods: ${subjectState.text}")
- putExtra(
- Intent.EXTRA_TEXT,
- "${descriptionState.text}" +
- "\n\n----------" +
- "\nPhone details:" +
- "\nMANUFACTURER: ${Build.MANUFACTURER}" +
- "\nMODEL: ${Build.MODEL} (${Build.PRODUCT})" +
- "\nDISPLAY_VERSION: ${Build.DISPLAY}" +
- "\nID: ${Build.ID} (SDK ${Build.VERSION.SDK_INT_FULL})" +
- "\nXposed enabled/active: ${XposedState.isAvailable}/${XposedState.bluetoothScopeEnabled}" +
- "\n\nApp details:" +
- "\nVERSION: ${BuildConfig.VERSION_NAME}" +
- "\nVERSION_CODE: ${BuildConfig.VERSION_CODE}" +
- "\nFLAVOR: ${BuildConfig.FLAVOR}" +
- "\nBUILD_TYPE: ${BuildConfig.BUILD_TYPE}"
- )
- }
- context.startActivity(intent)
- subjectState.clearText()
- descriptionState.clearText()
- }
- )
- }
-
- Spacer(modifier = Modifier.height(8.dp))
-
- StyledInputField(
- inputState = subjectState,
- focusRequester = subjectFocusRequester,
- placeholder = stringResource(R.string.subject),
- )
-
- Spacer(modifier = Modifier.height(12.dp))
-
- StyledInputField(
- inputState = descriptionState,
- focusRequester = descriptionFocusRequester,
- placeholder = stringResource(R.string.describe_your_issue),
- singleLine = false
- )
- }
}
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/CameraControlScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/CameraControlScreen.kt
index 433e7b324..635fbb45d 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/CameraControlScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/CameraControlScreen.kt
@@ -24,28 +24,33 @@ import android.content.Context
import android.content.Intent
import android.provider.Settings
import android.view.accessibility.AccessibilityManager
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Block
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.PhotoCamera
+import androidx.compose.material.icons.filled.TouchApp
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import com.kyant.backdrop.backdrops.layerBackdrop
-import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.presentation.components.SelectItem
-import me.kavishdevar.librepods.presentation.components.StyledScaffold
-import me.kavishdevar.librepods.presentation.components.StyledSelectList
-import me.kavishdevar.librepods.services.AppListenerService
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.StemPressType
+import me.kavishdevar.librepods.presentation.m3.ExpressiveDivider
+import me.kavishdevar.librepods.presentation.m3.ExpressiveGroup
+import me.kavishdevar.librepods.presentation.m3.ExpressiveListItem
+import me.kavishdevar.librepods.presentation.m3.ExpressiveScreenScaffold
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSectionHeader
+import me.kavishdevar.librepods.services.AppListenerService
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
@@ -72,40 +77,53 @@ fun CameraControlScreen(viewModel: AirPodsViewModel) {
}
}
- val cameraOptions = remember(currentCameraAction) {
+ data class CameraOption(
+ val name: String,
+ val icon: ImageVector,
+ val action: StemPressType?,
+ )
+
+ val cameraOptions = remember {
listOf(
- SelectItem(
- name = "Off",
- selected = currentCameraAction == null,
- onClick = { handleSelection(null) }
- ),
- SelectItem(
- name = "Press once",
- selected = currentCameraAction == StemPressType.SINGLE_PRESS,
- onClick = { handleSelection(StemPressType.SINGLE_PRESS) }
- ),
- SelectItem(
- name = "Press and hold AirPods",
- selected = currentCameraAction == StemPressType.LONG_PRESS,
- onClick = { handleSelection(StemPressType.LONG_PRESS) }
- )
+ CameraOption("Off", Icons.Filled.Block, null),
+ CameraOption("Press once", Icons.Filled.PhotoCamera, StemPressType.SINGLE_PRESS),
+ CameraOption("Press and hold AirPods", Icons.Filled.TouchApp, StemPressType.LONG_PRESS),
)
}
- val backdrop = rememberLayerBackdrop()
-
- StyledScaffold(
- title = stringResource(R.string.camera_control)
- ) { spacerHeight ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .layerBackdrop(backdrop)
- .padding(horizontal = 16.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp)
+ ExpressiveScreenScaffold(title = stringResource(R.string.camera_control)) { padding ->
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(
+ top = padding.calculateTopPadding(),
+ bottom = 120.dp,
+ start = 16.dp,
+ end = 16.dp,
+ ),
) {
- Spacer(modifier = Modifier.height(spacerHeight))
- StyledSelectList(items = cameraOptions)
+ item {
+ ExpressiveSectionHeader("Shutter action")
+ ExpressiveGroup {
+ cameraOptions.forEachIndexed { index, option ->
+ if (index > 0) ExpressiveDivider()
+ val selected = currentCameraAction == option.action
+ ExpressiveListItem(
+ title = option.name,
+ leadingIcon = option.icon,
+ onClick = { handleSelection(option.action) },
+ trailing = if (selected) {
+ {
+ Icon(
+ imageVector = Icons.Filled.Check,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+ } else null,
+ )
+ }
+ }
+ }
}
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/DebugScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/DebugScreen.kt
index 75c4bc2d6..088f09d1e 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/DebugScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/DebugScreen.kt
@@ -16,7 +16,7 @@
along with this program. If not, see .
*/
-@file:OptIn(ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class)
+@file:OptIn(ExperimentalEncodingApi::class)
package me.kavishdevar.librepods.presentation.screens
@@ -28,36 +28,43 @@ import android.os.Build
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
-import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.PaddingValues
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.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Send
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
+import androidx.compose.material.icons.automirrored.filled.Send
+import androidx.compose.material.icons.filled.DeleteSweep
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -67,25 +74,18 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
-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.unit.dp
-import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
-import com.kyant.backdrop.backdrops.layerBackdrop
-import com.kyant.backdrop.backdrops.rememberLayerBackdrop
-import dev.chrisbanes.haze.hazeSource
-import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
-import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.presentation.components.StyledIconButton
-import me.kavishdevar.librepods.presentation.components.StyledScaffold
import me.kavishdevar.librepods.data.BatteryStatus
import me.kavishdevar.librepods.data.isHeadTrackingData
+import me.kavishdevar.librepods.presentation.m3.ExpressiveDivider
+import me.kavishdevar.librepods.presentation.m3.ExpressiveGroup
+import me.kavishdevar.librepods.presentation.m3.ExpressiveScreenScaffold
+import me.kavishdevar.librepods.presentation.m3.pressBounce
import me.kavishdevar.librepods.services.ServiceManager
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -302,13 +302,14 @@ fun DebugScreen(navController: NavController) {
val refreshTrigger = remember { mutableIntStateOf(0) }
LaunchedEffect(refreshTrigger.intValue) {
- while(true) {
+ while (true) {
delay(1000)
refreshTrigger.intValue += 1
}
}
val expandedItems = remember { mutableStateOf(setOf()) }
+ val packet = remember { mutableStateOf(TextFieldValue("")) }
fun copyToClipboard(text: String) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
@@ -323,49 +324,97 @@ fun DebugScreen(navController: NavController) {
}
}
- val isDarkTheme = isSystemInDarkTheme()
- val backdrop = rememberLayerBackdrop()
- StyledScaffold(
+ fun sendPacket() {
+ if (packet.value.text.isNotBlank()) {
+ airPodsService?.aacpManager?.sendPacket(
+ packet.value.text
+ .split(" ")
+ .map { it.toInt(16).toByte() }
+ .toByteArray()
+ )
+ packet.value = TextFieldValue("")
+ focusManager.clearFocus()
+
+ if (packetLogs.isNotEmpty()) {
+ coroutineScope.launch {
+ try {
+ delay(100)
+ listState.animateScrollToItem(
+ index = (packetLogs.size - 1).coerceAtLeast(0),
+ scrollOffset = 0
+ )
+ } catch (e: Exception) {
+ e.printStackTrace()
+ listState.scrollToItem(
+ index = (packetLogs.size - 1).coerceAtLeast(0)
+ )
+ }
+ }
+ }
+ }
+ }
+
+ ExpressiveScreenScaffold(
title = "Debug",
- actionButtons = listOf(
- {scaffoldBackdrop ->
- StyledIconButton(
- onClick = {
- airPodsService?.clearLogs()
- expandedItems.value = emptySet()
- },
- icon = "",
- backdrop = scaffoldBackdrop
+ onBack = { navController.popBackStack() },
+ actions = {
+ IconButton(
+ onClick = {
+ airPodsService?.clearLogs()
+ expandedItems.value = emptySet()
+ }
+ ) {
+ Icon(
+ imageVector = Icons.Filled.DeleteSweep,
+ contentDescription = "Clear logs",
)
}
- ),
- ) { topPadding, hazeState, bottomPadding ->
+ },
+ ) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
- .hazeSource(hazeState)
- .navigationBarsPadding()
- .layerBackdrop(backdrop)
+ .padding(top = padding.calculateTopPadding())
.padding(horizontal = 16.dp)
) {
- Spacer(modifier = Modifier.height(topPadding))
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxWidth()
.weight(1f),
- content = {
- items(packetLogs.size) { index ->
- val message = packetLogs.elementAt(index)
- val isSent = message.startsWith("Sent")
- val isExpanded = expandedItems.value.contains(index)
- val packetInfo = parsePacket(message)
-
- Card(
+ contentPadding = PaddingValues(top = 8.dp, bottom = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ if (packetLogs.isEmpty()) {
+ item {
+ Spacer(Modifier.height(48.dp))
+ Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
+ Text(
+ text = "No packets captured yet",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+
+ items(packetLogs.size) { index ->
+ val message = packetLogs.elementAt(index)
+ val isSent = message.startsWith("Sent")
+ val isExpanded = expandedItems.value.contains(index)
+ val packetInfo = parsePacket(message)
+
+ val interaction = remember { MutableInteractionSource() }
+
+ ExpressiveGroup(
+ modifier = Modifier.pressBounce(interaction),
+ ) {
+ Box(
modifier = Modifier
.fillMaxWidth()
- .padding(vertical = 2.dp)
.combinedClickable(
+ interactionSource = interaction,
+ indication = null,
onClick = {
expandedItems.value = if (isExpanded) {
expandedItems.value - index
@@ -377,23 +426,19 @@ fun DebugScreen(navController: NavController) {
copyToClipboard(packetInfo.rawData)
}
),
- elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
- shape = RoundedCornerShape(4.dp),
- colors = CardDefaults.cardColors(
- containerColor = if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7),
- )
) {
- Row(verticalAlignment = Alignment.CenterVertically) {
- Text(
- text = if (isSent) "" else "",
- style = TextStyle(
- fontSize = 16.sp,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- color = if (isSent) Color(0xFF4CD964) else Color(0xFFFF3B30)
- ),
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = if (isSent) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
+ contentDescription = if (isSent) "Sent" else "Received",
+ tint = if (isSent) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error,
+ modifier = Modifier.size(20.dp),
)
- Spacer(modifier = Modifier.width(4.dp))
- Column {
+ Spacer(modifier = Modifier.width(12.dp))
+ Column(modifier = Modifier.weight(1f)) {
Text(
text = if (packetInfo.isUnknown) {
val shortenedData = packetInfo.rawData.take(60) +
@@ -402,47 +447,38 @@ fun DebugScreen(navController: NavController) {
} else {
"${packetInfo.type}: ${packetInfo.description}"
},
- style = TextStyle(
- fontSize = 12.sp,
- fontWeight = FontWeight.Medium,
- fontFamily = FontFamily(Font(R.font.hack))
- )
+ style = MaterialTheme.typography.bodyMedium,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurface,
)
if (isExpanded) {
- Spacer(modifier = Modifier.height(4.dp))
+ Spacer(modifier = Modifier.height(6.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
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = value,
- style = TextStyle(
- fontSize = 12.sp,
- fontFamily = FontFamily(Font(R.font.hack))
- ),
- color = Color.Gray
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
- Spacer(modifier = Modifier.height(4.dp))
+ Spacer(modifier = Modifier.height(6.dp))
}
Text(
text = "Raw: ${packetInfo.rawData}",
- style = TextStyle(
- fontSize = 12.sp,
- fontFamily = FontFamily(Font(R.font.hack))
- ),
- color = Color.Gray
+ style = MaterialTheme.typography.bodySmall,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -450,73 +486,45 @@ fun DebugScreen(navController: NavController) {
}
}
}
- )
+ }
+
+ ExpressiveDivider(startInset = false)
Spacer(modifier = Modifier.height(8.dp))
- val airPodsService = ServiceManager.getService()?.let { mutableStateOf(it) }
- HorizontalDivider()
Row(
modifier = Modifier
.fillMaxWidth()
- .background(if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7)),
- verticalAlignment = Alignment.CenterVertically
+ .navigationBarsPadding()
+ .imePadding(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
- val packet = remember { mutableStateOf(TextFieldValue("")) }
TextField(
value = packet.value,
onValueChange = { packet.value = it },
- label = { Text("Packet") },
+ label = { Text("Packet (hex)") },
+ singleLine = true,
modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 8.dp)
- .padding(bottom = 5.dp),
- trailingIcon = {
- IconButton(
- onClick = {
- if (packet.value.text.isNotBlank()) {
- airPodsService?.value?.aacpManager?.sendPacket(
- packet.value.text
- .split(" ")
- .map { it.toInt(16).toByte() }
- .toByteArray()
- )
- packet.value = TextFieldValue("")
- focusManager.clearFocus()
-
- if (packetLogs.isNotEmpty()) {
- coroutineScope.launch {
- try {
- delay(100)
- listState.animateScrollToItem(
- index = (packetLogs.size - 1).coerceAtLeast(0),
- scrollOffset = 0
- )
- } catch (e: Exception) {
- e.printStackTrace()
- listState.scrollToItem(
- index = (packetLogs.size - 1).coerceAtLeast(0)
- )
- }
- }
- }
- }
- }
- ) {
- @Suppress("DEPRECATION")
- Icon(Icons.Filled.Send, contentDescription = "Send")
- }
- },
+ .weight(1f)
+ .padding(vertical = 8.dp),
+ shape = RoundedCornerShape(20.dp),
colors = TextFieldDefaults.colors(
- focusedContainerColor = if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7),
- unfocusedContainerColor = if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7),
+ focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
+ unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
- focusedTextColor = if (isSystemInDarkTheme()) Color.White else Color.Black,
- unfocusedTextColor = if (isSystemInDarkTheme()) Color.White else Color.Black.copy(alpha = 0.6f),
- focusedLabelColor = if (isSystemInDarkTheme()) Color.White.copy(alpha = 0.6f) else Color.Black,
- unfocusedLabelColor = if (isSystemInDarkTheme()) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f),
+ disabledIndicatorColor = Color.Transparent,
+ focusedTextColor = MaterialTheme.colorScheme.onSurface,
+ unfocusedTextColor = MaterialTheme.colorScheme.onSurface,
+ focusedLabelColor = MaterialTheme.colorScheme.primary,
+ unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
- shape = RoundedCornerShape(12.dp)
)
+ FilledIconButton(
+ onClick = { sendPacket() },
+ modifier = Modifier.size(56.dp),
+ ) {
+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
+ }
}
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt
index d440e6d60..378ba0787 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HeadTrackingScreen.kt
@@ -19,38 +19,42 @@
// this is absolutely unnecessary, why did I make this. a simple toggle would've sufficed
-@file:OptIn(ExperimentalEncodingApi::class)
+@file:OptIn(ExperimentalEncodingApi::class, ExperimentalMaterial3Api::class)
package me.kavishdevar.librepods.presentation.screens
import android.graphics.Paint
import android.graphics.RadialGradient
import android.graphics.Shader
-import android.graphics.Typeface
import android.util.Log
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.ExperimentalAnimationApi
+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
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Canvas
-import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
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.rememberScrollState
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.verticalScroll
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -69,7 +73,6 @@ import androidx.compose.ui.Modifier
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.StrokeCap
import androidx.compose.ui.graphics.asAndroidPath
@@ -79,214 +82,209 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
-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.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
-import com.kyant.backdrop.backdrops.layerBackdrop
-import com.kyant.backdrop.backdrops.rememberLayerBackdrop
-import dev.chrisbanes.haze.hazeSource
-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.presentation.components.StyledButton
-import me.kavishdevar.librepods.presentation.components.StyledIconButton
-import me.kavishdevar.librepods.presentation.components.StyledScaffold
-import me.kavishdevar.librepods.presentation.components.StyledToggle
+import me.kavishdevar.librepods.presentation.m3.ExpressiveAction
+import me.kavishdevar.librepods.presentation.m3.ExpressiveActionGroup
+import me.kavishdevar.librepods.presentation.m3.ExpressiveGroup
+import me.kavishdevar.librepods.presentation.m3.ExpressiveListItem
+import me.kavishdevar.librepods.presentation.m3.ExpressiveScreenScaffold
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSectionHeader
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSlider
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSwitchRow
+import me.kavishdevar.librepods.presentation.m3.rememberAppHaptics
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.HeadTracking
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
import kotlin.math.cos
+import kotlin.math.roundToInt
import kotlin.math.sin
import kotlin.random.Random
-@ExperimentalHazeMaterialsApi
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
+@OptIn(ExperimentalAnimationApi::class)
@Composable
fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController) {
val state by viewModel.uiState.collectAsState()
+ val haptics = rememberAppHaptics()
+
DisposableEffect(Unit) {
viewModel.startHeadTracking()
onDispose {
viewModel.stopHeadTracking()
}
}
- val isDarkTheme = isSystemInDarkTheme()
- if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
- val textColor = if (isDarkTheme) Color.White else Color.Black
- val scrollState = rememberScrollState()
- val backdrop = rememberLayerBackdrop()
- StyledScaffold(
- title = stringResource(R.string.head_tracking),
- actionButtons = listOf(
- { scaffoldBackdrop ->
- StyledIconButton(
- onClick = {
- if (!state.headTrackingActive) {
- viewModel.startHeadTracking()
- Log.d("HeadTrackingScreen", "Head tracking started")
- } else {
- viewModel.stopHeadTracking()
- Log.d("HeadTrackingScreen", "Head tracking stopped")
- }
- },
- icon = if (state.headTrackingActive) "" else "",
- backdrop = scaffoldBackdrop
- )
- }
- ),
- ) { topPadding, hazeState, _ ->
+ var gestureText by remember { mutableStateOf("") }
+ val coroutineScope = rememberCoroutineScope()
- var gestureText by remember { mutableStateOf("") }
- val coroutineScope = rememberCoroutineScope()
+ var lastClickTime by remember { mutableLongStateOf(0L) }
+ var shouldExplode by remember { mutableStateOf(false) }
- var lastClickTime by remember { mutableLongStateOf(0L) }
- var shouldExplode by remember { mutableStateOf(false) }
+ LaunchedEffect(gestureText) {
+ if (gestureText.isNotEmpty()) {
+ lastClickTime = System.currentTimeMillis()
+ delay(3000)
+ if (System.currentTimeMillis() - lastClickTime >= 3000) {
+ shouldExplode = true
+ }
+ }
+ }
- val scrollState = rememberScrollState()
+ val gestureTextValue = stringResource(R.string.shake_your_head_or_nod)
+ val particleColor = MaterialTheme.colorScheme.onSurface
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .verticalScroll(scrollState),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Column (
- modifier = Modifier
- .fillMaxWidth()
- .hazeSource(state = hazeState)
- .layerBackdrop(backdrop)
- .padding(top = 8.dp)
- .padding(horizontal = 16.dp)
- ) {
- Spacer(modifier = Modifier.height(topPadding))
-
- StyledToggle(
- label = "Head Gestures",
- checked = state.headGesturesEnabled,
- onCheckedChange = { viewModel.setHeadGesturesEnabled(it) },
- enabled = state.isPremium || state.headGesturesEnabled,
- description = stringResource(R.string.head_gestures_details)
+ ExpressiveScreenScaffold(
+ title = stringResource(R.string.head_tracking),
+ onBack = { navController.popBackStack() },
+ actions = {
+ IconButton(onClick = {
+ haptics.toggle()
+ if (!state.headTrackingActive) {
+ viewModel.startHeadTracking()
+ Log.d("HeadTrackingScreen", "Head tracking started")
+ } else {
+ viewModel.stopHeadTracking()
+ Log.d("HeadTrackingScreen", "Head tracking stopped")
+ }
+ }) {
+ Icon(
+ imageVector = if (state.headTrackingActive) Icons.Filled.Pause else Icons.Filled.PlayArrow,
+ contentDescription = if (state.headTrackingActive) "Stop head tracking" else "Start head tracking",
)
+ }
+ },
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(
+ top = padding.calculateTopPadding(),
+ bottom = 120.dp,
+ start = 16.dp,
+ end = 16.dp,
+ ),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ item {
+ ExpressiveGroup {
+ ExpressiveSwitchRow(
+ title = "Head Gestures",
+ subtitle = stringResource(R.string.head_gestures_details),
+ checked = state.headGesturesEnabled,
+ onCheckedChange = { viewModel.setHeadGesturesEnabled(it) },
+ enabled = state.isPremium || state.headGesturesEnabled,
+ )
+ }
+ }
- Spacer(modifier = Modifier.height(16.dp))
- Text(
- "Head Orientation",
- style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
- )
+ item {
+ ExpressiveSectionHeader("Gesture Sensitivity")
+ // Local value while dragging; the preference is committed on release and
+ // picked up the next time detection starts (including the test below).
+ var sliderValue by remember(state.headGestureSensitivity) {
+ mutableFloatStateOf(state.headGestureSensitivity)
+ }
+ ExpressiveGroup {
+ ExpressiveListItem(
+ title = "Sensitivity",
+ subtitle = "How readily a nod or shake is recognised. Raise it if gestures go undetected, lower it if stray movement triggers them.",
+ value = "${(sliderValue * 100).roundToInt()}%",
+ enabled = state.headGesturesEnabled,
+ )
+ ExpressiveSlider(
+ value = sliderValue,
+ onValueChange = { sliderValue = it },
+ onValueChangeFinished = { viewModel.setHeadGestureSensitivity(sliderValue) },
+ valueRange = 0f..1f,
+ enabled = state.headGesturesEnabled,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ )
+ }
+ }
+
+ item {
+ ExpressiveSectionHeader("Head Orientation")
HeadVisualization()
+ }
- Spacer(modifier = Modifier.height(16.dp))
- Text(
- "Velocity",
- style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
- )
+ item {
+ ExpressiveSectionHeader("Velocity")
AccelerationPlot()
-
- Spacer(modifier = Modifier.height(16.dp))
-
- LaunchedEffect(gestureText) {
- if (gestureText.isNotEmpty()) {
- lastClickTime = System.currentTimeMillis()
- delay(3000)
- if (System.currentTimeMillis() - lastClickTime >= 3000) {
- shouldExplode = true
- }
- }
- }
}
- val gestureTextValue = stringResource(R.string.shake_your_head_or_nod)
- StyledButton(
- onClick = {
- gestureText = gestureTextValue
- coroutineScope.launch {
- val accepted = ServiceManager.getService()?.testHeadGestures() ?: false
- gestureText = if (accepted) "\"Yes\" gesture detected." else "\"No\" gesture detected."
- }
- },
- backdrop = backdrop,
- modifier = Modifier.fillMaxWidth(0.75f),
- maxScale = 0.05f
- ) {
- Text(
- "Test Head Gestures",
- style = TextStyle(
- fontSize = 16.sp,
- fontWeight = FontWeight.Medium,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- color = textColor
+
+ item {
+ Spacer(Modifier.height(24.dp))
+ ExpressiveActionGroup(
+ actions = listOf(
+ ExpressiveAction(
+ label = "Test Head Gestures",
+ onClick = {
+ gestureText = gestureTextValue
+ coroutineScope.launch {
+ val accepted = ServiceManager.getService()?.testHeadGestures() ?: false
+ gestureText = if (accepted) "\"Yes\" gesture detected." else "\"No\" gesture detected."
+ }
+ },
+ )
),
+ modifier = Modifier.fillMaxWidth(),
)
}
- Box(
- contentAlignment = Alignment.Center,
- modifier = Modifier.padding(top = 12.dp, bottom = 24.dp)
- ) {
- AnimatedContent(
- targetState = gestureText,
- transitionSpec = {
- (fadeIn(
- animationSpec = tween(300)
- ) + slideInVertically(
- initialOffsetY = { 40 },
- animationSpec = tween(300)
- )).togetherWith(fadeOut(animationSpec = tween(150)))
- }
- ) { text ->
- if (shouldExplode) {
- LaunchedEffect(Unit) {
- CoroutineScope(coroutineScope.coroutineContext).launch {
- delay(750)
- gestureText = ""
+
+ item {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 12.dp, bottom = 24.dp),
+ ) {
+ AnimatedContent(
+ targetState = gestureText,
+ transitionSpec = {
+ (fadeIn(
+ animationSpec = tween(300)
+ ) + slideInVertically(
+ initialOffsetY = { 40 },
+ animationSpec = tween(300)
+ )).togetherWith(fadeOut(animationSpec = tween(150)))
+ }
+ ) { text ->
+ if (shouldExplode) {
+ LaunchedEffect(Unit) {
+ CoroutineScope(coroutineScope.coroutineContext).launch {
+ delay(750)
+ gestureText = ""
+ }
}
+ ParticleText(
+ text = text,
+ style = TextStyle(
+ fontSize = 20.sp,
+ color = particleColor,
+ textAlign = TextAlign.Center
+ ),
+ onAnimationComplete = {
+ shouldExplode = false
+ },
+ )
+ } else {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ )
}
- ParticleText(
- text = text,
- style = TextStyle(
- fontSize = 20.sp,
- fontWeight = FontWeight.Medium,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- color = textColor,
- textAlign = TextAlign.Center
- ),
- onAnimationComplete = {
- shouldExplode = false
- },
- )
- } else {
- Text(
- text = text,
- style = TextStyle(
- fontSize = 20.sp,
- fontWeight = FontWeight.Medium,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- color = textColor,
- textAlign = TextAlign.Center
- ),
- modifier = Modifier
- .fillMaxWidth()
- )
}
}
}
@@ -366,9 +364,36 @@ private fun ParticleText(
@Composable
private fun HeadVisualization() {
val orientation by HeadTracking.orientation.collectAsState()
- val darkTheme = isSystemInDarkTheme()
- val backgroundColor = if (darkTheme) Color(0xFF1C1C1E) else Color.White
- val strokeColor = if (darkTheme) Color.White else Color.Black
+ val backgroundColor = MaterialTheme.colorScheme.surfaceContainer
+ val strokeColor = MaterialTheme.colorScheme.onSurface
+ val labelColor = MaterialTheme.colorScheme.onSurfaceVariant
+
+ // Decouple rendering from the sparse, noisy sensor stream: spring toward each
+ // new orientation so the face glides at the display's refresh rate (120 fps
+ // where available) instead of snapping between raw samples. A lightly-damped,
+ // medium-stiffness spring doubles as a low-pass filter for the sensor jitter
+ // that made the head look "janky", while still tracking quickly enough to feel
+ // live.
+ val animatedPitch by animateFloatAsState(
+ targetValue = orientation.pitch,
+ animationSpec = spring(dampingRatio = 0.9f, stiffness = 350f),
+ label = "headPitch"
+ )
+ val animatedYaw by animateFloatAsState(
+ targetValue = orientation.yaw,
+ animationSpec = spring(dampingRatio = 0.9f, stiffness = 350f),
+ label = "headYaw"
+ )
+
+ // Reusable draw objects — re-allocating native Paint/Path on every frame at
+ // 120 fps churns the heap and reintroduces the very stutter we're removing.
+ val spherePath = remember { Path() }
+ val smilePath = remember { Path() }
+ val facePaint = remember { Paint().apply { style = Paint.Style.FILL } }
+ val highlightPaint = remember { Paint().apply { style = Paint.Style.FILL } }
+ val secondaryHighlightPaint = remember { Paint().apply { style = Paint.Style.FILL } }
+ val shadowPaint = remember { Paint().apply { style = Paint.Style.FILL } }
+ val labelPaint = remember { Paint().apply { isAntiAlias = true } }
Card(
modifier = Modifier
@@ -377,7 +402,7 @@ private fun HeadVisualization() {
colors = CardDefaults.cardColors(
containerColor = backgroundColor
),
- shape = RoundedCornerShape(28.dp)
+ shape = MaterialTheme.shapes.large
) {
Box(
modifier = Modifier.fillMaxSize(),
@@ -393,8 +418,8 @@ private fun HeadVisualization() {
val center = Offset(width / 2, height / 2)
val faceRadius = height * 0.35f
- val pitch = Math.toRadians(orientation.pitch.toDouble())
- val yaw = Math.toRadians(orientation.yaw.toDouble())
+ val pitch = Math.toRadians(animatedPitch.toDouble())
+ val yaw = Math.toRadians(animatedYaw.toDouble())
val cosY = cos(yaw).toFloat()
val sinY = sin(yaw).toFloat()
@@ -436,7 +461,7 @@ private fun HeadVisualization() {
)
}
- val spherePath = Path()
+ spherePath.rewind()
val firstPoint = project(rotate3D(Triple(faceRadius, 0f, 0f)))
spherePath.moveTo(firstPoint.first, firstPoint.second)
@@ -452,75 +477,63 @@ private fun HeadVisualization() {
spherePath.close()
drawContext.canvas.nativeCanvas.apply {
- val paint = Paint().apply {
- style = Paint.Style.FILL
- shader = RadialGradient(
- center.x + sinY * faceRadius * 0.3f,
- center.y - sinP * faceRadius * 0.3f,
- faceRadius * 1.4f,
- intArrayOf(
- backgroundColor.copy(alpha = 1f).toArgb(),
- backgroundColor.copy(alpha = 0.95f).toArgb(),
- backgroundColor.copy(alpha = 0.9f).toArgb(),
- backgroundColor.copy(alpha = 0.8f).toArgb(),
- backgroundColor.copy(alpha = 0.7f).toArgb()
- ),
- floatArrayOf(0.3f, 0.5f, 0.7f, 0.8f, 1f),
- Shader.TileMode.CLAMP
- )
- }
- drawPath(spherePath.asAndroidPath(), paint)
-
- val highlightPaint = Paint().apply {
- style = Paint.Style.FILL
- shader = RadialGradient(
- center.x - faceRadius * 0.4f - sinY * faceRadius * 0.5f,
- center.y - faceRadius * 0.4f - sinP * faceRadius * 0.5f,
- faceRadius * 0.9f,
- intArrayOf(
- android.graphics.Color.WHITE,
- android.graphics.Color.argb(100, 255, 255, 255),
- android.graphics.Color.TRANSPARENT
- ),
- floatArrayOf(0f, 0.3f, 1f),
- Shader.TileMode.CLAMP
- )
- alpha = if (darkTheme) 30 else 60
- }
+ facePaint.shader = RadialGradient(
+ center.x + sinY * faceRadius * 0.3f,
+ center.y - sinP * faceRadius * 0.3f,
+ faceRadius * 1.4f,
+ intArrayOf(
+ backgroundColor.copy(alpha = 1f).toArgb(),
+ backgroundColor.copy(alpha = 0.95f).toArgb(),
+ backgroundColor.copy(alpha = 0.9f).toArgb(),
+ backgroundColor.copy(alpha = 0.8f).toArgb(),
+ backgroundColor.copy(alpha = 0.7f).toArgb()
+ ),
+ floatArrayOf(0.3f, 0.5f, 0.7f, 0.8f, 1f),
+ Shader.TileMode.CLAMP
+ )
+ drawPath(spherePath.asAndroidPath(), facePaint)
+
+ highlightPaint.shader = RadialGradient(
+ center.x - faceRadius * 0.4f - sinY * faceRadius * 0.5f,
+ center.y - faceRadius * 0.4f - sinP * faceRadius * 0.5f,
+ faceRadius * 0.9f,
+ intArrayOf(
+ strokeColor.copy(alpha = 1f).toArgb(),
+ strokeColor.copy(alpha = 0.4f).toArgb(),
+ strokeColor.copy(alpha = 0f).toArgb()
+ ),
+ floatArrayOf(0f, 0.3f, 1f),
+ Shader.TileMode.CLAMP
+ )
+ highlightPaint.alpha = 25
drawPath(spherePath.asAndroidPath(), highlightPaint)
- val secondaryHighlightPaint = Paint().apply {
- style = Paint.Style.FILL
- shader = RadialGradient(
- center.x + faceRadius * 0.3f + sinY * faceRadius * 0.3f,
- center.y + faceRadius * 0.3f - sinP * faceRadius * 0.3f,
- faceRadius * 0.7f,
- intArrayOf(
- android.graphics.Color.WHITE,
- android.graphics.Color.TRANSPARENT
- ),
- floatArrayOf(0f, 1f),
- Shader.TileMode.CLAMP
- )
- alpha = if (darkTheme) 15 else 30
- }
+ secondaryHighlightPaint.shader = RadialGradient(
+ center.x + faceRadius * 0.3f + sinY * faceRadius * 0.3f,
+ center.y + faceRadius * 0.3f - sinP * faceRadius * 0.3f,
+ faceRadius * 0.7f,
+ intArrayOf(
+ strokeColor.copy(alpha = 1f).toArgb(),
+ strokeColor.copy(alpha = 0f).toArgb()
+ ),
+ floatArrayOf(0f, 1f),
+ Shader.TileMode.CLAMP
+ )
+ secondaryHighlightPaint.alpha = 15
drawPath(spherePath.asAndroidPath(), secondaryHighlightPaint)
- val shadowPaint = Paint().apply {
- style = Paint.Style.FILL
- shader = RadialGradient(
- center.x + sinY * faceRadius * 0.5f,
- center.y - sinP * faceRadius * 0.5f,
- faceRadius * 1.1f,
- intArrayOf(
- android.graphics.Color.TRANSPARENT,
- android.graphics.Color.BLACK
- ),
- floatArrayOf(0.7f, 1f),
- Shader.TileMode.CLAMP
- )
- alpha = if (darkTheme) 40 else 20
- }
+ shadowPaint.shader = RadialGradient(
+ center.x + sinY * faceRadius * 0.5f,
+ center.y - sinP * faceRadius * 0.5f,
+ faceRadius * 1.1f,
+ intArrayOf(
+ android.graphics.Color.TRANSPARENT,
+ android.graphics.Color.BLACK
+ ),
+ floatArrayOf(0.7f, 1f),
+ Shader.TileMode.CLAMP
+ )
+ shadowPaint.alpha = 25
drawPath(spherePath.asAndroidPath(), shadowPaint)
}
@@ -535,7 +548,7 @@ private fun HeadVisualization() {
val smileSweepAngle = 140f
val smileOffsetY = faceRadius * 0.1f
- val smilePath = Path()
+ smilePath.rewind()
for (i in 0..32) {
val angle = Math.toRadians(smileStartAngle + (smileSweepAngle * i / 32.0))
val x = cos(angle.toFloat()) * smileRadius
@@ -577,25 +590,17 @@ private fun HeadVisualization() {
}
drawContext.canvas.nativeCanvas.apply {
- val paint = Paint().apply {
- color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
- textSize = 12.sp.toPx()
- textAlign = Paint.Align.RIGHT
- typeface = Typeface.create(
- "SF Pro",
- Typeface.NORMAL
- )
- }
+ labelPaint.color = labelColor.toArgb()
+ labelPaint.textSize = 12.sp.toPx()
+ labelPaint.textAlign = Paint.Align.RIGHT
- val pitch = orientation.pitch.toInt()
- val yaw = orientation.yaw.toInt()
- val text = "Pitch: ${pitch}° Yaw: ${yaw}°"
+ val text = "Pitch: ${animatedPitch.toInt()}° Yaw: ${animatedYaw.toInt()}°"
drawText(
text,
width - 8.dp.toPx(),
height - 8.dp.toPx(),
- paint
+ labelPaint
)
}
}
@@ -608,10 +613,16 @@ private fun AccelerationPlot() {
val acceleration by HeadTracking.acceleration.collectAsState()
val maxPoints = 100
val points = remember { mutableStateListOf>() }
- val darkTheme = isSystemInDarkTheme()
var maxAbs by remember { mutableFloatStateOf(1000f) }
+ val containerColor = MaterialTheme.colorScheme.surfaceContainer
+ val gridColor = MaterialTheme.colorScheme.outlineVariant
+ val axisColor = MaterialTheme.colorScheme.outline
+ val labelColor = MaterialTheme.colorScheme.onSurfaceVariant
+ val horizontalColor = MaterialTheme.colorScheme.primary
+ val verticalColor = MaterialTheme.colorScheme.error
+
LaunchedEffect(acceleration) {
points.add(Pair(acceleration.horizontal, acceleration.vertical))
if (points.size > maxPoints) {
@@ -627,9 +638,9 @@ private fun AccelerationPlot() {
.fillMaxWidth()
.height(300.dp),
colors = CardDefaults.cardColors(
- containerColor = if (darkTheme) Color(0xFF1C1C1E) else Color.White
+ containerColor = containerColor
),
- shape = RoundedCornerShape(28.dp)
+ shape = MaterialTheme.shapes.large
) {
Box(
modifier = Modifier
@@ -645,8 +656,6 @@ private fun AccelerationPlot() {
val yScale = (height - 40.dp.toPx()) / (maxAbs * 2)
val zeroY = height / 2
- val gridColor = if (darkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.1f)
-
for (i in 0..maxPoints step 10) {
val x = i * xScale
drawLine(
@@ -669,7 +678,7 @@ private fun AccelerationPlot() {
}
drawLine(
- color = if (darkTheme) Color.White.copy(alpha = 0.3f) else Color.Black.copy(alpha = 0.3f),
+ color = axisColor,
start = Offset(0f, zeroY),
end = Offset(width, zeroY),
strokeWidth = 1.5f.dp.toPx()
@@ -681,14 +690,14 @@ private fun AccelerationPlot() {
val x2 = (i + 1) * xScale
drawLine(
- color = Color(0xFF007AFF),
+ color = horizontalColor,
start = Offset(x1, zeroY - points[i].first * yScale),
end = Offset(x2, zeroY - points[i + 1].first * yScale),
strokeWidth = 2.dp.toPx()
)
drawLine(
- color = Color(0xFFFF3B30),
+ color = verticalColor,
start = Offset(x1, zeroY - points[i].second * yScale),
end = Offset(x2, zeroY - points[i + 1].second * yScale),
strokeWidth = 2.dp.toPx()
@@ -698,7 +707,7 @@ private fun AccelerationPlot() {
drawContext.canvas.nativeCanvas.apply {
val paint = Paint().apply {
- color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
+ color = labelColor.toArgb()
textSize = 12.sp.toPx()
textAlign = Paint.Align.RIGHT
}
@@ -711,20 +720,20 @@ private fun AccelerationPlot() {
val legendY = 15.dp.toPx()
val textOffsetY = legendY + 5.dp.toPx() / 2
- drawCircle(Color(0xFF007AFF), 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY))
+ drawCircle(horizontalColor, 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY))
drawContext.canvas.nativeCanvas.apply {
val paint = Paint().apply {
- color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
+ color = labelColor.toArgb()
textSize = 12.sp.toPx()
textAlign = Paint.Align.LEFT
}
drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint)
}
- drawCircle(Color(0xFFFF3B30), 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY))
+ drawCircle(verticalColor, 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY))
drawContext.canvas.nativeCanvas.apply {
val paint = Paint().apply {
- color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
+ color = labelColor.toArgb()
textSize = 12.sp.toPx()
textAlign = Paint.Align.LEFT
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidAdjustmentsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidAdjustmentsScreen.kt
index 108e19c0d..d3ebd9919 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidAdjustmentsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidAdjustmentsScreen.kt
@@ -16,20 +16,26 @@
along with this program. If not, see .
*/
+@file:OptIn(ExperimentalMaterial3Api::class)
+
package me.kavishdevar.librepods.presentation.screens
import android.annotation.SuppressLint
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.PaddingValues
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.VolumeMute
+import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -41,35 +47,27 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import com.kyant.backdrop.backdrops.layerBackdrop
-import com.kyant.backdrop.backdrops.rememberLayerBackdrop
-import dev.chrisbanes.haze.HazeState
-import dev.chrisbanes.haze.hazeSource
-import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.data.HearingAidSettings
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.data.sendHearingAidSettings
-import me.kavishdevar.librepods.presentation.components.StyledScaffold
-import me.kavishdevar.librepods.presentation.components.StyledSlider
-import me.kavishdevar.librepods.presentation.components.StyledToggle
+import me.kavishdevar.librepods.presentation.m3.ExpressiveGroup
+import me.kavishdevar.librepods.presentation.m3.ExpressiveScreenScaffold
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSectionHeader
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSlider
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSwitchRow
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
private const val TAG = "HearingAidAdjustments"
@SuppressLint("DefaultLocale")
-@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
- isSystemInDarkTheme()
- val verticalScrollState = rememberScrollState()
- val hazeState = remember { HazeState() }
val state by viewModel.uiState.collectAsState()
- val backdrop = rememberLayerBackdrop()
val debounceJob = remember { mutableStateOf(null) }
@@ -144,81 +142,134 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::setATTCharacteristicValue)
}
- StyledScaffold(title = stringResource(R.string.adjustments)) { spacerHeight ->
- Column(
- modifier = Modifier
- .hazeSource(hazeState)
- .fillMaxSize()
- .layerBackdrop(backdrop)
- .verticalScroll(verticalScrollState)
- .padding(horizontal = 16.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp)
+ ExpressiveScreenScaffold(title = stringResource(R.string.adjustments)) { padding ->
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(
+ top = padding.calculateTopPadding(),
+ bottom = 120.dp,
+ start = 16.dp,
+ end = 16.dp,
+ ),
) {
- Spacer(modifier = Modifier.height(spacerHeight))
-
- StyledSlider(
- label = stringResource(R.string.amplification),
- valueRange = -1f..1f,
- value = amplificationSliderValue.floatValue,
- onValueChange = {
- amplificationSliderValue.floatValue = it
- },
- startIcon = "",
- endIcon = "",
- independent = true,
- )
-
- StyledToggle(
- label = stringResource(R.string.swipe_to_control_amplification),
- checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),
- onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE, it) },
- description = stringResource(R.string.swipe_amplification_description)
- )
-
- StyledSlider(
- label = stringResource(R.string.balance),
- valueRange = -1f..1f,
- value = balanceSliderValue.floatValue,
- onValueChange = {
- balanceSliderValue.floatValue = it
- },
- snapPoints = listOf(-1f, 0f, 1f),
- startLabel = stringResource(R.string.left),
- endLabel = stringResource(R.string.right),
- independent = true,
- )
-
- StyledSlider(
- label = stringResource(R.string.tone),
- valueRange = -1f..1f,
- value = toneSliderValue.floatValue,
- onValueChange = {
- toneSliderValue.floatValue = it
- },
- startLabel = stringResource(R.string.darker),
- endLabel = stringResource(R.string.brighter),
- independent = true,
- )
-
- StyledSlider(
- label = stringResource(R.string.ambient_noise_reduction),
- valueRange = 0f..1f,
- value = ambientNoiseReductionSliderValue.floatValue,
- onValueChange = {
- ambientNoiseReductionSliderValue.floatValue = it
- },
- startLabel = stringResource(R.string.less),
- endLabel = stringResource(R.string.more),
- independent = true,
- )
-
- StyledToggle(
- label = stringResource(R.string.conversation_boost),
- checked = conversationBoostEnabled.value,
- onCheckedChange = { conversationBoostEnabled.value = it },
- independent = true,
- description = stringResource(R.string.conversation_boost_description)
- )
+ item {
+ ExpressiveSectionHeader(stringResource(R.string.amplification))
+ ExpressiveGroup {
+ SliderRow {
+ ExpressiveSlider(
+ value = amplificationSliderValue.floatValue,
+ onValueChange = { amplificationSliderValue.floatValue = it },
+ valueRange = -1f..1f,
+ startIcon = Icons.AutoMirrored.Filled.VolumeMute,
+ endIcon = Icons.AutoMirrored.Filled.VolumeUp,
+ )
+ }
+ }
+ }
+
+ item {
+ ExpressiveGroup(modifier = Modifier.padding(top = 12.dp)) {
+ ExpressiveSwitchRow(
+ title = stringResource(R.string.swipe_to_control_amplification),
+ subtitle = stringResource(R.string.swipe_amplification_description),
+ checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),
+ onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE, it) },
+ )
+ }
+ }
+
+ item {
+ ExpressiveSectionHeader(stringResource(R.string.balance))
+ ExpressiveGroup {
+ SliderRow(
+ startLabel = stringResource(R.string.left),
+ endLabel = stringResource(R.string.right),
+ ) {
+ ExpressiveSlider(
+ value = balanceSliderValue.floatValue,
+ onValueChange = { balanceSliderValue.floatValue = it },
+ valueRange = -1f..1f,
+ )
+ }
+ }
+ }
+
+ item {
+ ExpressiveSectionHeader(stringResource(R.string.tone))
+ ExpressiveGroup {
+ SliderRow(
+ startLabel = stringResource(R.string.darker),
+ endLabel = stringResource(R.string.brighter),
+ ) {
+ ExpressiveSlider(
+ value = toneSliderValue.floatValue,
+ onValueChange = { toneSliderValue.floatValue = it },
+ valueRange = -1f..1f,
+ )
+ }
+ }
+ }
+
+ item {
+ ExpressiveSectionHeader(stringResource(R.string.ambient_noise_reduction))
+ ExpressiveGroup {
+ SliderRow(
+ startLabel = stringResource(R.string.less),
+ endLabel = stringResource(R.string.more),
+ ) {
+ ExpressiveSlider(
+ value = ambientNoiseReductionSliderValue.floatValue,
+ onValueChange = { ambientNoiseReductionSliderValue.floatValue = it },
+ valueRange = 0f..1f,
+ )
+ }
+ }
+ }
+
+ item {
+ ExpressiveGroup(modifier = Modifier.padding(top = 12.dp)) {
+ ExpressiveSwitchRow(
+ title = stringResource(R.string.conversation_boost),
+ subtitle = stringResource(R.string.conversation_boost_description),
+ checked = conversationBoostEnabled.value,
+ onCheckedChange = { conversationBoostEnabled.value = it },
+ )
+ }
+ }
+ }
+ }
+}
+
+/** A slider hosted inside an [ExpressiveGroup] with optional left/right captions beneath it. */
+@Composable
+private fun SliderRow(
+ startLabel: String? = null,
+ endLabel: String? = null,
+ slider: @Composable () -> Unit,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ slider()
+ if (startLabel != null || endLabel != null) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ text = startLabel.orEmpty(),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = endLabel.orEmpty(),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
}
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidScreen.kt
index 3cb72ce40..2aca15172 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingAidScreen.kt
@@ -16,26 +16,27 @@
along with this program. If not, see .
*/
+@file:OptIn(ExperimentalMaterial3Api::class)
+
package me.kavishdevar.librepods.presentation.screens
import android.annotation.SuppressLint
import android.util.Log
-import androidx.compose.foundation.background
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
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.rememberScrollState
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.verticalScroll
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Hearing
+import androidx.compose.material.icons.filled.MonitorHeart
+import androidx.compose.material.icons.filled.Tune
+import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.HorizontalDivider
-import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -43,21 +44,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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
-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 com.kyant.backdrop.backdrops.layerBackdrop
-import com.kyant.backdrop.backdrops.rememberLayerBackdrop
-import dev.chrisbanes.haze.HazeState
-import dev.chrisbanes.haze.hazeSource
-import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -65,27 +54,22 @@ import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
import me.kavishdevar.librepods.data.sendTransparencySettings
-import me.kavishdevar.librepods.presentation.components.ConfirmationDialog
-import me.kavishdevar.librepods.presentation.components.NavigationButton
-import me.kavishdevar.librepods.presentation.components.StyledScaffold
-import me.kavishdevar.librepods.presentation.components.StyledToggle
+import me.kavishdevar.librepods.presentation.m3.ExpressiveDivider
+import me.kavishdevar.librepods.presentation.m3.ExpressiveGroup
+import me.kavishdevar.librepods.presentation.m3.ExpressiveListItem
+import me.kavishdevar.librepods.presentation.m3.ExpressiveScreenScaffold
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSectionHeader
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSwitchRow
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
private const val TAG = "AccessibilitySettings"
@SuppressLint("DefaultLocale")
-@ExperimentalHazeMaterialsApi
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
+@OptIn(ExperimentalEncodingApi::class)
@Composable
fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController) {
- val isDarkTheme = isSystemInDarkTheme()
- val textColor = if (isDarkTheme) Color.White else Color.Black
- val verticalScrollState = rememberScrollState()
- val snackbarHostState = remember { SnackbarHostState() }
-
val showDialog = remember { mutableStateOf(false) }
- val backdrop = rememberLayerBackdrop()
val initialLoad = remember { mutableStateOf(true) }
val state by viewModel.uiState.collectAsState()
@@ -96,185 +80,117 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
mutableStateOf((aidStatus?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.getOrNull(0) == 0x01.toByte()))
}
- val hazeStateS = remember { mutableStateOf(HazeState()) } // dont question this. i could possibly use something other than initializing it with an empty state and then replacing it with the the one provided by the scaffold
+ LaunchedEffect(hearingAidEnabled.value) {
+ if (hearingAidEnabled.value && !initialLoad.value) {
+ showDialog.value = true
+ } else if (!hearingAidEnabled.value && !initialLoad.value) {
+ viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x02))
+ viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x02.toByte())
+ hearingAidEnabled.value = false
+ }
+ initialLoad.value = false
+ }
- StyledScaffold(
+ ExpressiveScreenScaffold(
title = stringResource(R.string.hearing_aid),
- snackbarHostState = snackbarHostState,
- ) { topPadding, hazeState, bottomPadding ->
- Column(
- modifier = Modifier
- .layerBackdrop(backdrop)
- .hazeSource(hazeState)
- .fillMaxSize()
- .verticalScroll(verticalScrollState)
- .padding(horizontal = 16.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp)
+ onBack = { navController.popBackStack() },
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(
+ top = padding.calculateTopPadding(),
+ bottom = 120.dp,
+ start = 16.dp,
+ end = 16.dp,
+ ),
) {
- hazeStateS.value = hazeState
- Spacer(modifier = Modifier.height(topPadding))
-
-// val mediaAssistEnabled = remember { mutableStateOf(false) }
-// val adjustMediaEnabled = remember { mutableStateOf(false) }
-// val adjustPhoneEnabled = remember { mutableStateOf(false) }
-
- LaunchedEffect(hearingAidEnabled.value) {
- if (hearingAidEnabled.value && !initialLoad.value) {
- showDialog.value = true
- } else if (!hearingAidEnabled.value && !initialLoad.value) {
- viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x02))
- viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x02.toByte())
- hearingAidEnabled.value = false
+ item {
+ ExpressiveSectionHeader(stringResource(R.string.hearing_aid))
+ ExpressiveGroup {
+ ExpressiveSwitchRow(
+ title = stringResource(R.string.hearing_aid),
+ leadingIcon = Icons.Filled.Hearing,
+ checked = hearingAidEnabled.value,
+ onCheckedChange = { hearingAidEnabled.value = it },
+ )
+ ExpressiveDivider()
+ ExpressiveListItem(
+ title = stringResource(R.string.adjustments),
+ leadingIcon = Icons.Filled.Tune,
+ showChevron = true,
+ onClick = { navController.navigate("hearing_aid_adjustments") },
+ )
}
- initialLoad.value = false
+ Text(
+ text = stringResource(R.string.hearing_aid_description),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 12.dp, end = 12.dp, top = 8.dp),
+ )
}
-// fun onAdjustPhoneChange(value: Boolean) {
-// // TODO
-// }
-
-// fun onAdjustMediaChange(value: Boolean) {
-// // TODO
-// }
-
- Text(
- text = stringResource(R.string.hearing_aid),
- style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- modifier = Modifier.padding(16.dp, bottom = 2.dp)
- )
-
- val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
-
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .background(backgroundColor, RoundedCornerShape(28.dp))
- .clip(
- RoundedCornerShape(28.dp)
+ item {
+ Spacer(Modifier.height(8.dp))
+ ExpressiveGroup {
+ ExpressiveListItem(
+ title = stringResource(R.string.update_hearing_test),
+ leadingIcon = Icons.Filled.MonitorHeart,
+ showChevron = true,
+ onClick = { navController.navigate("update_hearing_test") },
)
- ) {
- StyledToggle(
- label = stringResource(R.string.hearing_aid),
- checked = hearingAidEnabled.value,
- onCheckedChange = { hearingAidEnabled.value = it },
- independent = false
- )
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier
- .padding(horizontal = 12.dp)
- )
- NavigationButton(
- to = "hearing_aid_adjustments",
- name = stringResource(R.string.adjustments),
- navController = navController,
- independent = false
- )
+ }
}
- Text(
- text = stringResource(R.string.hearing_aid_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 = 16.dp)
- )
- Spacer(modifier = Modifier.height(16.dp))
-
- NavigationButton(
- to = "update_hearing_test",
- name = stringResource(R.string.update_hearing_test),
- navController = navController,
- independent = true
- )
-
- // not implemented yet
-
- // StyledToggle(
- // title = stringResource(R.string.media_assist),
- // label = stringResource(R.string.media_assist),
- // checkedState = mediaAssistEnabled,
- // independent = true,
- // description = stringResource(R.string.media_assist_description)
- // )
-
- // Spacer(modifier = Modifier.height(8.dp))
-
- // Column (
- // modifier = Modifier
- // .fillMaxWidth()
- // .background(backgroundColor, RoundedCornerShape(28.dp))
- // ) {
- // StyledToggle(
- // label = stringResource(R.string.adjust_media),
- // checkedState = adjustMediaEnabled,
- // onCheckedChange = { onAdjustMediaChange(it) },
- // independent = false
- // )
- // HorizontalDivider(
- // thickness = 1.dp,
- // color = Color(0x40888888),
- // modifier = Modifier
- // .padding(horizontal = 12.dp)
- // )
-
- // StyledToggle(
- // label = stringResource(R.string.adjust_calls),
- // checkedState = adjustPhoneEnabled,
- // onCheckedChange = { onAdjustPhoneChange(it) },
- // independent = false
- // )
- // }
- Spacer(modifier = Modifier.height(bottomPadding))
}
}
- ConfirmationDialog(
- showDialog = showDialog,
- title = "Enable Hearing Aid",
- message = "Enabling Hearing Aid will disable Headphone Accommodation and Customized Transparency Mode.",
- confirmText = "Enable",
- dismissText = "Cancel",
- onConfirm = {
- showDialog.value = false
- val enrolled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(0) == 0x01.toByte()
- if (!enrolled) {
- viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01))
- } else {
- viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01))
- }
- viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x01.toByte())
- hearingAidEnabled.value = true
- CoroutineScope(Dispatchers.IO).launch {
- try {
- if (state.hearingAidData.isEmpty()) {
- Log.w(TAG, "read failed")
- return@launch
+
+ if (showDialog.value) {
+ AlertDialog(
+ onDismissRequest = {
+ hearingAidEnabled.value = false
+ showDialog.value = false
+ },
+ title = { Text("Enable Hearing Aid") },
+ text = { Text("Enabling Hearing Aid will disable Headphone Accommodation and Customized Transparency Mode.") },
+ confirmButton = {
+ TextButton(onClick = {
+ showDialog.value = false
+ val enrolled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(0) == 0x01.toByte()
+ if (!enrolled) {
+ viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01))
+ } else {
+ viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01))
}
- val parsed = parseTransparencySettingsResponse(state.hearingAidData)
- if (parsed == null) {
- Log.w(TAG, "transparency parse failed")
- return@launch
+ viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x01.toByte())
+ hearingAidEnabled.value = true
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ if (state.hearingAidData.isEmpty()) {
+ Log.w(TAG, "read failed")
+ return@launch
+ }
+ val parsed = parseTransparencySettingsResponse(state.hearingAidData)
+ if (parsed == null) {
+ Log.w(TAG, "transparency parse failed")
+ return@launch
+ }
+ val disabledSettings = parsed.copy(enabled = false)
+ sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error disabling transparency: ${e.message}")
+ }
}
- val disabledSettings = parsed.copy(enabled = false)
- sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings)
- } catch (e: Exception) {
- Log.e(TAG, "Error disabling transparency: ${e.message}")
+ }) {
+ Text("Enable")
}
- }
- },
- onDismiss = {
- hearingAidEnabled.value = false
- showDialog.value = false
- },
-// hazeState = hazeStateS.value,
- backdrop = backdrop
- )
+ },
+ dismissButton = {
+ TextButton(onClick = {
+ hearingAidEnabled.value = false
+ showDialog.value = false
+ }) {
+ Text("Cancel")
+ }
+ },
+ )
+ }
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingProtectionScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingProtectionScreen.kt
index 42b04660b..b048dc04a 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingProtectionScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/HearingProtectionScreen.kt
@@ -18,82 +18,86 @@
package me.kavishdevar.librepods.presentation.screens
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.PaddingValues
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.Text
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.HearingDisabled
+import androidx.compose.material.icons.filled.VolumeOff
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.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 com.kyant.backdrop.backdrops.layerBackdrop
-import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles
-import me.kavishdevar.librepods.presentation.components.StyledButton
-import me.kavishdevar.librepods.presentation.components.StyledScaffold
-import me.kavishdevar.librepods.presentation.components.StyledToggle
+import me.kavishdevar.librepods.presentation.m3.ExpressiveGroup
+import me.kavishdevar.librepods.presentation.m3.ExpressiveScreenScaffold
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSectionHeader
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSwitchRow
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun HearingProtectionScreen(viewModel: AirPodsViewModel, navController: NavController) {
- val backdrop = rememberLayerBackdrop()
val state by viewModel.uiState.collectAsState()
- StyledScaffold(
+
+ val ppeChecked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG]
+ ?.getOrNull(0)?.toInt() == 1
+
+ ExpressiveScreenScaffold(
title = stringResource(R.string.hearing_protection),
- ) { spacerHeight ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .layerBackdrop(backdrop)
- .padding(horizontal = 16.dp)
+ onBack = { navController.popBackStack() },
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(
+ top = padding.calculateTopPadding(),
+ bottom = 120.dp,
+ start = 16.dp,
+ end = 16.dp,
+ ),
) {
- Spacer(modifier = Modifier.height(spacerHeight))
if (state.vendorIdHook) {
- StyledToggle(
- title = stringResource(R.string.environmental_noise),
- label = stringResource(R.string.loud_sound_reduction),
- description = stringResource(R.string.loud_sound_reduction_description),
- checked = state.loudSoundReductionEnabled,
- onCheckedChange = {
- viewModel.setATTCharacteristicValue(
- ATTHandles.LOUD_SOUND_REDUCTION,
- byteArrayOf(if (it) 1.toByte() else 0.toByte())
+ item(key = "environmental_noise") {
+ ExpressiveSectionHeader(stringResource(R.string.environmental_noise))
+ ExpressiveGroup {
+ ExpressiveSwitchRow(
+ title = stringResource(R.string.loud_sound_reduction),
+ subtitle = stringResource(R.string.loud_sound_reduction_description),
+ leadingIcon = Icons.Filled.VolumeOff,
+ checked = state.loudSoundReductionEnabled,
+ onCheckedChange = {
+ viewModel.setATTCharacteristicValue(
+ ATTHandles.LOUD_SOUND_REDUCTION,
+ byteArrayOf(if (it) 1.toByte() else 0.toByte())
+ )
+ },
+ enabled = state.isPremium,
)
- },
- enabled = state.isPremium
- )
-
- Spacer(modifier = Modifier.height(12.dp))
+ }
+ }
}
- StyledToggle(
- title = stringResource(R.string.workspace_use),
- label = stringResource(R.string.ppe),
- description = stringResource(R.string.workspace_use_description),
- checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG]?.getOrNull(
- 0
- )?.toInt() == 1,
- onCheckedChange = {
- viewModel.setControlCommandBoolean(
- AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, it
+
+ item(key = "workspace_use") {
+ ExpressiveSectionHeader(stringResource(R.string.workspace_use))
+ ExpressiveGroup {
+ ExpressiveSwitchRow(
+ title = stringResource(R.string.ppe),
+ subtitle = stringResource(R.string.workspace_use_description),
+ leadingIcon = Icons.Filled.HearingDisabled,
+ checked = ppeChecked,
+ onCheckedChange = {
+ viewModel.setControlCommandBoolean(
+ AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, it
+ )
+ },
+ enabled = state.isPremium,
)
- },
- enabled = state.isPremium
- )
+ }
+ }
}
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/OpenSourceLicensesScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/OpenSourceLicensesScreen.kt
index 8f57cdb56..9f47aabfc 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/OpenSourceLicensesScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/OpenSourceLicensesScreen.kt
@@ -16,15 +16,12 @@
along with this program. If not, see .
*/
+@file:OptIn(ExperimentalMaterial3Api::class)
+
package me.kavishdevar.librepods.presentation.screens
-import android.annotation.SuppressLint
-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.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
@@ -34,49 +31,35 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
-import com.kyant.backdrop.backdrops.layerBackdrop
-import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
import com.mikepenz.aboutlibraries.ui.compose.produceLibraries
-import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
-import kotlinx.coroutines.Job
import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.presentation.components.StyledScaffold
-import kotlin.io.encoding.ExperimentalEncodingApi
-
-private var debounceJob: Job? = null
+import me.kavishdevar.librepods.presentation.m3.ExpressiveScreenScaffold
-@SuppressLint("DefaultLocale")
-@ExperimentalHazeMaterialsApi
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun OpenSourceLicensesScreen(navController: NavController) {
- val isDarkTheme = isSystemInDarkTheme()
- val backdrop = rememberLayerBackdrop()
+ val context = LocalContext.current
+ val libraries by produceLibraries {
+ context.resources.openRawResource(R.raw.aboutlibraries)
+ .bufferedReader()
+ .use { it.readText() }
+ }
- StyledScaffold(
- title = stringResource(R.string.open_source_licenses)
- ) { spacerHeight ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .layerBackdrop(backdrop)
- .padding(horizontal = 16.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- Spacer(modifier = Modifier.height(spacerHeight))
- val context = LocalContext.current
- val libraries by produceLibraries {
- context.resources.openRawResource(R.raw.aboutlibraries)
- .bufferedReader()
- .use { it.readText() }
- }
- LibrariesContainer(
- libraries = libraries,
- modifier = Modifier
- .padding(0.dp)
- .fillMaxSize()
- )
- }
+ ExpressiveScreenScaffold(
+ title = stringResource(R.string.open_source_licenses),
+ onBack = { navController.popBackStack() },
+ ) { padding ->
+ // LibrariesContainer scrolls internally, so apply the scaffold top inset as content
+ // padding and reserve bottom space so the last row clears the floating nav bar.
+ LibrariesContainer(
+ libraries = libraries,
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(
+ top = padding.calculateTopPadding(),
+ bottom = 120.dp,
+ start = 16.dp,
+ end = 16.dp,
+ ),
+ )
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PressAndHoldSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PressAndHoldSettingsScreen.kt
index c09df1206..d291405bf 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PressAndHoldSettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PressAndHoldSettingsScreen.kt
@@ -16,43 +16,46 @@
along with this program. If not, see .
*/
-@file:OptIn(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class)
+@file:OptIn(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class, ExperimentalMaterial3Api::class)
package me.kavishdevar.librepods.presentation.screens
import android.util.Log
-import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
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.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.GraphicEq
+import androidx.compose.material.icons.filled.Hearing
+import androidx.compose.material.icons.filled.HearingDisabled
+import androidx.compose.material.icons.filled.NoiseAware
+import androidx.compose.material.icons.filled.RecordVoiceOver
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
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 com.kyant.backdrop.backdrops.layerBackdrop
-import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.data.StemAction
-import me.kavishdevar.librepods.presentation.components.SelectItem
-import me.kavishdevar.librepods.presentation.components.StyledButton
-import me.kavishdevar.librepods.presentation.components.StyledScaffold
-import me.kavishdevar.librepods.presentation.components.StyledSelectList
+import me.kavishdevar.librepods.presentation.m3.ExpressiveDivider
+import me.kavishdevar.librepods.presentation.m3.ExpressiveGroup
+import me.kavishdevar.librepods.presentation.m3.ExpressiveListItem
+import me.kavishdevar.librepods.presentation.m3.ExpressiveScreenScaffold
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSectionHeader
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSwitchRow
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.experimental.and
import kotlin.io.encoding.ExperimentalEncodingApi
@@ -61,9 +64,6 @@ import kotlin.io.encoding.ExperimentalEncodingApi
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavController) {
- val isDarkTheme = isSystemInDarkTheme()
- val textColor = if (isDarkTheme) Color.White else Color.Black
-
val state by viewModel.uiState.collectAsState()
val modesByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0) ?: 0
@@ -75,113 +75,131 @@ fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavContr
Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
val longPressAction = if (name.lowercase() == "left") state.leftAction else state.rightAction
- val backdrop = rememberLayerBackdrop()
- StyledScaffold(
- title = name
- ) { spacerHeight ->
- Column (
- modifier = Modifier
- .layerBackdrop(backdrop)
- .fillMaxSize()
- .padding(top = 8.dp)
- .padding(horizontal = 16.dp)
+ val cycleSelected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES
+
+ ExpressiveScreenScaffold(
+ title = name,
+ onBack = { navController.popBackStack() },
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(
+ top = padding.calculateTopPadding(),
+ bottom = 120.dp,
+ start = 16.dp,
+ end = 16.dp,
+ ),
) {
- Spacer(modifier = Modifier.height(spacerHeight))
- val actionItems = listOf(
- SelectItem(
- name = stringResource(R.string.noise_control),
- selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES,
- onClick = {
- viewModel.setLongPressAction(name, StemAction.CYCLE_NOISE_CONTROL_MODES)
- }
- ),
- SelectItem(
- name = stringResource(R.string.digital_assistant),
- selected = longPressAction == StemAction.DIGITAL_ASSISTANT,
- onClick = {
- viewModel.setLongPressAction(name, StemAction.DIGITAL_ASSISTANT)
- },
- enabled = state.isPremium
- )
- )
- StyledSelectList(items = actionItems)
-
- if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) {
- Spacer(modifier = Modifier.height(32.dp))
- Text(
- text = stringResource(R.string.noise_control),
- style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- modifier = Modifier
- .padding(horizontal = 18.dp)
- )
-
- Spacer(modifier = Modifier.height(8.dp))
+ item {
+ ExpressiveSectionHeader("Press & hold")
+ ExpressiveGroup {
+ ExpressiveListItem(
+ title = stringResource(R.string.noise_control),
+ leadingIcon = Icons.Filled.NoiseAware,
+ trailing = if (cycleSelected) {
+ { SelectedCheck() }
+ } else null,
+ onClick = {
+ viewModel.setLongPressAction(name, StemAction.CYCLE_NOISE_CONTROL_MODES)
+ },
+ )
+ ExpressiveDivider()
+ ExpressiveListItem(
+ title = stringResource(R.string.digital_assistant),
+ leadingIcon = Icons.Filled.RecordVoiceOver,
+ enabled = state.isPremium,
+ trailing = if (longPressAction == StemAction.DIGITAL_ASSISTANT) {
+ { SelectedCheck() }
+ } else null,
+ onClick = {
+ viewModel.setLongPressAction(name, StemAction.DIGITAL_ASSISTANT)
+ },
+ )
+ }
+ }
+ if (cycleSelected) {
val currentByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0)?.toInt() ?: 0
- val listeningModeItems = mutableListOf()
- if (state.offListeningMode) {
- listeningModeItems.add(
- SelectItem(
- name = stringResource(R.string.off),
- description = stringResource(R.string.listening_mode_off_description),
- iconRes = R.drawable.noise_cancellation,
- selected = (currentByte and 0x01) != 0,
- onClick = {
- viewModel.toggleListeningMode(0x01)
- }
+ item {
+ ExpressiveSectionHeader(stringResource(R.string.noise_control))
+ ExpressiveGroup {
+ var first = true
+
+ if (state.offListeningMode) {
+ ListeningModeToggle(
+ title = stringResource(R.string.off),
+ subtitle = stringResource(R.string.listening_mode_off_description),
+ icon = Icons.Filled.HearingDisabled,
+ checked = (currentByte and 0x01) != 0,
+ onCheckedChange = { viewModel.toggleListeningMode(0x01) },
+ )
+ first = false
+ }
+
+ if (!first) ExpressiveDivider()
+ ListeningModeToggle(
+ title = stringResource(R.string.transparency),
+ subtitle = stringResource(R.string.listening_mode_transparency_description),
+ icon = Icons.Filled.Hearing,
+ checked = (currentByte and 0x04) != 0,
+ onCheckedChange = { viewModel.toggleListeningMode(0x04) },
+ )
+
+ ExpressiveDivider()
+ ListeningModeToggle(
+ title = stringResource(R.string.adaptive),
+ subtitle = stringResource(R.string.listening_mode_adaptive_description),
+ icon = Icons.Filled.GraphicEq,
+ checked = (currentByte and 0x08) != 0,
+ onCheckedChange = { viewModel.toggleListeningMode(0x08) },
+ )
+
+ ExpressiveDivider()
+ ListeningModeToggle(
+ title = stringResource(R.string.noise_cancellation),
+ subtitle = stringResource(R.string.listening_mode_noise_cancellation_description),
+ icon = Icons.Filled.NoiseAware,
+ checked = (currentByte and 0x02) != 0,
+ onCheckedChange = { viewModel.toggleListeningMode(0x02) },
)
+ }
+
+ Spacer(Modifier.height(12.dp))
+ Text(
+ text = stringResource(R.string.press_and_hold_noise_control_description),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 12.dp),
)
}
- listeningModeItems.addAll(listOf(
- SelectItem(
- name = stringResource(R.string.transparency),
- description = stringResource(R.string.listening_mode_transparency_description),
- iconRes = R.drawable.transparency,
- selected = (currentByte and 0x04) != 0,
- onClick = {
- viewModel.toggleListeningMode(0x04)
- }
- ),
- SelectItem(
- name = stringResource(R.string.adaptive),
- description = stringResource(R.string.listening_mode_adaptive_description),
- iconRes = R.drawable.adaptive,
- selected = (currentByte and 0x08) != 0,
- onClick = {
- viewModel.toggleListeningMode(0x08)
- }
- ),
- SelectItem(
- name = stringResource(R.string.noise_cancellation),
- description = stringResource(R.string.listening_mode_noise_cancellation_description),
- iconRes = R.drawable.noise_cancellation,
- selected = (currentByte and 0x02) != 0,
- onClick = {
- viewModel.toggleListeningMode(0x02)
- }
- )
- ))
- StyledSelectList(items = listeningModeItems)
- Spacer(modifier = Modifier.height(8.dp))
- Text(
- text = stringResource(R.string.press_and_hold_noise_control_description),
- style = TextStyle(
- fontSize = 12.sp,
- fontWeight = FontWeight.Light,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- modifier = Modifier
- .padding(horizontal = 18.dp)
- )
}
}
}
}
+
+@Composable
+private fun SelectedCheck() {
+ Icon(
+ imageVector = Icons.Filled.Check,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ )
+}
+
+@Composable
+private fun ListeningModeToggle(
+ title: String,
+ subtitle: String,
+ icon: ImageVector,
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+) {
+ ExpressiveSwitchRow(
+ title = title,
+ subtitle = subtitle,
+ leadingIcon = icon,
+ checked = checked,
+ onCheckedChange = onCheckedChange,
+ )
+}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/RenameScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/RenameScreen.kt
index d06177b6d..815db9d7a 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/RenameScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/RenameScreen.kt
@@ -16,37 +16,43 @@
along with this program. If not, see .
*/
-@file:OptIn(ExperimentalEncodingApi::class)
+@file:OptIn(ExperimentalEncodingApi::class, ExperimentalMaterial3Api::class)
package me.kavishdevar.librepods.presentation.screens
import android.content.Context
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.rememberTextFieldState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.DriveFileRenameOutline
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
-import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.presentation.components.StyledInputField
-import me.kavishdevar.librepods.presentation.components.StyledScaffold
+import me.kavishdevar.librepods.presentation.m3.ExpressiveScreenScaffold
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSectionHeader
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RenameScreen(viewModel: AirPodsViewModel) {
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
@@ -58,28 +64,42 @@ fun RenameScreen(viewModel: AirPodsViewModel) {
keyboardController?.show()
}
- StyledScaffold(
- title = stringResource(R.string.name),
- ) { spacerHeight ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .padding(horizontal = 16.dp)
- ) {
- Spacer(modifier = Modifier.height(spacerHeight))
+ val name = sharedPreferences.getString("name", "") ?: ""
+ val textFieldState = rememberTextFieldState(initialText = name)
- val name = sharedPreferences.getString("name", "")?: ""
- val textFieldState = rememberTextFieldState(initialText = name)
-
- LaunchedEffect(textFieldState.text) {
- sharedPreferences.edit {putString("name", textFieldState.text as String?)}
- viewModel.setName(textFieldState.text.toString())
- }
+ LaunchedEffect(textFieldState.text) {
+ sharedPreferences.edit { putString("name", textFieldState.text as String?) }
+ viewModel.setName(textFieldState.text.toString())
+ }
- StyledInputField(
- textFieldState,
- focusRequester
- )
+ ExpressiveScreenScaffold(title = stringResource(R.string.name)) { padding ->
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(
+ top = padding.calculateTopPadding(),
+ bottom = 120.dp,
+ start = 16.dp,
+ end = 16.dp,
+ ),
+ ) {
+ item {
+ ExpressiveSectionHeader(stringResource(R.string.name))
+ OutlinedTextField(
+ state = textFieldState,
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester),
+ label = { Text(stringResource(R.string.name)) },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Filled.DriveFileRenameOutline,
+ contentDescription = null,
+ )
+ },
+ lineLimits = TextFieldLineLimits.SingleLine,
+ textStyle = MaterialTheme.typography.bodyLarge,
+ )
+ }
}
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TransparencySettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TransparencySettingsScreen.kt
index 526771212..cf60005eb 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TransparencySettingsScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TransparencySettingsScreen.kt
@@ -21,25 +21,23 @@ package me.kavishdevar.librepods.presentation.screens
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
import android.annotation.SuppressLint
import android.util.Log
-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.PaddingValues
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.size
-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.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.VolumeMute
+import androidx.compose.material.icons.automirrored.filled.VolumeUp
+import androidx.compose.material.icons.filled.Equalizer
+import androidx.compose.material.icons.filled.GraphicEq
+import androidx.compose.material.icons.filled.Hearing
+import androidx.compose.material.icons.filled.RecordVoiceOver
+import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Slider
-import androidx.compose.material3.SliderDefaults
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -50,254 +48,267 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.shadow
-import androidx.compose.ui.graphics.Color
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 com.kyant.backdrop.backdrops.layerBackdrop
-import com.kyant.backdrop.backdrops.rememberLayerBackdrop
-import dev.chrisbanes.haze.hazeSource
-import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.TransparencySettings
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
import me.kavishdevar.librepods.data.sendTransparencySettings
-import me.kavishdevar.librepods.presentation.components.StyledScaffold
-import me.kavishdevar.librepods.presentation.components.StyledSlider
-import me.kavishdevar.librepods.presentation.components.StyledToggle
+import me.kavishdevar.librepods.presentation.m3.ExpressiveDivider
+import me.kavishdevar.librepods.presentation.m3.ExpressiveGroup
+import me.kavishdevar.librepods.presentation.m3.ExpressiveListItem
+import me.kavishdevar.librepods.presentation.m3.ExpressiveScreenScaffold
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSectionHeader
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSlider
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSwitchRow
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi
private const val TAG = "TransparencySettings"
+/** A small left/right caption pair shown under a slider, replacing the legacy slider's startLabel/endLabel. */
+@Composable
+private fun SliderEndLabels(start: String, end: String) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Text(
+ text = start,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = end,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+}
+
@SuppressLint("DefaultLocale")
-@ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable
fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
- val isDarkTheme = isSystemInDarkTheme()
- val textColor = if (isDarkTheme) Color.White else Color.Black
- val verticalScrollState = rememberScrollState()
-
- val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
- val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
- val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
-
- val backdrop = rememberLayerBackdrop()
-
-
val state by viewModel.uiState.collectAsState()
- StyledScaffold(
- title = stringResource(R.string.customize_transparency_mode)
- ){ topPadding, hazeState, bottomPadding ->
- Column(
- modifier = Modifier
- .hazeSource(hazeState)
- .layerBackdrop(backdrop)
- .fillMaxSize()
- .verticalScroll(verticalScrollState)
- .padding(horizontal = 16.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- Spacer(modifier = Modifier.height(topPadding))
- val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
-
- val enabled = rememberSaveable { mutableStateOf(false) }
- val amplificationSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
- val balanceSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
- val toneSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
- val ambientNoiseReductionSliderValue = rememberSaveable { mutableFloatStateOf(0.0f) }
- val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
- val eq = rememberSaveable(
- saver = Saver(
- save = { it.value.toList() },
- restore = { mutableStateOf(it.toFloatArray()) }
- )
- ) { mutableStateOf(FloatArray(8)) }
- val phoneMediaEQ = rememberSaveable(
- saver = Saver(
- save = { it.value.toList() },
- restore = { mutableStateOf(it.toFloatArray()) }
- )
- ) { mutableStateOf(FloatArray(8) { 0.5f }) }
+ val enabled = rememberSaveable { mutableStateOf(false) }
+ val amplificationSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
+ val balanceSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
+ val toneSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
+ val ambientNoiseReductionSliderValue = rememberSaveable { mutableFloatStateOf(0.0f) }
+ val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
+ val eq = rememberSaveable(
+ saver = Saver(
+ save = { it.value.toList() },
+ restore = { mutableStateOf(it.toFloatArray()) }
+ )
+ ) { mutableStateOf(FloatArray(8)) }
+ val phoneMediaEQ = rememberSaveable(
+ saver = Saver(
+ save = { it.value.toList() },
+ restore = { mutableStateOf(it.toFloatArray()) }
+ )
+ ) { mutableStateOf(FloatArray(8) { 0.5f }) }
- val initialized = rememberSaveable { mutableStateOf(false) }
+ val initialized = rememberSaveable { mutableStateOf(false) }
- 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 transparencySettings = remember {
+ mutableStateOf(
+ TransparencySettings(
+ enabled = enabled.value,
+ leftEQ = eq.value,
+ rightEQ = eq.value,
+ leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2,
+ rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2,
+ leftTone = toneSliderValue.floatValue,
+ rightTone = toneSliderValue.floatValue,
+ leftConversationBoost = conversationBoostEnabled.value,
+ rightConversationBoost = conversationBoostEnabled.value,
+ leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
+ rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
+ netAmplification = amplificationSliderValue.floatValue,
+ balance = balanceSliderValue.floatValue
+ )
+ )
+ }
- LaunchedEffect(
- enabled.value,
- amplificationSliderValue.floatValue,
- balanceSliderValue.floatValue,
- toneSliderValue.floatValue,
- conversationBoostEnabled.value,
- ambientNoiseReductionSliderValue.floatValue,
- eq.value
- ) {
- if (!initialized.value) return@LaunchedEffect
- transparencySettings.value = TransparencySettings(
- enabled = enabled.value,
- leftEQ = eq.value,
- rightEQ = eq.value,
- leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
- rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f,
- leftTone = toneSliderValue.floatValue,
- rightTone = toneSliderValue.floatValue,
- leftConversationBoost = conversationBoostEnabled.value,
- rightConversationBoost = conversationBoostEnabled.value,
- leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
- rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
- netAmplification = amplificationSliderValue.floatValue,
- balance = balanceSliderValue.floatValue
- )
- Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}")
- sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
- }
+ LaunchedEffect(
+ enabled.value,
+ amplificationSliderValue.floatValue,
+ balanceSliderValue.floatValue,
+ toneSliderValue.floatValue,
+ conversationBoostEnabled.value,
+ ambientNoiseReductionSliderValue.floatValue,
+ eq.value
+ ) {
+ if (!initialized.value) return@LaunchedEffect
+ transparencySettings.value = TransparencySettings(
+ enabled = enabled.value,
+ leftEQ = eq.value,
+ rightEQ = eq.value,
+ leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
+ rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f,
+ leftTone = toneSliderValue.floatValue,
+ rightTone = toneSliderValue.floatValue,
+ leftConversationBoost = conversationBoostEnabled.value,
+ rightConversationBoost = conversationBoostEnabled.value,
+ leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
+ rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
+ netAmplification = amplificationSliderValue.floatValue,
+ balance = balanceSliderValue.floatValue
+ )
+ Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}")
+ sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
+ }
- LaunchedEffect(state.transparencyData) {
- val parsedSettings = parseTransparencySettingsResponse(data = state.transparencyData) ?: return@LaunchedEffect
- Log.d(TAG, "Initial transparency settings: $parsedSettings")
- enabled.value = parsedSettings.enabled
- amplificationSliderValue.floatValue = parsedSettings.netAmplification
- balanceSliderValue.floatValue = parsedSettings.balance
- toneSliderValue.floatValue = parsedSettings.leftTone
- ambientNoiseReductionSliderValue.floatValue =
- parsedSettings.leftAmbientNoiseReduction
- conversationBoostEnabled.value = parsedSettings.leftConversationBoost
- if (!eq.value.contentEquals(parsedSettings.leftEQ)) {
- eq.value = parsedSettings.leftEQ.copyOf()
- }
- initialized.value = true
- }
+ LaunchedEffect(state.transparencyData) {
+ val parsedSettings = parseTransparencySettingsResponse(data = state.transparencyData) ?: return@LaunchedEffect
+ Log.d(TAG, "Initial transparency settings: $parsedSettings")
+ enabled.value = parsedSettings.enabled
+ amplificationSliderValue.floatValue = parsedSettings.netAmplification
+ balanceSliderValue.floatValue = parsedSettings.balance
+ toneSliderValue.floatValue = parsedSettings.leftTone
+ ambientNoiseReductionSliderValue.floatValue =
+ parsedSettings.leftAmbientNoiseReduction
+ conversationBoostEnabled.value = parsedSettings.leftConversationBoost
+ if (!eq.value.contentEquals(parsedSettings.leftEQ)) {
+ eq.value = parsedSettings.leftEQ.copyOf()
+ }
+ initialized.value = true
+ }
+ ExpressiveScreenScaffold(
+ title = stringResource(R.string.customize_transparency_mode),
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(
+ top = padding.calculateTopPadding(),
+ bottom = 120.dp,
+ start = 16.dp,
+ end = 16.dp,
+ ),
+ ) {
if (state.vendorIdHook) {
- StyledToggle(
- label = stringResource(R.string.transparency_mode),
- checked = enabled.value,
- independent = true,
- description = stringResource(R.string.customize_transparency_mode_description),
- onCheckedChange = { enabled.value = it }
- )
- Spacer(modifier = Modifier.height(4.dp))
- StyledSlider(
- label = stringResource(R.string.amplification),
- valueRange = -1f..1f,
- value = amplificationSliderValue.floatValue,
- onValueChange = {
- amplificationSliderValue.floatValue = it
- },
- startIcon = "",
- endIcon = "",
- independent = true
- )
-
- StyledSlider(
- label = stringResource(R.string.balance),
- valueRange = -1f..1f,
- value = balanceSliderValue.floatValue,
- onValueChange = {
- balanceSliderValue.floatValue = it
- },
- snapPoints = listOf(-1f, 0f, 1f),
- startLabel = stringResource(R.string.left),
- endLabel = stringResource(R.string.right),
- independent = true,
- )
+ // --- Master toggle ---
+ item(key = "toggle") {
+ ExpressiveGroup {
+ ExpressiveSwitchRow(
+ title = stringResource(R.string.transparency_mode),
+ subtitle = stringResource(R.string.customize_transparency_mode_description),
+ leadingIcon = Icons.Filled.Hearing,
+ checked = enabled.value,
+ onCheckedChange = { enabled.value = it },
+ )
+ }
+ }
- StyledSlider(
- label = stringResource(R.string.tone),
- valueRange = -1f..1f,
- value = toneSliderValue.floatValue,
- onValueChange = {
- toneSliderValue.floatValue = it
- },
- startLabel = stringResource(R.string.darker),
- endLabel = stringResource(R.string.brighter),
- independent = true,
- )
+ // --- Amplification & balance ---
+ item(key = "amplification") {
+ ExpressiveSectionHeader(stringResource(R.string.amplification))
+ ExpressiveGroup {
+ ExpressiveListItem(
+ title = stringResource(R.string.amplification),
+ leadingIcon = Icons.AutoMirrored.Filled.VolumeUp,
+ )
+ ExpressiveSlider(
+ value = amplificationSliderValue.floatValue,
+ onValueChange = { amplificationSliderValue.floatValue = it },
+ valueRange = -1f..1f,
+ startIcon = Icons.AutoMirrored.Filled.VolumeMute,
+ endIcon = Icons.AutoMirrored.Filled.VolumeUp,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ )
+ ExpressiveDivider(startInset = false)
+ ExpressiveListItem(
+ title = stringResource(R.string.balance),
+ leadingIcon = Icons.Filled.Tune,
+ )
+ ExpressiveSlider(
+ value = balanceSliderValue.floatValue,
+ onValueChange = { balanceSliderValue.floatValue = it },
+ valueRange = -1f..1f,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ )
+ SliderEndLabels(
+ start = stringResource(R.string.left),
+ end = stringResource(R.string.right),
+ )
+ }
+ }
- StyledSlider(
- label = stringResource(R.string.ambient_noise_reduction),
- valueRange = 0f..1f,
- value = ambientNoiseReductionSliderValue.floatValue,
- onValueChange = {
- ambientNoiseReductionSliderValue.floatValue = it
- },
- startLabel = stringResource(R.string.less),
- endLabel = stringResource(R.string.more),
- independent = true,
- )
+ // --- Tone ---
+ item(key = "tone") {
+ ExpressiveSectionHeader(stringResource(R.string.tone))
+ ExpressiveGroup {
+ ExpressiveListItem(
+ title = stringResource(R.string.tone),
+ leadingIcon = Icons.Filled.Tune,
+ )
+ ExpressiveSlider(
+ value = toneSliderValue.floatValue,
+ onValueChange = { toneSliderValue.floatValue = it },
+ valueRange = -1f..1f,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ )
+ SliderEndLabels(
+ start = stringResource(R.string.darker),
+ end = stringResource(R.string.brighter),
+ )
+ }
+ }
- StyledToggle(
- label = stringResource(R.string.conversation_boost),
- checked = conversationBoostEnabled.value,
- independent = true,
- description = stringResource(R.string.conversation_boost_description),
- onCheckedChange = { conversationBoostEnabled.value = it }
- )
+ // --- Ambient noise reduction ---
+ item(key = "anr") {
+ ExpressiveSectionHeader(stringResource(R.string.ambient_noise_reduction))
+ ExpressiveGroup {
+ ExpressiveListItem(
+ title = stringResource(R.string.ambient_noise_reduction),
+ leadingIcon = Icons.Filled.GraphicEq,
+ )
+ ExpressiveSlider(
+ value = ambientNoiseReductionSliderValue.floatValue,
+ onValueChange = { ambientNoiseReductionSliderValue.floatValue = it },
+ valueRange = 0f..1f,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+ )
+ SliderEndLabels(
+ start = stringResource(R.string.less),
+ end = stringResource(R.string.more),
+ )
+ }
+ }
- Text(
- text = stringResource(R.string.equalizer),
- style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- modifier = Modifier.padding(16.dp, bottom = 4.dp)
- )
+ // --- Conversation boost ---
+ item(key = "conversation_boost") {
+ ExpressiveGroup {
+ ExpressiveSwitchRow(
+ title = stringResource(R.string.conversation_boost),
+ subtitle = stringResource(R.string.conversation_boost_description),
+ leadingIcon = Icons.Filled.RecordVoiceOver,
+ checked = conversationBoostEnabled.value,
+ onCheckedChange = { conversationBoostEnabled.value = it },
+ )
+ }
+ }
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .background(backgroundColor, RoundedCornerShape(28.dp))
- .padding(16.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.SpaceBetween
- ) {
- for (i in 0 until 8) {
- val eqValue = remember(eq.value[i]) { mutableFloatStateOf(eq.value[i]) }
- Row(
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically,
- modifier = Modifier
- .fillMaxWidth()
- .height(38.dp)
- ) {
- Text(
- text = String.format("%.2f", eqValue.floatValue),
- fontSize = 12.sp,
- color = textColor,
- modifier = Modifier.padding(bottom = 4.dp)
+ // --- Equalizer (8 bands) ---
+ item(key = "equalizer") {
+ ExpressiveSectionHeader(stringResource(R.string.equalizer))
+ ExpressiveGroup {
+ for (i in 0 until 8) {
+ val eqValue = remember(eq.value[i]) { mutableFloatStateOf(eq.value[i]) }
+ if (i > 0) ExpressiveDivider(startInset = false)
+ ExpressiveListItem(
+ title = stringResource(R.string.band_label, i + 1),
+ leadingIcon = Icons.Filled.Equalizer,
+ value = String.format("%.2f", eqValue.floatValue),
)
-
- Slider(
+ ExpressiveSlider(
value = eqValue.floatValue,
onValueChange = { newVal ->
eqValue.floatValue = newVal
@@ -306,63 +317,12 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
eq.value = newEQ
},
valueRange = 0f..100f,
- modifier = Modifier
- .fillMaxWidth(0.9f)
- .height(36.dp),
- colors = SliderDefaults.colors(
- thumbColor = thumbColor,
- activeTrackColor = activeTrackColor,
- inactiveTrackColor = trackColor
- ),
- thumb = {
- Box(
- modifier = Modifier
- .size(24.dp)
- .shadow(4.dp, CircleShape)
- .background(thumbColor, CircleShape)
- )
- },
- track = {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .height(12.dp),
- contentAlignment = Alignment.CenterStart
- )
- {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .height(4.dp)
- .background(trackColor, RoundedCornerShape(4.dp))
- )
- Box(
- modifier = Modifier
- .fillMaxWidth(eqValue.floatValue / 100f)
- .height(4.dp)
- .background(
- activeTrackColor,
- RoundedCornerShape(4.dp)
- )
- )
- }
- }
- )
-
- Text(
- text = stringResource(R.string.band_label, i + 1),
- fontSize = 12.sp,
- color = textColor,
- modifier = Modifier.padding(top = 4.dp)
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
}
-
- Spacer(modifier = Modifier.height(16.dp))
}
-
- Spacer(modifier = Modifier.height(bottomPadding))
}
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TroubleshootingScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TroubleshootingScreen.kt
index d9195718e..22736fbb7 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TroubleshootingScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/TroubleshootingScreen.kt
@@ -16,6 +16,8 @@
along with this program. If not, see .
*/
+@file:OptIn(ExperimentalMaterial3Api::class)
+
package me.kavishdevar.librepods.presentation.screens
import android.content.Intent
@@ -23,38 +25,33 @@ import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
-import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
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.width
+import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
-import androidx.compose.material.icons.filled.Share
+import androidx.compose.material.icons.filled.Description
import androidx.compose.material3.AlertDialog
-import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
@@ -72,55 +69,34 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import androidx.navigation.NavController
-import com.kyant.backdrop.backdrops.layerBackdrop
-import com.kyant.backdrop.backdrops.rememberLayerBackdrop
-import dev.chrisbanes.haze.hazeSource
-import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.presentation.components.StyledScaffold
+import me.kavishdevar.librepods.presentation.m3.ExpressiveAction
+import me.kavishdevar.librepods.presentation.m3.ExpressiveActionGroup
+import me.kavishdevar.librepods.presentation.m3.ExpressiveDivider
+import me.kavishdevar.librepods.presentation.m3.ExpressiveGroup
+import me.kavishdevar.librepods.presentation.m3.ExpressiveListItem
+import me.kavishdevar.librepods.presentation.m3.ExpressiveLoading
+import me.kavishdevar.librepods.presentation.m3.ExpressiveScreenScaffold
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSectionHeader
import me.kavishdevar.librepods.utils.LogCollector
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
-@Composable
-fun CustomIconButton(
- onClick: () -> Unit,
- content: @Composable () -> Unit
-) {
- Box(
- modifier = Modifier
- .clickable(onClick = onClick)
- .padding(8.dp),
- contentAlignment = Alignment.Center
- ) {
- content()
- }
-}
-
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable
fun TroubleshootingScreen(navController: NavController) {
val context = LocalContext.current
- val scrollState = rememberScrollState()
val coroutineScope = rememberCoroutineScope()
val logCollector = remember { LogCollector(context) }
@@ -146,13 +122,7 @@ fun TroubleshootingScreen(navController: NavController) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
var showBottomSheet by remember { mutableStateOf(false) }
- val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
- val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
- val accentColor = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5)
- val buttonBgColor = if (isSystemInDarkTheme()) Color(0xFF333333) else Color(0xFFDDDDDD)
-
var instructionText by remember { mutableStateOf("") }
- val isDarkTheme = isSystemInDarkTheme()
LaunchedEffect(Unit) {
withContext(Dispatchers.IO) {
@@ -209,155 +179,102 @@ fun TroubleshootingScreen(navController: NavController) {
showBottomSheet = true
}
- val backdrop = rememberLayerBackdrop()
+ fun shareLog(file: File) {
+ val fileUri = FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.provider",
+ file
+ )
+ val shareIntent = Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_STREAM, fileUri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.startActivity(Intent.createChooser(shareIntent, "Share log file"))
+ }
- Box(
- modifier = Modifier.fillMaxSize()
- ) {
- StyledScaffold(
- title = stringResource(R.string.troubleshooting)
- ){ topPadding, hazeState, bottomPadding ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .layerBackdrop(backdrop)
- .hazeSource(state = hazeState)
- .verticalScroll(scrollState)
- .padding(horizontal = 16.dp)
- ) {
- Spacer(modifier = Modifier.height(topPadding))
-
- Text(
- text = stringResource(R.string.saved_logs),
- style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- modifier = Modifier.padding(16.dp, bottom = 4.dp, top = 8.dp)
- )
-
- Spacer(modifier = Modifier.height(2.dp))
-
- if (savedLogs.isEmpty()) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .background(
- backgroundColor,
- RoundedCornerShape(28.dp)
- )
- .padding(16.dp),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Text(
- text = stringResource(R.string.no_logs_found),
- fontSize = 16.sp,
- color = textColor
+ ExpressiveScreenScaffold(
+ title = stringResource(R.string.troubleshooting),
+ onBack = { navController.popBackStack() },
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(
+ top = padding.calculateTopPadding(),
+ bottom = 120.dp,
+ start = 16.dp,
+ end = 16.dp,
+ ),
+ ) {
+ item(key = "saved_logs") {
+ ExpressiveSectionHeader(stringResource(R.string.saved_logs))
+ ExpressiveGroup {
+ if (savedLogs.isEmpty()) {
+ ExpressiveListItem(
+ title = stringResource(R.string.no_logs_found),
+ leadingIcon = Icons.Default.Description,
)
- }
- } else {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .background(
- backgroundColor,
- RoundedCornerShape(28.dp)
- )
- .padding(horizontal = 16.dp, vertical = 8.dp)
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 8.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = "Total Logs: ${savedLogs.size}",
- fontSize = 16.sp,
- fontWeight = FontWeight.Medium,
- color = textColor
- )
-
- if (savedLogs.size > 1) {
- TextButton(
- onClick = { showDeleteAllDialog = true },
- colors = ButtonDefaults.textButtonColors(
- contentColor = MaterialTheme.colorScheme.error
- )
- ) {
- Text("Delete All")
+ } else {
+ ExpressiveListItem(
+ title = "Total logs",
+ value = savedLogs.size.toString(),
+ trailing = if (savedLogs.size > 1) {
+ {
+ TextButton(onClick = { showDeleteAllDialog = true }) {
+ Text(
+ "Delete all",
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
}
- }
- }
-
+ } else null,
+ )
savedLogs.forEach { logFile ->
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 8.dp)
- .clickable {
- openLogBottomSheet(logFile)
- },
- verticalAlignment = Alignment.CenterVertically
- ) {
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = logFile.name,
- fontSize = 16.sp,
- color = textColor,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
-
- Text(
- text = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.US)
- .format(Date(logFile.lastModified())),
- fontSize = 14.sp,
- color = textColor.copy(alpha = 0.6f)
- )
- }
-
- CustomIconButton(
- onClick = {
+ ExpressiveDivider()
+ ExpressiveListItem(
+ title = logFile.name,
+ subtitle = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.US)
+ .format(Date(logFile.lastModified())),
+ leadingIcon = Icons.Default.Description,
+ onClick = { openLogBottomSheet(logFile) },
+ trailing = {
+ IconButton(onClick = {
selectedLogFile = logFile
showDeleteDialog = true
+ }) {
+ Icon(
+ Icons.Default.Delete,
+ contentDescription = "Delete",
+ tint = MaterialTheme.colorScheme.error,
+ )
}
- ) {
- Icon(
- Icons.Default.Delete,
- contentDescription = "Delete",
- tint = MaterialTheme.colorScheme.error
- )
- }
- }
+ },
+ )
}
}
}
+ }
+ item(key = "collect_action") {
Spacer(modifier = Modifier.height(16.dp))
-
AnimatedVisibility(
visible = !showTroubleshootingSteps,
enter = fadeIn(animationSpec = tween(300)),
exit = fadeOut(animationSpec = tween(300))
) {
- Button(
- onClick = { showTroubleshootingSteps = true },
- modifier = Modifier.fillMaxWidth(),
- shape = RoundedCornerShape(10.dp),
- colors = ButtonDefaults.buttonColors(
- containerColor = buttonBgColor,
- contentColor = textColor
- ),
- enabled = !isCollectingLogs
- ) {
- Text(stringResource(R.string.collect_logs))
+ if (!isCollectingLogs) {
+ ExpressiveActionGroup(
+ actions = listOf(
+ ExpressiveAction(stringResource(R.string.collect_logs)) {
+ showTroubleshootingSteps = true
+ }
+ )
+ )
}
}
+ }
+ item(key = "wizard") {
AnimatedVisibility(
visible = showTroubleshootingSteps,
enter = fadeIn(animationSpec = tween(300)) +
@@ -367,544 +284,388 @@ fun TroubleshootingScreen(navController: NavController) {
) {
Column {
Spacer(modifier = Modifier.height(16.dp))
-
- Text(
- text = stringResource(R.string.troubleshooting_steps),
- style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Light,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 8.dp)
- )
-
- Spacer(modifier = Modifier.height(2.dp))
-
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .background(
- backgroundColor,
- RoundedCornerShape(28.dp)
+ ExpressiveSectionHeader(stringResource(R.string.troubleshooting_steps))
+ ExpressiveGroup {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(20.dp)
+ ) {
+ Text(
+ text = instructionText,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface
)
- .padding(16.dp)
- ) {
- val textAlpha = animateFloatAsState(
- targetValue = 1f,
- animationSpec = tween(durationMillis = 300),
- label = "textAlpha"
- )
-
- Text(
- text = instructionText,
- fontSize = 16.sp,
- color = textColor.copy(alpha = textAlpha.value),
- lineHeight = 22.sp
- )
- Spacer(modifier = Modifier.height(16.dp))
+ Spacer(modifier = Modifier.height(16.dp))
- when (currentStep) {
- 0 -> {
- Button(
- onClick = {
- coroutineScope.launch {
- logCollector.openXposedSettings(context)
- delay(2000)
- currentStep = 1
- }
- },
- modifier = Modifier.fillMaxWidth(),
- shape = RoundedCornerShape(10.dp),
- colors = ButtonDefaults.buttonColors(
- containerColor = buttonBgColor,
- contentColor = textColor
+ when (currentStep) {
+ 0 -> {
+ ExpressiveActionGroup(
+ actions = listOf(
+ ExpressiveAction("Open Xposed Settings") {
+ coroutineScope.launch {
+ logCollector.openXposedSettings(context)
+ delay(2000)
+ currentStep = 1
+ }
+ }
+ )
)
- ) {
- Text("Open Xposed Settings")
}
- }
- 1 -> {
- Button(
- onClick = {
- currentStep = 2
- isCollectingLogs = true
+ 1 -> {
+ ExpressiveActionGroup(
+ actions = listOf(
+ ExpressiveAction("Continue") {
+ currentStep = 2
+ isCollectingLogs = true
- coroutineScope.launch {
- try {
- logCollector.clearLogs()
+ coroutineScope.launch {
+ try {
+ logCollector.clearLogs()
- logCollector.addLogMarker(LogCollector.LogMarkerType.START)
+ logCollector.addLogMarker(LogCollector.LogMarkerType.START)
- logCollector.killBluetoothService()
+ logCollector.killBluetoothService()
- withContext(Dispatchers.Main) {
- delay(500)
- currentStep = 3
- }
+ withContext(Dispatchers.Main) {
+ delay(500)
+ currentStep = 3
+ }
- val timestamp = SimpleDateFormat(
- "yyyyMMdd_HHmmss",
- Locale.US
- ).format(Date())
-
- logContent =
- logCollector.startLogCollection(
- listener = { /* Removed live log display */ },
- connectionDetectedCallback = {
- launch {
- delay(5000)
- withContext(Dispatchers.Main) {
- if (isCollectingLogs) {
- logCollector.stopLogCollection()
- currentStep = 4
- isCollectingLogs =
- false
+ val timestamp = SimpleDateFormat(
+ "yyyyMMdd_HHmmss",
+ Locale.US
+ ).format(Date())
+
+ logContent =
+ logCollector.startLogCollection(
+ listener = { /* Removed live log display */ },
+ connectionDetectedCallback = {
+ launch {
+ delay(5000)
+ withContext(Dispatchers.Main) {
+ if (isCollectingLogs) {
+ logCollector.stopLogCollection()
+ currentStep = 4
+ isCollectingLogs =
+ false
+ }
+ }
}
}
+ )
+
+ val logFile =
+ logCollector.saveLogToInternalStorage(
+ "airpods_log_$timestamp.txt",
+ logContent
+ )
+ logFile?.let {
+ withContext(Dispatchers.Main) {
+ savedLogs.add(0, it)
+ selectedLogFile = it
+ Toast.makeText(
+ context,
+ "Log saved: ${it.name}",
+ Toast.LENGTH_SHORT
+ ).show()
}
}
- )
-
- val logFile =
- logCollector.saveLogToInternalStorage(
- "airpods_log_$timestamp.txt",
- logContent
- )
- logFile?.let {
- withContext(Dispatchers.Main) {
- savedLogs.add(0, it)
- selectedLogFile = it
- Toast.makeText(
- context,
- "Log saved: ${it.name}",
- Toast.LENGTH_SHORT
- ).show()
+ } catch (e: Exception) {
+ withContext(Dispatchers.Main) {
+ Toast.makeText(
+ context,
+ "Error collecting logs: ${e.message}",
+ Toast.LENGTH_SHORT
+ ).show()
+ isCollectingLogs = false
+ currentStep = 0
+ }
}
}
- } catch (e: Exception) {
- withContext(Dispatchers.Main) {
- Toast.makeText(
- context,
- "Error collecting logs: ${e.message}",
- Toast.LENGTH_SHORT
- ).show()
- isCollectingLogs = false
- currentStep = 0
- }
}
- }
- },
- modifier = Modifier.fillMaxWidth(),
- shape = RoundedCornerShape(10.dp),
- colors = ButtonDefaults.buttonColors(
- containerColor = buttonBgColor,
- contentColor = textColor
+ )
)
- ) {
- Text("Continue")
}
- }
-
- 2, 3 -> {
- Column(
- modifier = Modifier.fillMaxWidth(),
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- CircularProgressIndicator(
- color = accentColor
- )
- Spacer(modifier = Modifier.height(8.dp))
+ 2, 3 -> {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ ExpressiveLoading()
- Text(
- text = if (currentStep == 2) "Preparing..." else "Collecting logs...",
- fontSize = 14.sp,
- color = textColor
- )
+ Spacer(modifier = Modifier.height(8.dp))
- if (currentStep == 3) {
- Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = if (currentStep == 2) "Preparing..." else "Collecting logs...",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
- Button(
- onClick = {
- coroutineScope.launch {
- logCollector.addLogMarker(
- LogCollector.LogMarkerType.CUSTOM,
- "Manual stop requested by user"
- )
- delay(1000)
- logCollector.stopLogCollection()
- delay(500)
-
- withContext(Dispatchers.Main) {
- currentStep = 4
- isCollectingLogs = false
- Toast.makeText(
- context,
- "Log collection stopped",
- Toast.LENGTH_SHORT
- ).show()
+ if (currentStep == 3) {
+ Spacer(modifier = Modifier.height(16.dp))
+
+ ExpressiveActionGroup(
+ actions = listOf(
+ ExpressiveAction("Stop Collection") {
+ coroutineScope.launch {
+ logCollector.addLogMarker(
+ LogCollector.LogMarkerType.CUSTOM,
+ "Manual stop requested by user"
+ )
+ delay(1000)
+ logCollector.stopLogCollection()
+ delay(500)
+
+ withContext(Dispatchers.Main) {
+ currentStep = 4
+ isCollectingLogs = false
+ Toast.makeText(
+ context,
+ "Log collection stopped",
+ Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
}
- }
- },
- shape = RoundedCornerShape(10.dp),
- colors = ButtonDefaults.buttonColors(
- containerColor = buttonBgColor,
- contentColor = textColor
- ),
- modifier = Modifier
- .fillMaxWidth()
- ) {
- Text("Stop Collection")
+ )
+ )
}
}
}
- }
- 4 -> {
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.Center
- ) {
- Button(
- onClick = {
- selectedLogFile?.let { file ->
- val fileUri = FileProvider.getUriForFile(
- context,
- "${context.packageName}.provider",
- file
- )
- val shareIntent =
- Intent(Intent.ACTION_SEND).apply {
- type = "text/plain"
- putExtra(
- Intent.EXTRA_STREAM,
- fileUri
- )
- addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
- }
- context.startActivity(
- Intent.createChooser(
- shareIntent,
- "Share log file"
- )
- )
- }
- },
- shape = RoundedCornerShape(10.dp),
- colors = ButtonDefaults.buttonColors(
- containerColor = buttonBgColor,
- contentColor = textColor
- ),
- modifier = Modifier.width(150.dp)
- ) {
- Icon(
- imageVector = Icons.Default.Share,
- contentDescription = "Share"
+ 4 -> {
+ ExpressiveActionGroup(
+ actions = listOf(
+ ExpressiveAction("Share") {
+ selectedLogFile?.let { shareLog(it) }
+ },
+ ExpressiveAction("Save") {
+ selectedLogFile?.let { file ->
+ saveLauncher.launch(file.absolutePath)
+ }
+ },
)
- Spacer(modifier = Modifier.width(8.dp))
- Text("Share")
- }
+ )
- Spacer(modifier = Modifier.width(16.dp))
+ Spacer(modifier = Modifier.height(16.dp))
- Button(
- onClick = {
- selectedLogFile?.let { file ->
- saveLauncher.launch(
- file.absolutePath
- )
+ ExpressiveActionGroup(
+ actions = listOf(
+ ExpressiveAction("Done") {
+ currentStep = 0
+ showTroubleshootingSteps = false
}
- },
- shape = RoundedCornerShape(10.dp),
- colors = ButtonDefaults.buttonColors(
- containerColor = buttonBgColor,
- contentColor = textColor
- ),
- modifier = Modifier.width(150.dp)
- ) {
- Icon(
- painter = painterResource(id = R.drawable.ic_save),
- contentDescription = "Save"
)
- Spacer(modifier = Modifier.width(8.dp))
- Text("Save")
- }
- }
-
- Spacer(modifier = Modifier.height(16.dp))
-
- Button(
- onClick = {
- currentStep = 0
- showTroubleshootingSteps = false
- },
- modifier = Modifier.fillMaxWidth(),
- shape = RoundedCornerShape(10.dp),
- colors = ButtonDefaults.buttonColors(
- containerColor = buttonBgColor,
- contentColor = textColor
)
- ) {
- Text("Done")
}
}
}
}
}
}
+ }
+ }
+ }
- if (showDeleteDialog && selectedLogFile != null) {
- AlertDialog(
- onDismissRequest = { showDeleteDialog = false },
- title = { Text("Delete Log File") },
- text = {
- Text("Are you sure you want to delete this log file? This action cannot be undone.")
- },
- confirmButton = {
- TextButton(
- onClick = {
- selectedLogFile?.let { file ->
- if (file.delete()) {
- savedLogs.remove(file)
- Toast.makeText(
- context,
- "Log file deleted",
- Toast.LENGTH_SHORT
- )
- .show()
- } else {
- Toast.makeText(
- context,
- "Failed to delete log file",
- Toast.LENGTH_SHORT
- ).show()
- }
- }
- showDeleteDialog = false
- }
- ) {
- Text("Delete", color = MaterialTheme.colorScheme.error)
- }
- },
- dismissButton = {
- TextButton(onClick = { showDeleteDialog = false }) {
- Text("Cancel")
+ if (showDeleteDialog && selectedLogFile != null) {
+ AlertDialog(
+ onDismissRequest = { showDeleteDialog = false },
+ title = { Text("Delete Log File") },
+ text = {
+ Text("Are you sure you want to delete this log file? This action cannot be undone.")
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ selectedLogFile?.let { file ->
+ if (file.delete()) {
+ savedLogs.remove(file)
+ Toast.makeText(
+ context,
+ "Log file deleted",
+ Toast.LENGTH_SHORT
+ ).show()
+ } else {
+ Toast.makeText(
+ context,
+ "Failed to delete log file",
+ Toast.LENGTH_SHORT
+ ).show()
}
}
- )
+ showDeleteDialog = false
+ }
+ ) {
+ Text("Delete", color = MaterialTheme.colorScheme.error)
}
+ },
+ dismissButton = {
+ TextButton(onClick = { showDeleteDialog = false }) {
+ Text("Cancel")
+ }
+ }
+ )
+ }
- if (showDeleteAllDialog) {
- AlertDialog(
- onDismissRequest = { showDeleteAllDialog = false },
- title = { Text("Delete All Logs") },
- text = {
- Text("Are you sure you want to delete all log files? This action cannot be undone and will remove ${savedLogs.size} log files.")
- },
- confirmButton = {
- TextButton(
- onClick = {
- coroutineScope.launch(Dispatchers.IO) {
- var deletedCount = 0
- savedLogs.forEach { file ->
- if (file.delete()) {
- deletedCount++
- }
- }
- withContext(Dispatchers.Main) {
- if (deletedCount > 0) {
- savedLogs.clear()
- Toast.makeText(
- context,
- "Deleted $deletedCount log files",
- Toast.LENGTH_SHORT
- ).show()
- } else {
- Toast.makeText(
- context,
- "Failed to delete log files",
- Toast.LENGTH_SHORT
- ).show()
- }
- }
- }
- showDeleteAllDialog = false
+ if (showDeleteAllDialog) {
+ AlertDialog(
+ onDismissRequest = { showDeleteAllDialog = false },
+ title = { Text("Delete All Logs") },
+ text = {
+ Text("Are you sure you want to delete all log files? This action cannot be undone and will remove ${savedLogs.size} log files.")
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ coroutineScope.launch(Dispatchers.IO) {
+ var deletedCount = 0
+ savedLogs.forEach { file ->
+ if (file.delete()) {
+ deletedCount++
}
- ) {
- Text("Delete All", color = MaterialTheme.colorScheme.error)
}
- },
- dismissButton = {
- TextButton(onClick = { showDeleteAllDialog = false }) {
- Text("Cancel")
+ withContext(Dispatchers.Main) {
+ if (deletedCount > 0) {
+ savedLogs.clear()
+ Toast.makeText(
+ context,
+ "Deleted $deletedCount log files",
+ Toast.LENGTH_SHORT
+ ).show()
+ } else {
+ Toast.makeText(
+ context,
+ "Failed to delete log files",
+ Toast.LENGTH_SHORT
+ ).show()
+ }
}
}
- )
+ showDeleteAllDialog = false
+ }
+ ) {
+ Text("Delete All", color = MaterialTheme.colorScheme.error)
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showDeleteAllDialog = false }) {
+ Text("Cancel")
}
}
- }
+ )
+ }
- if (showBottomSheet) {
- ModalBottomSheet(
- onDismissRequest = { showBottomSheet = false },
- sheetState = sheetState,
- containerColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7),
- shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
- tonalElevation = 8.dp
- ) {
- LaunchedEffect(selectedLogFile) {
- if (!logContentLoaded) {
- delay(300)
- withContext(Dispatchers.IO) {
- isLoadingLogContent = true
- logContent = try {
- selectedLogFile?.readText() ?: ""
- } catch (e: Exception) {
- "Error loading log content: ${e.message}"
- }
- isLoadingLogContent = false
- logContentLoaded = true
+ if (showBottomSheet) {
+ ModalBottomSheet(
+ onDismissRequest = { showBottomSheet = false },
+ sheetState = sheetState,
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
+ shape = MaterialTheme.shapes.extraLarge,
+ tonalElevation = 8.dp
+ ) {
+ LaunchedEffect(selectedLogFile) {
+ if (!logContentLoaded) {
+ delay(300)
+ withContext(Dispatchers.IO) {
+ isLoadingLogContent = true
+ logContent = try {
+ selectedLogFile?.readText() ?: ""
+ } catch (e: Exception) {
+ "Error loading log content: ${e.message}"
}
+ isLoadingLogContent = false
+ logContentLoaded = true
}
}
+ }
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp)
+ .padding(bottom = 32.dp)
+ ) {
Column(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 12.dp)
- .padding(bottom = 32.dp)
+ .padding(bottom = 12.dp),
) {
- Column(
+ Text(
+ text = selectedLogFile?.name ?: "Log Content",
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.US)
+ .format(Date(selectedLogFile?.lastModified() ?: 0)),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+
+ if (isLoadingLogContent) {
+ Box(
modifier = Modifier
.fillMaxWidth()
- .padding(bottom = 12.dp),
+ .height(300.dp),
+ contentAlignment = Alignment.Center
) {
- Text(
- text = selectedLogFile?.name ?: "Log Content",
- style = TextStyle(
- fontWeight = FontWeight.Bold,
- fontSize = 20.sp,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- color = textColor
- )
- Text(
- text = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.US)
- .format(Date(selectedLogFile?.lastModified() ?: 0)),
- fontSize = 14.sp,
- color = textColor.copy(alpha = 0.7f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
+ ExpressiveLoading()
}
+ } else {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(300.dp)
+ .background(
+ color = MaterialTheme.colorScheme.surfaceContainerHighest,
+ shape = MaterialTheme.shapes.medium
+ )
+ ) {
+ val horizontalScrollState = rememberScrollState()
+ val verticalScrollState = rememberScrollState()
- if (isLoadingLogContent) {
Box(
modifier = Modifier
- .fillMaxWidth()
- .height(300.dp),
- contentAlignment = Alignment.Center
+ .fillMaxSize()
+ .padding(8.dp)
+ .horizontalScroll(horizontalScrollState)
+ .verticalScroll(verticalScrollState)
) {
- CircularProgressIndicator(color = accentColor)
- }
- } else {
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .height(300.dp)
- .background(
- color = Color.Black,
- shape = RoundedCornerShape(8.dp)
- )
- ) {
- val horizontalScrollState = rememberScrollState()
- val verticalScrollState = rememberScrollState()
-
- Box(
- modifier = Modifier
- .fillMaxSize()
- .padding(8.dp)
- .horizontalScroll(horizontalScrollState)
- .verticalScroll(verticalScrollState)
- ) {
- Text(
- text = logContent,
- fontSize = 14.sp,
- color = Color.LightGray,
- lineHeight = 20.sp,
- fontFamily = FontFamily.Monospace,
- softWrap = false
- )
- }
+ Text(
+ text = logContent,
+ style = MaterialTheme.typography.bodyMedium.copy(
+ fontFamily = FontFamily.Monospace
+ ),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ softWrap = false
+ )
}
}
+ }
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 16.dp),
- horizontalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- Button(
- onClick = {
- selectedLogFile?.let { file ->
- val fileUri = FileProvider.getUriForFile(
- context,
- "${context.packageName}.provider",
- file
- )
- val shareIntent = Intent(Intent.ACTION_SEND).apply {
- type = "text/plain"
- putExtra(Intent.EXTRA_STREAM, fileUri)
- addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
- }
- context.startActivity(
- Intent.createChooser(
- shareIntent,
- "Share log file"
- )
- )
- }
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ ExpressiveActionGroup(
+ actions = listOf(
+ ExpressiveAction("Share") {
+ selectedLogFile?.let { shareLog(it) }
},
- shape = RoundedCornerShape(10.dp),
- colors = ButtonDefaults.buttonColors(
- containerColor = buttonBgColor,
- contentColor = textColor
- ),
- modifier = Modifier.weight(1f)
- ) {
- Icon(
- imageVector = Icons.Default.Share,
- contentDescription = "Share"
- )
- Spacer(modifier = Modifier.width(8.dp))
- Text("Share")
- }
-
- Button(
- onClick = {
+ ExpressiveAction("Save") {
selectedLogFile?.let { file ->
saveLauncher.launch(file.absolutePath)
}
},
- shape = RoundedCornerShape(10.dp),
- colors = ButtonDefaults.buttonColors(
- containerColor = buttonBgColor,
- contentColor = textColor
- ),
- modifier = Modifier.weight(1f)
- ) {
- Icon(
- painter = painterResource(id = R.drawable.ic_save),
- contentDescription = "Save"
- )
- Spacer(modifier = Modifier.width(8.dp))
- Text("Save")
- }
- }
+ )
+ )
}
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/UpdateHearingTestScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/UpdateHearingTestScreen.kt
index 175a830cc..ee88dfe89 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/UpdateHearingTestScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/UpdateHearingTestScreen.kt
@@ -19,9 +19,9 @@
package me.kavishdevar.librepods.presentation.screens
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -29,9 +29,9 @@ 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.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -45,241 +45,221 @@ import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
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.Font
-import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import com.kyant.backdrop.backdrops.layerBackdrop
-import com.kyant.backdrop.backdrops.rememberLayerBackdrop
-import dev.chrisbanes.haze.hazeSource
import kotlinx.coroutines.Job
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.HearingAidSettings
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.data.sendHearingAidSettings
-import me.kavishdevar.librepods.presentation.components.StyledScaffold
+import me.kavishdevar.librepods.presentation.m3.ExpressiveGroup
+import me.kavishdevar.librepods.presentation.m3.ExpressiveScreenScaffold
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSectionHeader
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
private const val TAG = "HearingAidAdjustments"
@Composable
fun UpdateHearingTestScreen(viewModel: AirPodsViewModel) {
- val verticalScrollState = rememberScrollState()
val state by viewModel.uiState.collectAsState()
- val backdrop = rememberLayerBackdrop()
- StyledScaffold(
- title = stringResource(R.string.hearing_test)
- ) { topPadding, hazeState, bottomPadding ->
- Column(
- modifier = Modifier
- .hazeSource(hazeState)
- .fillMaxSize()
- .layerBackdrop(backdrop)
- .verticalScroll(verticalScrollState)
- .padding(horizontal = 16.dp),
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
-
- Spacer(modifier = Modifier.height(topPadding))
- Text(
- text = stringResource(R.string.hearing_test_value_instruction),
- modifier = Modifier.fillMaxWidth(),
- style = TextStyle(
- fontSize = 16.sp,
- color = textColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- textAlign = TextAlign.Center,
- )
- val tone = rememberSaveable { mutableFloatStateOf(0.5f) }
- val ambientNoiseReduction = rememberSaveable { mutableFloatStateOf(0.0f) }
- val ownVoiceAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
- val leftAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
- val rightAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
- val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
- val leftEQ = rememberSaveable(
- saver = Saver(
- save = { it.value.toList() },
- restore = { mutableStateOf(it.toFloatArray()) }
- )
- ) {
- mutableStateOf(FloatArray(8))
- }
- val rightEQ = rememberSaveable(
- saver = Saver(
- save = { it.value.toList() },
- restore = { mutableStateOf(it.toFloatArray()) }
- )
- ) {
- mutableStateOf(FloatArray(8))
- }
+ val tone = rememberSaveable { mutableFloatStateOf(0.5f) }
+ val ambientNoiseReduction = rememberSaveable { mutableFloatStateOf(0.0f) }
+ val ownVoiceAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
+ val leftAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
+ val rightAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
+ val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
+ val leftEQ = rememberSaveable(
+ saver = Saver(
+ save = { it.value.toList() },
+ restore = { mutableStateOf(it.toFloatArray()) }
+ )
+ ) {
+ mutableStateOf(FloatArray(8))
+ }
+ val rightEQ = rememberSaveable(
+ saver = Saver(
+ save = { it.value.toList() },
+ restore = { mutableStateOf(it.toFloatArray()) }
+ )
+ ) {
+ mutableStateOf(FloatArray(8))
+ }
- val debounceJob = remember { mutableStateOf(null) }
- val initialized = rememberSaveable { mutableStateOf(false) }
+ val debounceJob = remember { mutableStateOf(null) }
+ val initialized = rememberSaveable { mutableStateOf(false) }
- val hearingAidSettings = remember {
- mutableStateOf(
- HearingAidSettings(
- leftEQ = leftEQ.value,
- rightEQ = rightEQ.value,
- leftAmplification = leftAmplification.floatValue,
- rightAmplification = rightAmplification.floatValue,
- leftTone = tone.floatValue,
- rightTone = tone.floatValue,
- leftConversationBoost = conversationBoostEnabled.value,
- rightConversationBoost = conversationBoostEnabled.value,
- leftAmbientNoiseReduction = ambientNoiseReduction.floatValue,
- rightAmbientNoiseReduction = ambientNoiseReduction.floatValue,
- netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2,
- balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
- ownVoiceAmplification = ownVoiceAmplification.floatValue
- )
- )
- }
+ val hearingAidSettings = remember {
+ mutableStateOf(
+ HearingAidSettings(
+ leftEQ = leftEQ.value,
+ rightEQ = rightEQ.value,
+ leftAmplification = leftAmplification.floatValue,
+ rightAmplification = rightAmplification.floatValue,
+ leftTone = tone.floatValue,
+ rightTone = tone.floatValue,
+ leftConversationBoost = conversationBoostEnabled.value,
+ rightConversationBoost = conversationBoostEnabled.value,
+ leftAmbientNoiseReduction = ambientNoiseReduction.floatValue,
+ rightAmbientNoiseReduction = ambientNoiseReduction.floatValue,
+ netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2,
+ balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
+ ownVoiceAmplification = ownVoiceAmplification.floatValue
+ )
+ )
+ }
- LaunchedEffect(state.hearingAidData) {
- val parsed = parseHearingAidSettingsResponse(state.hearingAidData)
- if (parsed != null) {
- leftEQ.value = parsed.leftEQ.copyOf()
- rightEQ.value = parsed.rightEQ.copyOf()
- conversationBoostEnabled.value = parsed.leftConversationBoost
- tone.floatValue = parsed.leftTone
- ambientNoiseReduction.floatValue = parsed.leftAmbientNoiseReduction
- ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
- leftAmplification.floatValue = parsed.leftAmplification
- rightAmplification.floatValue = parsed.rightAmplification
- initialized.value = true
- Log.d(TAG, "Updated hearing aid settings from notification")
- } else {
- Log.w(TAG, "Failed to parse hearing aid settings from notification")
- }
- }
+ LaunchedEffect(state.hearingAidData) {
+ val parsed = parseHearingAidSettingsResponse(state.hearingAidData)
+ if (parsed != null) {
+ leftEQ.value = parsed.leftEQ.copyOf()
+ rightEQ.value = parsed.rightEQ.copyOf()
+ conversationBoostEnabled.value = parsed.leftConversationBoost
+ tone.floatValue = parsed.leftTone
+ ambientNoiseReduction.floatValue = parsed.leftAmbientNoiseReduction
+ ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
+ leftAmplification.floatValue = parsed.leftAmplification
+ rightAmplification.floatValue = parsed.rightAmplification
+ initialized.value = true
+ Log.d(TAG, "Updated hearing aid settings from notification")
+ } else {
+ Log.w(TAG, "Failed to parse hearing aid settings from notification")
+ }
+ }
- LaunchedEffect(
- leftEQ.value,
- rightEQ.value,
- conversationBoostEnabled.value,
- leftAmplification.floatValue,
- rightAmplification.floatValue,
- tone.floatValue,
- ambientNoiseReduction.floatValue,
- ownVoiceAmplification.floatValue
- ) {
- if (!initialized.value) return@LaunchedEffect
- hearingAidSettings.value = HearingAidSettings(
- leftEQ = leftEQ.value,
- rightEQ = rightEQ.value,
- leftAmplification = leftAmplification.floatValue,
- rightAmplification = rightAmplification.floatValue,
- leftTone = tone.floatValue,
- rightTone = tone.floatValue,
- leftConversationBoost = conversationBoostEnabled.value,
- rightConversationBoost = conversationBoostEnabled.value,
- leftAmbientNoiseReduction = ambientNoiseReduction.floatValue,
- rightAmbientNoiseReduction = ambientNoiseReduction.floatValue,
- netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2,
- balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
- ownVoiceAmplification = ownVoiceAmplification.floatValue
- )
- Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
- sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::setATTCharacteristicValue)
- }
+ LaunchedEffect(
+ leftEQ.value,
+ rightEQ.value,
+ conversationBoostEnabled.value,
+ leftAmplification.floatValue,
+ rightAmplification.floatValue,
+ tone.floatValue,
+ ambientNoiseReduction.floatValue,
+ ownVoiceAmplification.floatValue
+ ) {
+ if (!initialized.value) return@LaunchedEffect
+ hearingAidSettings.value = HearingAidSettings(
+ leftEQ = leftEQ.value,
+ rightEQ = rightEQ.value,
+ leftAmplification = leftAmplification.floatValue,
+ rightAmplification = rightAmplification.floatValue,
+ leftTone = tone.floatValue,
+ rightTone = tone.floatValue,
+ leftConversationBoost = conversationBoostEnabled.value,
+ rightConversationBoost = conversationBoostEnabled.value,
+ leftAmbientNoiseReduction = ambientNoiseReduction.floatValue,
+ rightAmbientNoiseReduction = ambientNoiseReduction.floatValue,
+ netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2,
+ balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
+ ownVoiceAmplification = ownVoiceAmplification.floatValue
+ )
+ Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
+ sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::setATTCharacteristicValue)
+ }
- val frequencies =
- listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz")
+ val frequencies =
+ listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz")
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- Spacer(modifier = Modifier.width(60.dp))
- Text(
- text = stringResource(R.string.left),
- modifier = Modifier.weight(1f),
- textAlign = TextAlign.Center,
- style = TextStyle(
- fontSize = 18.sp,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- color = textColor
- )
- )
+ ExpressiveScreenScaffold(title = stringResource(R.string.hearing_test)) { padding ->
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(
+ top = padding.calculateTopPadding(),
+ bottom = 120.dp,
+ start = 16.dp,
+ end = 16.dp,
+ ),
+ ) {
+ item {
Text(
- text = stringResource(R.string.right),
- modifier = Modifier.weight(1f),
+ text = stringResource(R.string.hearing_test_value_instruction),
+ modifier = Modifier.fillMaxWidth(),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
- style = TextStyle(
- fontSize = 18.sp,
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- color = textColor
- )
)
}
- frequencies.forEachIndexed { index, freq ->
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- Text(
- text = freq,
+ item {
+ ExpressiveSectionHeader(text = stringResource(R.string.hearing_test))
+ ExpressiveGroup {
+ Row(
modifier = Modifier
- .width(60.dp)
- .align(Alignment.CenterVertically),
- textAlign = TextAlign.End,
- style = TextStyle(
- color = textColor,
- fontSize = 16.sp,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- ),
- )
- OutlinedTextField(
- value = leftEQ.value[index].toString(),
- onValueChange = { newValue ->
- val parsed = newValue.toFloatOrNull()
- if (parsed != null) {
- val newArray = leftEQ.value.copyOf()
- newArray[index] = parsed
- leftEQ.value = newArray
- Log.d(TAG, "Left EQ updated at index $index to $parsed")
- }
- },
-// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) },
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
- textStyle = TextStyle(
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- fontSize = 14.sp
- ),
- modifier = Modifier.weight(1f)
- )
- OutlinedTextField(
- value = rightEQ.value[index].toString(),
- onValueChange = { newValue ->
- val parsed = newValue.toFloatOrNull()
- if (parsed != null) {
- val newArray = rightEQ.value.copyOf()
- newArray[index] = parsed
- rightEQ.value = newArray
- Log.d(TAG, "Right EQ updated at index $index to $parsed")
- }
- },
-// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) },
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
- textStyle = TextStyle(
- fontFamily = FontFamily(Font(R.font.sf_pro)),
- fontSize = 14.sp
- ),
- modifier = Modifier.weight(1f)
- )
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Spacer(modifier = Modifier.width(60.dp))
+ Text(
+ text = stringResource(R.string.left),
+ modifier = Modifier.weight(1f),
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Text(
+ text = stringResource(R.string.right),
+ modifier = Modifier.weight(1f),
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+
+ frequencies.forEachIndexed { index, freq ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 6.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = freq,
+ modifier = Modifier.width(60.dp),
+ textAlign = TextAlign.End,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ OutlinedTextField(
+ value = leftEQ.value[index].toString(),
+ onValueChange = { newValue ->
+ val parsed = newValue.toFloatOrNull()
+ if (parsed != null) {
+ val newArray = leftEQ.value.copyOf()
+ newArray[index] = parsed
+ leftEQ.value = newArray
+ Log.d(TAG, "Left EQ updated at index $index to $parsed")
+ }
+ },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
+ textStyle = MaterialTheme.typography.bodyLarge,
+ shape = MaterialTheme.shapes.medium,
+ modifier = Modifier.weight(1f),
+ )
+ OutlinedTextField(
+ value = rightEQ.value[index].toString(),
+ onValueChange = { newValue ->
+ val parsed = newValue.toFloatOrNull()
+ if (parsed != null) {
+ val newArray = rightEQ.value.copyOf()
+ newArray[index] = parsed
+ rightEQ.value = newArray
+ Log.d(TAG, "Right EQ updated at index $index to $parsed")
+ }
+ },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
+ textStyle = MaterialTheme.typography.bodyLarge,
+ shape = MaterialTheme.shapes.medium,
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ Spacer(modifier = Modifier.height(6.dp))
}
}
- Spacer(modifier = Modifier.height(bottomPadding))
}
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/VersionInfoScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/VersionInfoScreen.kt
index 266958762..19d5e7455 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/VersionInfoScreen.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/VersionInfoScreen.kt
@@ -18,160 +18,59 @@
package me.kavishdevar.librepods.presentation.screens
-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.PaddingValues
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.shape.RoundedCornerShape
-import androidx.compose.material3.HorizontalDivider
-import androidx.compose.material3.Text
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Numbers
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.clip
-import androidx.compose.ui.graphics.Color
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 com.kyant.backdrop.backdrops.layerBackdrop
-import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import me.kavishdevar.librepods.R
-import me.kavishdevar.librepods.presentation.components.StyledScaffold
+import me.kavishdevar.librepods.presentation.m3.ExpressiveDivider
+import me.kavishdevar.librepods.presentation.m3.ExpressiveGroup
+import me.kavishdevar.librepods.presentation.m3.ExpressiveListItem
+import me.kavishdevar.librepods.presentation.m3.ExpressiveScreenScaffold
+import me.kavishdevar.librepods.presentation.m3.ExpressiveSectionHeader
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable
fun VersionScreen(viewModel: AirPodsViewModel) {
val state by viewModel.uiState.collectAsState()
- val isDarkTheme = isSystemInDarkTheme()
- val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
- val textColor = if (isDarkTheme) Color.White else Color.Black
+ val versionLabel = stringResource(R.string.version)
- val backdrop = rememberLayerBackdrop()
-
- StyledScaffold(
- title = stringResource(R.string.version)
- ) { spacerHeight ->
- Column(
- modifier = Modifier
- .fillMaxSize()
- .layerBackdrop(backdrop)
- .padding(horizontal = 16.dp)
+ ExpressiveScreenScaffold(title = versionLabel) { padding ->
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(
+ top = padding.calculateTopPadding(),
+ bottom = 120.dp,
+ start = 16.dp,
+ end = 16.dp,
+ ),
) {
- Spacer(modifier = Modifier.height(spacerHeight))
- Box(
- modifier = Modifier
- .background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
- .padding(horizontal = 16.dp, vertical = 4.dp)
- ){
- Text(
- text = stringResource(R.string.version),
- style = TextStyle(
- fontSize = 14.sp,
- fontWeight = FontWeight.Bold,
- color = textColor.copy(alpha = 0.6f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- }
-
- Column(
- modifier = Modifier
- .clip(RoundedCornerShape(28.dp))
- .fillMaxWidth()
- .background(backgroundColor, RoundedCornerShape(28.dp))
- .padding(top = 2.dp)
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(
- text = stringResource(R.string.version) + " 1",
- style = TextStyle(
- fontSize = 16.sp,
- color = textColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- Text(
- text = state.version1,
- style = TextStyle(
- fontSize = 16.sp,
- color = textColor.copy(0.8f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
+ item {
+ ExpressiveSectionHeader(versionLabel)
+ ExpressiveGroup {
+ ExpressiveListItem(
+ title = "$versionLabel 1",
+ leadingIcon = Icons.Filled.Numbers,
+ value = state.version1,
)
- }
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier
- .padding(horizontal = 12.dp)
- )
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(
- text = stringResource(R.string.version) + " 2",
- style = TextStyle(
- fontSize = 16.sp,
- color = textColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- Text(
- text = state.version2,
- style = TextStyle(
- fontSize = 16.sp,
- color = textColor.copy(0.8f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
- )
- }
- HorizontalDivider(
- thickness = 1.dp,
- color = Color(0x40888888),
- modifier = Modifier
- .padding(horizontal = 12.dp)
- )
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- ) {
- Text(
- text = stringResource(R.string.version) + " 3",
- style = TextStyle(
- fontSize = 16.sp,
- color = textColor,
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
+ ExpressiveDivider()
+ ExpressiveListItem(
+ title = "$versionLabel 2",
+ leadingIcon = Icons.Filled.Numbers,
+ value = state.version2,
)
- Text(
- text = state.version3,
- style = TextStyle(
- fontSize = 16.sp,
- color = textColor.copy(0.8f),
- fontFamily = FontFamily(Font(R.font.sf_pro))
- )
+ ExpressiveDivider()
+ ExpressiveListItem(
+ title = "$versionLabel 3",
+ leadingIcon = Icons.Filled.Numbers,
+ value = state.version3,
)
}
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Color.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Color.kt
index f2e44c1d3..b6dc86f21 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Color.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Color.kt
@@ -16,15 +16,175 @@
along with this program. If not, see .
*/
-
package me.kavishdevar.librepods.presentation.theme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
+/*
+ * Material 3 Expressive palette for LibrePods.
+ *
+ * Vibrant violet primary, electric teal secondary and warm coral tertiary — a bold, energetic
+ * scheme that matches the "expressive" motion. Used as the fallback whenever dynamic color
+ * (Material You, Android 12+) is unavailable. Tonal roles are filled out completely so every
+ * M3 component (cards, sliders, switches, button groups, wavy indicators…) renders correctly.
+ */
+
+// region Light
+private val PrimaryLight = Color(0xFF5E35F2)
+private val OnPrimaryLight = Color(0xFFFFFFFF)
+private val PrimaryContainerLight = Color(0xFFE7DEFF)
+private val OnPrimaryContainerLight = Color(0xFF1B0066)
+
+private val SecondaryLight = Color(0xFF00696E)
+private val OnSecondaryLight = Color(0xFFFFFFFF)
+private val SecondaryContainerLight = Color(0xFF8FF2F8)
+private val OnSecondaryContainerLight = Color(0xFF00201F)
+
+private val TertiaryLight = Color(0xFF9B4500)
+private val OnTertiaryLight = Color(0xFFFFFFFF)
+private val TertiaryContainerLight = Color(0xFFFFDBCA)
+private val OnTertiaryContainerLight = Color(0xFF351000)
+
+private val ErrorLight = Color(0xFFBA1A1A)
+private val OnErrorLight = Color(0xFFFFFFFF)
+private val ErrorContainerLight = Color(0xFFFFDAD6)
+private val OnErrorContainerLight = Color(0xFF410002)
+
+private val BackgroundLight = Color(0xFFFDF7FF)
+private val OnBackgroundLight = Color(0xFF1C1B20)
+private val SurfaceLight = Color(0xFFFDF7FF)
+private val OnSurfaceLight = Color(0xFF1C1B20)
+private val SurfaceVariantLight = Color(0xFFE6E0EC)
+private val OnSurfaceVariantLight = Color(0xFF48454E)
+private val OutlineLight = Color(0xFF79757F)
+private val OutlineVariantLight = Color(0xFFCAC4CF)
+private val SurfaceContainerLowestLight = Color(0xFFFFFFFF)
+private val SurfaceContainerLowLight = Color(0xFFF7F1FB)
+private val SurfaceContainerLight = Color(0xFFF1EBF5)
+private val SurfaceContainerHighLight = Color(0xFFEBE6EF)
+private val SurfaceContainerHighestLight = Color(0xFFE5E0E9)
+private val InverseSurfaceLight = Color(0xFF313035)
+private val InverseOnSurfaceLight = Color(0xFFF4EFF7)
+private val InversePrimaryLight = Color(0xFFCFBDFF)
+// endregion
+
+// region Dark
+private val PrimaryDark = Color(0xFFCFBDFF)
+private val OnPrimaryDark = Color(0xFF330A92)
+private val PrimaryContainerDark = Color(0xFF491CC9)
+private val OnPrimaryContainerDark = Color(0xFFE7DEFF)
+
+private val SecondaryDark = Color(0xFF4FD8E0)
+private val OnSecondaryDark = Color(0xFF00363A)
+private val SecondaryContainerDark = Color(0xFF004F54)
+private val OnSecondaryContainerDark = Color(0xFF8FF2F8)
+
+private val TertiaryDark = Color(0xFFFFB68A)
+private val OnTertiaryDark = Color(0xFF532200)
+private val TertiaryContainerDark = Color(0xFF763300)
+private val OnTertiaryContainerDark = Color(0xFFFFDBCA)
+
+private val ErrorDark = Color(0xFFFFB4AB)
+private val OnErrorDark = Color(0xFF690005)
+private val ErrorContainerDark = Color(0xFF93000A)
+private val OnErrorContainerDark = Color(0xFFFFDAD6)
+
+private val BackgroundDark = Color(0xFF141218)
+private val OnBackgroundDark = Color(0xFFE6E1E9)
+private val SurfaceDark = Color(0xFF141218)
+private val OnSurfaceDark = Color(0xFFE6E1E9)
+private val SurfaceVariantDark = Color(0xFF48454E)
+private val OnSurfaceVariantDark = Color(0xFFCAC4CF)
+private val OutlineDark = Color(0xFF948F99)
+private val OutlineVariantDark = Color(0xFF48454E)
+private val SurfaceContainerLowestDark = Color(0xFF0E0D13)
+private val SurfaceContainerLowDark = Color(0xFF1D1B20)
+private val SurfaceContainerDark = Color(0xFF211F26)
+private val SurfaceContainerHighDark = Color(0xFF2C2A30)
+private val SurfaceContainerHighestDark = Color(0xFF36343B)
+private val InverseSurfaceDark = Color(0xFFE6E1E9)
+private val InverseOnSurfaceDark = Color(0xFF313035)
+private val InversePrimaryDark = Color(0xFF5E35F2)
+// endregion
+
+val LightColors = lightColorScheme(
+ primary = PrimaryLight,
+ onPrimary = OnPrimaryLight,
+ primaryContainer = PrimaryContainerLight,
+ onPrimaryContainer = OnPrimaryContainerLight,
+ secondary = SecondaryLight,
+ onSecondary = OnSecondaryLight,
+ secondaryContainer = SecondaryContainerLight,
+ onSecondaryContainer = OnSecondaryContainerLight,
+ tertiary = TertiaryLight,
+ onTertiary = OnTertiaryLight,
+ tertiaryContainer = TertiaryContainerLight,
+ onTertiaryContainer = OnTertiaryContainerLight,
+ error = ErrorLight,
+ onError = OnErrorLight,
+ errorContainer = ErrorContainerLight,
+ onErrorContainer = OnErrorContainerLight,
+ background = BackgroundLight,
+ onBackground = OnBackgroundLight,
+ surface = SurfaceLight,
+ onSurface = OnSurfaceLight,
+ surfaceVariant = SurfaceVariantLight,
+ onSurfaceVariant = OnSurfaceVariantLight,
+ outline = OutlineLight,
+ outlineVariant = OutlineVariantLight,
+ surfaceContainerLowest = SurfaceContainerLowestLight,
+ surfaceContainerLow = SurfaceContainerLowLight,
+ surfaceContainer = SurfaceContainerLight,
+ surfaceContainerHigh = SurfaceContainerHighLight,
+ surfaceContainerHighest = SurfaceContainerHighestLight,
+ inverseSurface = InverseSurfaceLight,
+ inverseOnSurface = InverseOnSurfaceLight,
+ inversePrimary = InversePrimaryLight,
+ surfaceTint = PrimaryLight,
+)
+
+val DarkColors = darkColorScheme(
+ primary = PrimaryDark,
+ onPrimary = OnPrimaryDark,
+ primaryContainer = PrimaryContainerDark,
+ onPrimaryContainer = OnPrimaryContainerDark,
+ secondary = SecondaryDark,
+ onSecondary = OnSecondaryDark,
+ secondaryContainer = SecondaryContainerDark,
+ onSecondaryContainer = OnSecondaryContainerDark,
+ tertiary = TertiaryDark,
+ onTertiary = OnTertiaryDark,
+ tertiaryContainer = TertiaryContainerDark,
+ onTertiaryContainer = OnTertiaryContainerDark,
+ error = ErrorDark,
+ onError = OnErrorDark,
+ errorContainer = ErrorContainerDark,
+ onErrorContainer = OnErrorContainerDark,
+ background = BackgroundDark,
+ onBackground = OnBackgroundDark,
+ surface = SurfaceDark,
+ onSurface = OnSurfaceDark,
+ surfaceVariant = SurfaceVariantDark,
+ onSurfaceVariant = OnSurfaceVariantDark,
+ outline = OutlineDark,
+ outlineVariant = OutlineVariantDark,
+ surfaceContainerLowest = SurfaceContainerLowestDark,
+ surfaceContainerLow = SurfaceContainerLowDark,
+ surfaceContainer = SurfaceContainerDark,
+ surfaceContainerHigh = SurfaceContainerHighDark,
+ surfaceContainerHighest = SurfaceContainerHighestDark,
+ inverseSurface = InverseSurfaceDark,
+ inverseOnSurface = InverseOnSurfaceDark,
+ inversePrimary = InversePrimaryDark,
+ surfaceTint = PrimaryDark,
+)
+
+// Legacy seed colors kept so any stray references still resolve; no longer used by the theme.
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
-
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Shape.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Shape.kt
new file mode 100644
index 000000000..8f63a8bdd
--- /dev/null
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Shape.kt
@@ -0,0 +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 General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.presentation.theme
+
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Shapes
+import androidx.compose.ui.unit.dp
+
+/*
+ * Material 3 Expressive leans on large, generous corner radii. These feed every M3 component's
+ * default shape (cards, dialogs, buttons, text fields, menus, sheets…) so the whole app gets a
+ * soft, rounded, expressive silhouette.
+ */
+val LibrePodsShapes = Shapes(
+ extraSmall = RoundedCornerShape(8.dp),
+ small = RoundedCornerShape(14.dp),
+ medium = RoundedCornerShape(20.dp),
+ large = RoundedCornerShape(28.dp),
+ extraLarge = RoundedCornerShape(36.dp),
+)
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Theme.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Theme.kt
index 04225ccdd..c3d6fbc11 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Theme.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Theme.kt
@@ -20,26 +20,23 @@ package me.kavishdevar.librepods.presentation.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.MaterialExpressiveTheme
+import androidx.compose.material3.MotionScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
-import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
-private val DarkColorScheme = darkColorScheme(
- primary = Purple80,
- secondary = PurpleGrey80,
- tertiary = Pink80
-)
-
-private val LightColorScheme = lightColorScheme(
- primary = Purple40,
- secondary = PurpleGrey40,
- tertiary = Pink40
-)
-
+/**
+ * Material 3 Expressive theme for LibrePods.
+ *
+ * [MaterialExpressiveTheme] wires in [MotionScheme.expressive] — springs with low damping that give
+ * every Material component a lively, slightly-overshooting "bouncy" feel out of the box. On Android
+ * 12+ we use dynamic color (Material You); otherwise we fall back to the vibrant violet→teal scheme
+ * defined in `Color.kt`. Typography is the Roboto Flex variable scale; shapes are the expressive set.
+ */
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun LibrePodsTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
@@ -52,13 +49,15 @@ fun LibrePodsTheme(
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
- darkTheme -> DarkColorScheme
- else -> LightColorScheme
+ darkTheme -> DarkColors
+ else -> LightColors
}
- MaterialTheme(
+ MaterialExpressiveTheme(
colorScheme = colorScheme,
- typography = Typography,
+ motionScheme = MotionScheme.expressive(),
+ shapes = LibrePodsShapes,
+ typography = LibrePodsTypography,
content = content
)
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Type.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Type.kt
index 72a44242a..e7415eff0 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Type.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/Type.kt
@@ -20,33 +20,69 @@ package me.kavishdevar.librepods.presentation.theme
import androidx.compose.material3.Typography
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.FontVariation
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
+import me.kavishdevar.librepods.R
-// Set of Material typography styles to start with
-val Typography = Typography(
- bodyLarge = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.Normal,
- fontSize = 16.sp,
- lineHeight = 24.sp,
- letterSpacing = 0.5.sp
- )
- /* Other default text styles to override
- titleLarge = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.Normal,
- fontSize = 22.sp,
- lineHeight = 28.sp,
- letterSpacing = 0.sp
- ),
- labelSmall = TextStyle(
- fontFamily = FontFamily.Default,
- fontWeight = FontWeight.Medium,
- fontSize = 11.sp,
- lineHeight = 16.sp,
- letterSpacing = 0.5.sp
- )
- */
+/*
+ * Typography for the Material 3 Expressive redesign — the clean, geometric **Montserrat** variable
+ * font (bundled, OFL — no Google Play Services / downloadable-font dependency, FOSS-friendly).
+ * Each registered weight maps to the single variable font driven through its `wght` axis.
+ */
+private fun montserrat(weight: Int): Font = Font(
+ resId = R.font.montserrat,
+ weight = FontWeight(weight),
+ variationSettings = FontVariation.Settings(FontVariation.weight(weight)),
+)
+
+val MontserratFamily = FontFamily(
+ montserrat(300),
+ montserrat(400),
+ montserrat(500),
+ montserrat(600),
+ montserrat(700),
+ montserrat(800),
+)
+
+private val BaseTypography = Typography(
+ displayLarge = TextStyle(fontFamily = MontserratFamily, fontWeight = FontWeight.Bold, fontSize = 57.sp, lineHeight = 64.sp, letterSpacing = (-0.25).sp),
+ displayMedium = TextStyle(fontFamily = MontserratFamily, fontWeight = FontWeight.Bold, fontSize = 45.sp, lineHeight = 52.sp),
+ displaySmall = TextStyle(fontFamily = MontserratFamily, fontWeight = FontWeight.Bold, fontSize = 36.sp, lineHeight = 44.sp),
+ headlineLarge = TextStyle(fontFamily = MontserratFamily, fontWeight = FontWeight.SemiBold, fontSize = 32.sp, lineHeight = 40.sp),
+ headlineMedium = TextStyle(fontFamily = MontserratFamily, fontWeight = FontWeight.SemiBold, fontSize = 28.sp, lineHeight = 36.sp),
+ headlineSmall = TextStyle(fontFamily = MontserratFamily, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 32.sp),
+ titleLarge = TextStyle(fontFamily = MontserratFamily, fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 28.sp),
+ titleMedium = TextStyle(fontFamily = MontserratFamily, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp),
+ titleSmall = TextStyle(fontFamily = MontserratFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp),
+ bodyLarge = TextStyle(fontFamily = MontserratFamily, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp, letterSpacing = 0.5.sp),
+ bodyMedium = TextStyle(fontFamily = MontserratFamily, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.25.sp),
+ bodySmall = TextStyle(fontFamily = MontserratFamily, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp),
+ labelLarge = TextStyle(fontFamily = MontserratFamily, fontWeight = FontWeight.SemiBold, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.1.sp),
+ labelMedium = TextStyle(fontFamily = MontserratFamily, fontWeight = FontWeight.SemiBold, fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp),
+ labelSmall = TextStyle(fontFamily = MontserratFamily, fontWeight = FontWeight.SemiBold, fontSize = 11.sp, lineHeight = 16.sp, letterSpacing = 0.5.sp),
+)
+
+/*
+ * M3 Expressive "emphasized" type roles — same metrics, heavier Montserrat. Without these the
+ * library defaults silently fall back to Roboto for emphasized text in expressive components.
+ */
+val LibrePodsTypography = BaseTypography.copy(
+ displayLargeEmphasized = BaseTypography.displayLarge,
+ displayMediumEmphasized = BaseTypography.displayMedium,
+ displaySmallEmphasized = BaseTypography.displaySmall,
+ headlineLargeEmphasized = BaseTypography.headlineLarge.copy(fontWeight = FontWeight.Bold),
+ headlineMediumEmphasized = BaseTypography.headlineMedium.copy(fontWeight = FontWeight.Bold),
+ headlineSmallEmphasized = BaseTypography.headlineSmall.copy(fontWeight = FontWeight.Bold),
+ titleLargeEmphasized = BaseTypography.titleLarge.copy(fontWeight = FontWeight.Bold),
+ titleMediumEmphasized = BaseTypography.titleMedium.copy(fontWeight = FontWeight.Bold),
+ titleSmallEmphasized = BaseTypography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
+ bodyLargeEmphasized = BaseTypography.bodyLarge.copy(fontWeight = FontWeight.SemiBold),
+ bodyMediumEmphasized = BaseTypography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
+ bodySmallEmphasized = BaseTypography.bodySmall.copy(fontWeight = FontWeight.SemiBold),
+ labelLargeEmphasized = BaseTypography.labelLarge.copy(fontWeight = FontWeight.Bold),
+ labelMediumEmphasized = BaseTypography.labelMedium.copy(fontWeight = FontWeight.Bold),
+ labelSmallEmphasized = BaseTypography.labelSmall.copy(fontWeight = FontWeight.Bold),
)
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt
index 8fba23bb5..8fc9b8caf 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AirPodsViewModel.kt
@@ -50,6 +50,7 @@ import me.kavishdevar.librepods.data.ControlCommandRepository
import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
import me.kavishdevar.librepods.services.AirPodsService
+import me.kavishdevar.librepods.utils.GestureDetector
@Suppress("ArrayInDataClass")
data class AirPodsUiState(
@@ -75,6 +76,7 @@ data class AirPodsUiState(
val headTrackingActive: Boolean = false,
val headGesturesEnabled: Boolean = true,
+ val headGestureSensitivity: Float = GestureDetector.DEFAULT_SENSITIVITY,
val eqData: FloatArray = floatArrayOf(),
@@ -170,7 +172,8 @@ class AirPodsViewModel(
when (key) {
"name" -> loadName()
"off_listening_mode", "automatic_ear_detection", "automatic_connection_ctrl_cmd",
- "head_gestures", "left_long_press_action", "right_long_press_action",
+ "head_gestures", "head_gesture_sensitivity",
+ "left_long_press_action", "right_long_press_action",
"dynamic_end_of_charge" -> loadSharedPreferences()
}
}
@@ -334,6 +337,9 @@ class AirPodsViewModel(
val automaticConnectionEnabled =
sharedPreferences.getBoolean("automatic_connection_ctrl_cmd", true)
val headGesturesEnabled = sharedPreferences.getBoolean("head_gestures", true)
+ val headGestureSensitivity = sharedPreferences.getFloat(
+ GestureDetector.PREF_KEY_SENSITIVITY, GestureDetector.DEFAULT_SENSITIVITY
+ )
val leftAction = StemAction.valueOf(
sharedPreferences.getString(
"left_long_press_action",
@@ -357,6 +363,7 @@ class AirPodsViewModel(
automaticEarDetectionEnabled = automaticEarDetectionEnabled,
automaticConnectionEnabled = automaticConnectionEnabled,
headGesturesEnabled = headGesturesEnabled,
+ headGestureSensitivity = headGestureSensitivity,
leftAction = leftAction,
rightAction = rightAction,
vendorIdHook = vendorIdHook,
@@ -381,6 +388,14 @@ class AirPodsViewModel(
}
}
+ fun setHeadGestureSensitivity(sensitivity: Float) {
+ val clamped = sensitivity.coerceIn(0f, 1f)
+ sharedPreferences.edit { putFloat(GestureDetector.PREF_KEY_SENSITIVITY, clamped) }
+ _uiState.update {
+ it.copy(headGestureSensitivity = clamped)
+ }
+ }
+
fun setDynamicEndOfCharge(enabled: Boolean) {
service.aacpManager.sendControlCommand(ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE.value, enabled)
sharedPreferences.edit { putBoolean("dynamic_end_of_charge", enabled) }
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt
index 7e0b126bb..839c46d66 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/viewmodel/AppSettingsViewModel.kt
@@ -33,8 +33,6 @@ data class AppSettingsUiState(
// All features are unlocked; billing was removed from this FOSS fork.
val isPremium: Boolean = true,
val connectionSuccessful: Boolean = false,
- val showBottomSheetPopup: Boolean = true,
- val showIslandPopup: Boolean = true,
)
class AppSettingsViewModel(application: Application) : AndroidViewModel(application) {
@@ -79,9 +77,7 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat(),
cameraPackageValue = sharedPreferences.getString("custom_camera_package", "") ?: "",
vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false),
- connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false),
- showBottomSheetPopup = sharedPreferences.getBoolean("show_bottom_sheet_popup", true),
- showIslandPopup = sharedPreferences.getBoolean("show_island_popup", true)
+ connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false)
)
}
}
@@ -172,13 +168,4 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
_uiState.update { it.copy(vendorIdHook = enabled) }
}
- fun setShowBottomSheetPopup(enabled: Boolean) {
- sharedPreferences.edit { putBoolean("show_bottom_sheet_popup", enabled) }
- _uiState.update { it.copy(showBottomSheetPopup = enabled) }
- }
-
- fun setShowIslandPopup(enabled: Boolean) {
- sharedPreferences.edit { putBoolean("show_island_popup", enabled) }
- _uiState.update { it.copy(showIslandPopup = enabled) }
- }
}
diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt
index f7f37372b..16cd4e42b 100644
--- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt
+++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsService.kt
@@ -101,9 +101,6 @@ import me.kavishdevar.librepods.data.BatteryStatus
import me.kavishdevar.librepods.data.StemAction
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
import me.kavishdevar.librepods.data.isHeadTrackingData
-import me.kavishdevar.librepods.presentation.overlays.IslandType
-import me.kavishdevar.librepods.presentation.overlays.IslandWindow
-import me.kavishdevar.librepods.presentation.overlays.PopupWindow
import me.kavishdevar.librepods.presentation.widgets.BatteryWidget
import me.kavishdevar.librepods.presentation.widgets.NoiseControlWidget
import me.kavishdevar.librepods.utils.GestureDetector
@@ -308,11 +305,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
) {
if (lidOpen) {
Log.d(TAG, "Lid opened")
- showPopup(
- this@AirPodsService,
- getSharedPreferences("settings", MODE_PRIVATE).getString("name", "AirPods Pro")
- ?: "AirPods"
- )
if (socket.isConnected) return
val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0
val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0
@@ -590,6 +582,25 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
enabled
)
}
+ } else if (intent?.action == "me.kavishdevar.librepods.GESTURE_CAPTURE") {
+ when (intent.getStringExtra("action")) {
+ "start" -> {
+ gestureCaptureLabel = intent.getStringExtra("label") ?: "idle"
+ gestureCaptureStart = System.currentTimeMillis()
+ gestureCaptureActive = true
+ startHeadTracking()
+ Log.d("GCAP", "GCAP_START label=$gestureCaptureLabel cols=ms,h,v,o1,o2,o3,label")
+ }
+ "stop" -> {
+ gestureCaptureActive = false
+ stopHeadTracking()
+ Log.d("GCAP", "GCAP_STOP")
+ }
+ "label" -> {
+ gestureCaptureLabel = intent.getStringExtra("label") ?: "idle"
+ Log.d("GCAP", "GCAP_LABEL ${gestureCaptureLabel} t=${System.currentTimeMillis() - gestureCaptureStart}")
+ }
+ }
}
}
}
@@ -716,7 +727,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} else if (intent?.action == AirPodsNotifications.AIRPODS_DISCONNECTED) {
device = null
// isConnectedLocally = false
- popupShown = false
updateNotificationContent(false)
aacpManager.disconnected()
attManager.disconnected()
@@ -725,40 +735,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
}
}
- val showIslandReceiver = object : BroadcastReceiver() {
- override fun onReceive(context: Context?, intent: Intent?) {
- if (intent?.action == "me.kavishdevar.librepods.cross_device_island") {
- showIsland(
- this@AirPodsService,
- batteryNotification.getBattery()
- .find { it.component == BatteryComponent.LEFT }?.level!!.coerceAtMost(
- batteryNotification.getBattery()
- .find { it.component == BatteryComponent.RIGHT }?.level!!
- )
- )
- } else if (intent?.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
- try {
- context?.unregisterReceiver(this)
- } catch (e: Exception) {
- e.printStackTrace()
- }
- }
- }
- }
-
- val showIslandIntentFilter = IntentFilter().apply {
- addAction("me.kavishdevar.librepods.cross_device_island")
- addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
- }
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
- registerReceiver(showIslandReceiver, showIslandIntentFilter, RECEIVER_EXPORTED)
- } else {
- @Suppress("UnspecifiedRegisterReceiverFlag") registerReceiver(
- showIslandReceiver, showIslandIntentFilter
- )
- }
-
val deviceIntentFilter = IntentFilter().apply {
addAction(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
@@ -995,50 +971,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
Log.d(TAG, "reverse tapped, disconnecting audio")
disconnectedBecauseReversed = true
disconnectAudio(this@AirPodsService, device)
- showIsland(
- this@AirPodsService,
- (batteryNotification.getBattery()
- .find { it.component == BatteryComponent.LEFT }?.level
- ?: 0).coerceAtMost(
- batteryNotification.getBattery()
- .find { it.component == BatteryComponent.RIGHT }?.level ?: 0
- ),
- IslandType.MOVED_TO_OTHER_DEVICE,
- reversed = true,
- otherDeviceName = senderName
- )
- }
- if (!aacpManager.owns) {
- showIsland(
- this@AirPodsService,
- (batteryNotification.getBattery()
- .find { it.component == BatteryComponent.LEFT }?.level
- ?: 0).coerceAtMost(
- batteryNotification.getBattery()
- .find { it.component == BatteryComponent.RIGHT }?.level ?: 0
- ),
- IslandType.MOVED_TO_OTHER_DEVICE,
- reversed = reasonReverseTapped,
- otherDeviceName = senderName
- )
}
MediaController.sendPause()
}
override fun onShowNearbyUI(sender: String) {
- val senderName =
- aacpManager.connectedDevices.find { it.mac == sender }?.type ?: "Other device"
- showIsland(
- this@AirPodsService,
- (batteryNotification.getBattery()
- .find { it.component == BatteryComponent.LEFT }?.level ?: 0).coerceAtMost(
- batteryNotification.getBattery()
- .find { it.component == BatteryComponent.RIGHT }?.level ?: 0
- ),
- IslandType.MOVED_TO_OTHER_DEVICE,
- reversed = false,
- otherDeviceName = senderName
- )
}
override fun onDeviceInformationReceived(deviceInformation: AACPManager.Companion.AirPodsInformation) {
@@ -1261,24 +1198,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
data[0] == 0x00.toByte(), data[1] == 0x00.toByte()
)
- if (inEarData.sorted() == listOf(false, false) && newInEarData.sorted() != listOf(
- false, false
- ) && islandWindow?.isVisible != true
- ) {
- showIsland(
- this@AirPodsService,
- (batteryNotification.getBattery()
- .find { it.component == BatteryComponent.LEFT }?.level ?: 0).coerceAtMost(
- batteryNotification.getBattery()
- .find { it.component == BatteryComponent.RIGHT }?.level ?: 0
- )
- )
- }
-
- if (newInEarData == listOf(false, false) && islandWindow?.isVisible == true) {
- islandWindow?.close()
- }
-
if (newInEarData.contains(true) && inEarData == listOf(false, false)) {
connectAudio(this@AirPodsService, device)
justEnabledA2dp = true
@@ -1663,6 +1582,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
private var isInCall = false
private var callNumber: String? = null
+ // --- Head-gesture raw-data capture (dev tuning only) ---------------------
+ // Decoupled from GestureDetector: dumps every head-tracking sample to logcat
+ // under tag "GCAP" as CSV so the nod/shake signal can be analysed offline.
+ // Driven entirely over adb via the GESTURE_CAPTURE broadcast; no UI.
+ private var gestureCaptureActive = false
+ private var gestureCaptureLabel = "idle"
+ private var gestureCaptureStart = 0L
+
private fun initGestureDetector() {
if (gestureDetector == null) {
gestureDetector = GestureDetector(this)
@@ -1670,55 +1597,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
}
- var popupShown = false
- fun showPopup(service: Service, name: String) {
- if (!sharedPreferences.getBoolean("show_bottom_sheet_popup", true)) {
- return
- }
- if (!Settings.canDrawOverlays(service)) {
- Log.d(TAG, "No permission for SYSTEM_ALERT_WINDOW")
- return
- }
- if (popupShown) {
- return
- }
- val popupWindow = PopupWindow(service.applicationContext)
- popupWindow.open(name, batteryNotification)
- popupShown = true
- }
-
- var islandOpen = false
- var islandWindow: IslandWindow? = null
-
- @SuppressLint("MissingPermission")
- fun showIsland(
- service: Service,
- batteryPercentage: Int,
- type: IslandType = IslandType.CONNECTED,
- reversed: Boolean = false,
- otherDeviceName: String? = null
- ) {
- Log.d(TAG, "Showing island window")
- if (!sharedPreferences.getBoolean("show_island_popup", true)) {
- return
- }
- if (!Settings.canDrawOverlays(service)) {
- Log.d(TAG, "No permission for SYSTEM_ALERT_WINDOW")
- return
- }
- CoroutineScope(Dispatchers.Main).launch {
- islandWindow = IslandWindow(service.applicationContext)
- islandWindow!!.show(
- sharedPreferences.getString("name", "AirPods Pro").toString(),
- batteryPercentage,
- this@AirPodsService,
- type,
- reversed,
- otherDeviceName
- )
- }
- }
-
@OptIn(ExperimentalMaterial3Api::class)
fun startMainActivity() {
val intent = Intent(this, MainActivity::class.java)
@@ -2174,8 +2052,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} catch (e: Exception) {
e.printStackTrace()
sendToast("Failed to answer call: ${e.message}")
- } finally {
- islandWindow?.close()
}
}
@@ -2200,8 +2076,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} catch (e: Exception) {
e.printStackTrace()
sendToast("Failed to reject call: ${e.message}")
- } finally {
- islandWindow?.close()
}
}
@@ -2215,6 +2089,16 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
fun processHeadTrackingData(data: ByteArray) {
val horizontal = ByteBuffer.wrap(data, 51, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt()
val vertical = ByteBuffer.wrap(data, 53, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt()
+ if (gestureCaptureActive && data.size >= 55) {
+ // Raw orientation triple (bytes 43-48) logged alongside the accel pair
+ // the detector currently uses, so we can compare which signal best
+ // separates nod vs shake. CSV: ms,h,v,o1,o2,o3,label
+ val o1 = ByteBuffer.wrap(data, 43, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt()
+ val o2 = ByteBuffer.wrap(data, 45, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt()
+ val o3 = ByteBuffer.wrap(data, 47, 2).order(ByteOrder.LITTLE_ENDIAN).short.toInt()
+ val t = System.currentTimeMillis() - gestureCaptureStart
+ Log.d("GCAP", "GCAP,$t,$horizontal,$vertical,$o1,$o2,$o3,$gestureCaptureLabel")
+ }
try {
gestureDetector?.processHeadOrientation(horizontal, vertical)
} catch (e: Exception) {
@@ -2449,6 +2333,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val externalBroadcastFilter = IntentFilter().apply {
addAction("me.kavishdevar.librepods.SET_ANC_MODE")
addAction("me.kavishdevar.librepods.CONVO_DETECT")
+ addAction("me.kavishdevar.librepods.GESTURE_CAPTURE")
}
var externalBroadcastReceiver: BroadcastReceiver? = null
@@ -2541,15 +2426,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
)
otherDeviceTookOver = false
connectAudio(this, device)
- showIsland(
- this,
- batteryNotification.getBattery()
- .find { it.component == BatteryComponent.LEFT }?.level!!.coerceAtMost(
- batteryNotification.getBattery()
- .find { it.component == BatteryComponent.RIGHT }?.level!!
- ),
- IslandType.CONNECTED
- )
CoroutineScope(Dispatchers.IO).launch {
delay(500) // a2dp takes time, and so does taking control + AirPods pause it for no reason after connecting
@@ -2652,16 +2528,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
// isConnectedLocally = true
}
}
- showIsland(
- this,
- batteryNotification.getBattery()
- .find { it.component == BatteryComponent.LEFT }?.level!!.coerceAtMost(
- batteryNotification.getBattery()
- .find { it.component == BatteryComponent.RIGHT }?.level!!
- ),
- IslandType.TAKING_OVER
- )
-
// CrossDevice.isAvailable = false
}
@@ -2964,16 +2830,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (!this::socket.isInitialized) return
socket.close()
MediaController.pausedWhileTakingOver = false
- Log.d(TAG, "Disconnected from AirPods, showing island.")
- showIsland(
- this,
- batteryNotification.getBattery()
- .find { it.component == BatteryComponent.LEFT }?.level!!.coerceAtMost(
- batteryNotification.getBattery()
- .find { it.component == BatteryComponent.RIGHT }?.level!!
- ),
- IslandType.MOVED_TO_REMOTE
- )
+ Log.d(TAG, "Disconnected from AirPods.")
val bluetoothAdapter = getSystemService(BluetoothManager::class.java).adapter
bluetoothAdapter.getProfileProxy(this, object : BluetoothProfile.ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
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 9892096bc..3ab74a9c0 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
@@ -20,6 +20,7 @@
package me.kavishdevar.librepods.utils
+import android.content.Context
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
@@ -30,77 +31,129 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.kavishdevar.librepods.services.AirPodsService
-import me.kavishdevar.librepods.services.ServiceManager
-import java.util.Collections
-import java.util.concurrent.CopyOnWriteArrayList
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
+/**
+ * Detects "yes" (nod, vertical) and "no" (shake, horizontal) head gestures from the
+ * head-tracking velocity pair the AirPods stream (bytes 51-54 of the tracking packet).
+ *
+ * The detector scores each axis on oscillation amplitude, rhythm, peak/trough
+ * alternation and cross-axis isolation, and fires as soon as one axis clears the
+ * confidence threshold. Everything is measured against a per-axis baseline that is
+ * learned at start and tracked slowly afterwards, because the raw values carry a
+ * posture-dependent DC offset that otherwise biases whichever axis happens to sit
+ * further from zero. All rhythm/interval state is kept strictly per-axis — an earlier
+ * version shared one interval list between both axes, which let incidental motion on
+ * one axis corrupt the other's rhythm score and made results feel random.
+ *
+ * Sensitivity is user-tunable via the "head_gesture_sensitivity" preference (0..1,
+ * 0.5 ≈ the historical default): it scales how large a swing must be to count as a
+ * peak and how much amplitude earns a full confidence score.
+ */
class GestureDetector(
private val airPodsService: AirPodsService
) {
companion object {
private const val TAG = "GestureDetector"
- private const val IMMEDIATE_FEEDBACK_THRESHOLD = 600
- private const val DIRECTION_CHANGE_SENSITIVITY = 150
+ const val PREF_KEY_SENSITIVITY = "head_gesture_sensitivity"
+ const val DEFAULT_SENSITIVITY = 0.5f
- private const val FAST_MOVEMENT_THRESHOLD = 300.0
+ // Sensitivity endpoints: thresholds interpolate between these as the user
+ // moves the slider from 0 (deliberate movements only) to 1 (gentle nods).
+ private const val PEAK_THRESHOLD_AT_0 = 550.0
+ private const val PEAK_THRESHOLD_AT_1 = 150.0
+ private const val AMPLITUDE_TARGET_AT_0 = 950.0
+ private const val AMPLITUDE_TARGET_AT_1 = 350.0
+ private const val FEEDBACK_THRESHOLD_AT_0 = 900.0
+ private const val FEEDBACK_THRESHOLD_AT_1 = 300.0
+
+ private const val MIN_CONFIDENCE = 0.7
+
+ private const val DIRECTION_CHANGE_SENSITIVITY = 150.0
+ private const val FAST_MOVEMENT_THRESHOLD_MS = 300.0
private const val MIN_REQUIRED_EXTREMES = 3
private const val MAX_REQUIRED_EXTREMES = 4
private const val MAX_VALID_ORIENTATION_VALUE = 6000
- }
- val audio = GestureFeedback(ServiceManager.getService()?.baseContext!!)
+ // Extremes older than this no longer describe the gesture happening *now*.
+ // Without expiry, a stray early movement (settling the phone, glancing down)
+ // permanently primes one axis, and whichever axis got primed first wins.
+ private const val GESTURE_WINDOW_MS = 2600L
- private val horizontalBuffer = Collections.synchronizedList(ArrayList())
- private val verticalBuffer = Collections.synchronizedList(ArrayList())
+ // Samples used to learn the resting baseline before peak detection starts.
+ private const val WARMUP_SAMPLES = 8
+ // Slow tracking so the baseline follows posture changes but not the gesture
+ // oscillation itself.
+ private const val BASELINE_ALPHA = 0.04
- private val horizontalAvgBuffer = Collections.synchronizedList(ArrayList())
- private val verticalAvgBuffer = Collections.synchronizedList(ArrayList())
+ private const val SMOOTHING_WINDOW = 3
+ private const val RECENT_WINDOW = 24
- private var prevHorizontal: Double = 0.0
- private var prevVertical: Double = 0.0
+ private const val RHYTHM_CONSISTENCY_THRESHOLD = 0.5
- private val horizontalPeaks = CopyOnWriteArrayList>()
- private val horizontalTroughs = CopyOnWriteArrayList>()
- private val verticalPeaks = CopyOnWriteArrayList>()
- private val verticalTroughs = CopyOnWriteArrayList>()
-
- private var lastPeakTime: Long = 0
- private val peakIntervals = Collections.synchronizedList(ArrayList())
+ private fun lerp(from: Double, to: Double, t: Double): Double = from + (to - from) * t
+ }
- private val movementSpeedIntervals = Collections.synchronizedList(ArrayList())
+ val audio = GestureFeedback(airPodsService.baseContext)
+
+ private data class Extreme(
+ val order: Long,
+ val value: Double,
+ val timeMs: Long,
+ val isPeak: Boolean,
+ )
+
+ /** All mutable per-axis detection state. Guarded by [lock]. */
+ private class AxisState {
+ val smoothing = ArrayDeque()
+ val recentCentered = ArrayDeque()
+ val extremes = ArrayDeque()
+ var baseline = 0.0
+ var warmupCount = 0
+ var sampleCount = 0L
+ var prevCentered = 0.0
+ var increasing: Boolean? = null
+
+ fun reset() {
+ smoothing.clear()
+ recentCentered.clear()
+ extremes.clear()
+ baseline = 0.0
+ warmupCount = 0
+ sampleCount = 0L
+ prevCentered = 0.0
+ increasing = null
+ }
+ }
- private val peakThreshold = 400
- private val directionChangeThreshold = DIRECTION_CHANGE_SENSITIVITY
- private val rhythmConsistencyThreshold = 0.5
+ private val lock = Any()
+ private val verticalAxis = AxisState()
+ private val horizontalAxis = AxisState()
- private var horizontalIncreasing: Boolean? = null
- private var verticalIncreasing: Boolean? = null
+ private var hasRawSample = false
+ private var prevRawHorizontal = 0.0
+ private var prevRawVertical = 0.0
- private val minConfidenceThreshold = 0.7
+ // Thresholds derived from the sensitivity preference at startDetection().
+ private var peakThreshold = lerp(PEAK_THRESHOLD_AT_0, PEAK_THRESHOLD_AT_1, DEFAULT_SENSITIVITY.toDouble())
+ private var amplitudeTarget = lerp(AMPLITUDE_TARGET_AT_0, AMPLITUDE_TARGET_AT_1, DEFAULT_SENSITIVITY.toDouble())
+ private var feedbackThreshold = lerp(FEEDBACK_THRESHOLD_AT_0, FEEDBACK_THRESHOLD_AT_1, DEFAULT_SENSITIVITY.toDouble())
private var isRunning = false
private var detectionJob: Job? = null
private var gestureDetectedCallback: ((Boolean) -> Unit)? = null
- private var significantMotion = false
- private var lastSignificantMotionTime = 0L
-
- init {
- while (horizontalAvgBuffer.size < 3) horizontalAvgBuffer.add(0.0)
- while (verticalAvgBuffer.size < 3) verticalAvgBuffer.add(0.0)
- }
-
-fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> Unit) {
+ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> Unit) {
if (isRunning) return
- Log.d(TAG, "Starting gesture detection...")
+ applySensitivity()
+ Log.d(TAG, "Starting gesture detection (peakThreshold=$peakThreshold, amplitudeTarget=$amplitudeTarget)")
isRunning = true
gestureDetectedCallback = onGestureDetected
@@ -108,9 +161,6 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U
clearData()
- prevHorizontal = 0.0
- prevVertical = 0.0
-
detectionJob = CoroutineScope(Dispatchers.Default).launch {
while (isRunning) {
delay(50)
@@ -128,6 +178,7 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U
}
}
}
+
fun stopDetection(doNotStop: Boolean = false) {
if (!isRunning) return
@@ -141,6 +192,15 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U
gestureDetectedCallback = null
}
+ private fun applySensitivity() {
+ val prefs = airPodsService.getSharedPreferences("settings", Context.MODE_PRIVATE)
+ val sensitivity = prefs.getFloat(PREF_KEY_SENSITIVITY, DEFAULT_SENSITIVITY)
+ .coerceIn(0f, 1f).toDouble()
+ peakThreshold = lerp(PEAK_THRESHOLD_AT_0, PEAK_THRESHOLD_AT_1, sensitivity)
+ amplitudeTarget = lerp(AMPLITUDE_TARGET_AT_0, AMPLITUDE_TARGET_AT_1, sensitivity)
+ feedbackThreshold = lerp(FEEDBACK_THRESHOLD_AT_0, FEEDBACK_THRESHOLD_AT_1, sensitivity)
+ }
+
@RequiresApi(Build.VERSION_CODES.R)
fun processHeadOrientation(horizontal: Int, vertical: Int) {
if (!isRunning) return
@@ -150,267 +210,206 @@ fun startDetection(doNotStop: Boolean = false, onGestureDetected: (Boolean) -> U
return
}
- val horizontalDelta = horizontal - prevHorizontal
- val verticalDelta = vertical - prevVertical
+ // Directional audio feedback on the raw deltas. Skipped for the very first
+ // sample: prev starts at 0, so the initial delta is meaningless and used to
+ // trigger a spurious blip the moment detection started.
+ if (hasRawSample) {
+ val horizontalDelta = horizontal - prevRawHorizontal
+ val verticalDelta = vertical - prevRawVertical
- val significantHorizontal = abs(horizontalDelta) > IMMEDIATE_FEEDBACK_THRESHOLD
- val significantVertical = abs(verticalDelta) > IMMEDIATE_FEEDBACK_THRESHOLD
+ val significantHorizontal = abs(horizontalDelta) > feedbackThreshold
+ val significantVertical = abs(verticalDelta) > feedbackThreshold
- if (significantHorizontal && (!significantVertical || abs(horizontalDelta) > abs(verticalDelta))) {
- CoroutineScope(Dispatchers.Main).launch {
- audio.playDirectional(isVertical = false, value = horizontalDelta)
- }
- significantMotion = true
- lastSignificantMotionTime = System.currentTimeMillis()
- Log.d(TAG, "Significant HORIZONTAL movement: $horizontalDelta")
- }
- else if (significantVertical) {
- CoroutineScope(Dispatchers.Main).launch {
- audio.playDirectional(isVertical = true, value = verticalDelta)
+ if (significantHorizontal && (!significantVertical || abs(horizontalDelta) > abs(verticalDelta))) {
+ CoroutineScope(Dispatchers.Main).launch {
+ audio.playDirectional(isVertical = false, value = horizontalDelta)
+ }
+ } else if (significantVertical) {
+ CoroutineScope(Dispatchers.Main).launch {
+ audio.playDirectional(isVertical = true, value = verticalDelta)
+ }
}
- significantMotion = true
- lastSignificantMotionTime = System.currentTimeMillis()
- Log.d(TAG, "Significant VERTICAL movement: $verticalDelta")
- }
- else if (significantMotion &&
- (System.currentTimeMillis() - lastSignificantMotionTime) > 300) {
- significantMotion = false
}
+ prevRawHorizontal = horizontal.toDouble()
+ prevRawVertical = vertical.toDouble()
+ hasRawSample = true
- prevHorizontal = horizontal.toDouble()
- prevVertical = vertical.toDouble()
-
- val smoothHorizontal = applySmoothing(horizontal.toDouble(), horizontalAvgBuffer)
- val smoothVertical = applySmoothing(vertical.toDouble(), verticalAvgBuffer)
-
- synchronized(horizontalBuffer) {
- horizontalBuffer.add(smoothHorizontal)
- if (horizontalBuffer.size > 100) horizontalBuffer.removeAt(0)
- }
-
- synchronized(verticalBuffer) {
- verticalBuffer.add(smoothVertical)
- if (verticalBuffer.size > 100) verticalBuffer.removeAt(0)
+ val now = System.currentTimeMillis()
+ synchronized(lock) {
+ processSample(horizontalAxis, horizontal.toDouble(), now)
+ processSample(verticalAxis, vertical.toDouble(), now)
}
-
- detectPeaksAndTroughs()
}
- private fun applySmoothing(newValue: Double, buffer: MutableList): Double {
- synchronized(buffer) {
- buffer.add(newValue)
- if (buffer.size > 3) buffer.removeAt(0)
- return buffer.average()
+ /** Smooth, learn/track the baseline, then look for direction changes. */
+ private fun processSample(axis: AxisState, raw: Double, now: Long) {
+ axis.smoothing.addLast(raw)
+ if (axis.smoothing.size > SMOOTHING_WINDOW) axis.smoothing.removeFirst()
+ val smoothed = axis.smoothing.average()
+
+ if (axis.warmupCount < WARMUP_SAMPLES) {
+ // Running mean of the resting signal; no peak detection during warmup so
+ // the settling sensor can't seed phantom extremes.
+ axis.warmupCount++
+ axis.baseline += (smoothed - axis.baseline) / axis.warmupCount
+ axis.prevCentered = 0.0
+ return
}
- }
-
-
- private fun detectPeaksAndTroughs() {
- if (horizontalBuffer.size < 4 || verticalBuffer.size < 4) return
-
- val hValues = horizontalBuffer.takeLast(4)
- val vValues = verticalBuffer.takeLast(4)
- val hVariance = calculateVariance(hValues)
- val vVariance = calculateVariance(vValues)
-
- processDirectionChanges(
- horizontalBuffer,
- horizontalIncreasing,
- hVariance,
- horizontalPeaks,
- horizontalTroughs
- )?.let { horizontalIncreasing = it }
-
- processDirectionChanges(
- verticalBuffer,
- verticalIncreasing,
- vVariance,
- verticalPeaks,
- verticalTroughs
- )?.let { verticalIncreasing = it }
- }
- private fun processDirectionChanges(
- buffer: List,
- isIncreasing: Boolean?,
- variance: Double,
- peaks: MutableList>,
- troughs: MutableList>
- ): Boolean? {
- if (buffer.size < 2) return isIncreasing
+ axis.baseline += BASELINE_ALPHA * (smoothed - axis.baseline)
+ val centered = smoothed - axis.baseline
- val current = buffer.last()
- val prev = buffer[buffer.size - 2]
- var increasing = isIncreasing ?: (current > prev)
+ axis.recentCentered.addLast(centered)
+ if (axis.recentCentered.size > RECENT_WINDOW) axis.recentCentered.removeFirst()
- val dynamicThreshold = max(50.0, min(directionChangeThreshold.toDouble(), variance / 3))
+ axis.sampleCount++
+ detectDirectionChange(axis, centered, now)
+ pruneStaleExtremes(axis, now)
+ }
- val now = System.currentTimeMillis()
+ private fun detectDirectionChange(axis: AxisState, centered: Double, now: Long) {
+ val prev = axis.prevCentered
+ axis.prevCentered = centered
+ if (axis.sampleCount < 2) return
- if (increasing && current < prev - dynamicThreshold) {
- if (abs(prev) > peakThreshold) {
- peaks.add(Triple(buffer.size - 1, prev, now))
- if (lastPeakTime > 0) {
- val interval = (now - lastPeakTime) / 1000.0
- val timeDiff = now - lastPeakTime
+ val variance = calculateVariance(axis.recentCentered.toList().takeLast(4))
+ val hysteresis = max(50.0, min(DIRECTION_CHANGE_SENSITIVITY, variance / 3))
- synchronized(peakIntervals) {
- peakIntervals.add(interval)
- if (peakIntervals.size > 5) peakIntervals.removeAt(0)
- }
+ var increasing = axis.increasing ?: (centered > prev)
- synchronized(movementSpeedIntervals) {
- movementSpeedIntervals.add(timeDiff)
- if (movementSpeedIntervals.size > 5) movementSpeedIntervals.removeAt(0)
- }
- }
- lastPeakTime = now
+ if (increasing && centered < prev - hysteresis) {
+ // Local maximum. Only positive maxima past the threshold are real swings;
+ // a "peak" below the baseline is just noise on the way down.
+ if (prev > peakThreshold) {
+ axis.extremes.addLast(Extreme(axis.sampleCount, prev, now, isPeak = true))
}
increasing = false
- } else if (!increasing && current > prev + dynamicThreshold) {
- if (abs(prev) > peakThreshold) {
- troughs.add(Triple(buffer.size - 1, prev, now))
-
- if (lastPeakTime > 0) {
- val interval = (now - lastPeakTime) / 1000.0
- val timeDiff = now - lastPeakTime
-
- synchronized(peakIntervals) {
- peakIntervals.add(interval)
- if (peakIntervals.size > 5) peakIntervals.removeAt(0)
- }
-
- synchronized(movementSpeedIntervals) {
- movementSpeedIntervals.add(timeDiff)
- if (movementSpeedIntervals.size > 5) movementSpeedIntervals.removeAt(0)
- }
- }
- lastPeakTime = now
+ } else if (!increasing && centered > prev + hysteresis) {
+ if (prev < -peakThreshold) {
+ axis.extremes.addLast(Extreme(axis.sampleCount, prev, now, isPeak = false))
}
increasing = true
}
- return increasing
+ axis.increasing = increasing
+ }
+
+ private fun pruneStaleExtremes(axis: AxisState, now: Long) {
+ while (axis.extremes.isNotEmpty() && now - axis.extremes.first().timeMs > GESTURE_WINDOW_MS) {
+ axis.extremes.removeFirst()
+ }
}
private fun calculateVariance(values: List): Double {
if (values.size <= 1) return 0.0
-
val mean = values.average()
- val squaredDiffs = values.map { (it - mean) * (it - mean) }
- return squaredDiffs.average()
+ return values.map { (it - mean) * (it - mean) }.average()
}
+ /** How evenly spaced this axis's own extremes are (1 = metronomic, 0 = erratic). */
+ private fun rhythmConsistency(extremes: List): Double {
+ if (extremes.size < 2) return 0.0
- private fun calculateRhythmConsistency(): Double {
- if (peakIntervals.size < 2) return 0.0
-
- val meanInterval = peakIntervals.average()
- if (meanInterval == 0.0) return 0.0
+ val intervals = (1 until extremes.size).map {
+ (extremes[it].timeMs - extremes[it - 1].timeMs) / 1000.0
+ }
+ val meanInterval = intervals.average()
+ if (meanInterval <= 0.0) return 0.0
- val variances = peakIntervals.map { (it / meanInterval - 1.0).pow(2) }
- val consistency = 1.0 - min(1.0, variances.average() / rhythmConsistencyThreshold)
- return max(0.0, consistency)
+ val variances = intervals.map { (it / meanInterval - 1.0).pow(2) }
+ return max(0.0, 1.0 - min(1.0, variances.average() / RHYTHM_CONSISTENCY_THRESHOLD))
}
+ private fun requiredExtremes(extremes: List): Int {
+ if (extremes.size < 2) return MIN_REQUIRED_EXTREMES
- private fun calculateConfidenceScore(extremes: List>, isVertical: Boolean): Double {
- if (extremes.size < getRequiredExtremes()) return 0.0
+ val avgIntervalMs = (1 until extremes.size)
+ .map { (extremes[it].timeMs - extremes[it - 1].timeMs).toDouble() }
+ .average()
- val sortedExtremes = extremes.sortedBy { it.first }
+ // Fast oscillation produces extremes so easily that we ask for one more of
+ // them before trusting it.
+ return if (avgIntervalMs < FAST_MOVEMENT_THRESHOLD_MS) MAX_REQUIRED_EXTREMES else MIN_REQUIRED_EXTREMES
+ }
+
+ private fun confidenceScore(axis: AxisState, other: AxisState): Double {
+ val all = axis.extremes.toList()
+ val required = requiredExtremes(all)
+ if (all.size < required) return 0.0
- val recent = sortedExtremes.takeLast(getRequiredExtremes())
+ // ArrayDeque preserves insertion order and `order` is monotonic, so this is
+ // genuinely chronological (the old buffer-index scheme saturated once the
+ // buffer filled, which scrambled ordering and randomised the score).
+ val recent = all.takeLast(required)
- val avgAmplitude = recent.map { abs(it.second) }.average()
- val amplitudeFactor = min(1.0, avgAmplitude / 600)
+ val avgAmplitude = recent.map { abs(it.value) }.average()
+ val amplitudeFactor = min(1.0, avgAmplitude / amplitudeTarget)
- val rhythmFactor = calculateRhythmConsistency()
+ val rhythmFactor = rhythmConsistency(recent)
- val signs = recent.map { if (it.second > 0) 1 else -1 }
- val alternating = (1 until signs.size).all { signs[it] != signs[it - 1] }
+ // A genuine nod/shake swings back and forth: maxima and minima alternate.
+ val alternating = (1 until recent.size).all { recent[it].isPeak != recent[it - 1].isPeak }
val alternationFactor = if (alternating) 1.0 else 0.5
- val isolationFactor = if (isVertical) {
- val vertAmplitude = recent.map { abs(it.second) }.average()
- val horizVals = horizontalBuffer.takeLast(recent.size * 2)
- val horizAmplitude = horizVals.map { abs(it) }.average()
- min(1.0, vertAmplitude / (horizAmplitude + 0.1) * 1.2)
- } else {
- val horizAmplitude = recent.map { abs(it.second) }.average()
- val vertVals = verticalBuffer.takeLast(recent.size * 2)
- val vertAmplitude = vertVals.map { abs(it) }.average()
- min(1.0, horizAmplitude / (vertAmplitude + 0.1) * 1.2)
- }
+ // Compare against what the *other* axis has been doing recently — both sides
+ // baseline-centered, so a resting offset can't fake cross-talk.
+ val otherAmplitude = other.recentCentered.toList()
+ .takeLast(required * 4)
+ .map { abs(it) }
+ .ifEmpty { listOf(0.0) }
+ .average()
+ val isolationFactor = min(1.0, avgAmplitude / (otherAmplitude + 0.1) * 1.2)
- return (
- amplitudeFactor * 0.4 +
+ return amplitudeFactor * 0.4 +
rhythmFactor * 0.2 +
alternationFactor * 0.2 +
isolationFactor * 0.2
- )
- }
-
- private fun getRequiredExtremes(): Int {
- if (movementSpeedIntervals.isEmpty()) return MIN_REQUIRED_EXTREMES
-
- val avgInterval = movementSpeedIntervals.average()
- Log.d(TAG, "Average movement interval: $avgInterval ms")
-
- return if (avgInterval < FAST_MOVEMENT_THRESHOLD) {
- MAX_REQUIRED_EXTREMES
- } else {
- MIN_REQUIRED_EXTREMES
- }
}
private fun detectGestures(): Boolean? {
- val requiredExtremes = getRequiredExtremes()
- Log.d(TAG, "Current required extremes: $requiredExtremes")
-
- if (verticalPeaks.size + verticalTroughs.size >= requiredExtremes) {
- val allExtremes = (verticalPeaks + verticalTroughs).sortedBy { it.first }
-
- val confidence = calculateConfidenceScore(allExtremes, isVertical = true)
-
- Log.d(TAG, "Vertical motion confidence: $confidence (need $minConfidenceThreshold)")
-
- if (confidence >= minConfidenceThreshold) {
- Log.d(TAG, "\"Yes\" Gesture Detected (confidence: $confidence, extremes: ${allExtremes.size}/$requiredExtremes)")
- return true
- }
+ val verticalConfidence: Double
+ val horizontalConfidence: Double
+ synchronized(lock) {
+ verticalConfidence = confidenceScore(verticalAxis, horizontalAxis)
+ horizontalConfidence = confidenceScore(horizontalAxis, verticalAxis)
}
- if (horizontalPeaks.size + horizontalTroughs.size >= requiredExtremes) {
- val allExtremes = (horizontalPeaks + horizontalTroughs).sortedBy { it.first }
-
- val confidence = calculateConfidenceScore(allExtremes, isVertical = false)
+ val verticalOk = verticalConfidence >= MIN_CONFIDENCE
+ val horizontalOk = horizontalConfidence >= MIN_CONFIDENCE
- Log.d(TAG, "Horizontal motion confidence: $confidence (need $minConfidenceThreshold)")
+ if (verticalConfidence > 0.0 || horizontalConfidence > 0.0) {
+ Log.d(
+ TAG,
+ "Confidence — vertical(yes)=$verticalConfidence (ok=$verticalOk), horizontal(no)=$horizontalConfidence (ok=$horizontalOk), need $MIN_CONFIDENCE"
+ )
+ }
- if (confidence >= minConfidenceThreshold) {
- Log.d(TAG, "\"No\" Gesture Detected (confidence: $confidence, extremes: ${allExtremes.size}/$requiredExtremes)")
- return false
+ return when {
+ verticalOk && horizontalOk -> {
+ // Both cleared — pick the stronger axis (ties fall to "yes", which is rare and fine).
+ val yes = verticalConfidence >= horizontalConfidence
+ Log.d(TAG, "Both axes cleared → ${if (yes) "\"Yes\"" else "\"No\""} (v=$verticalConfidence, h=$horizontalConfidence)")
+ yes
+ }
+ verticalOk -> {
+ Log.d(TAG, "\"Yes\" Gesture Detected (confidence=$verticalConfidence)")
+ true
}
+ horizontalOk -> {
+ Log.d(TAG, "\"No\" Gesture Detected (confidence=$horizontalConfidence)")
+ false
+ }
+ else -> null
}
-
- return null
}
private fun clearData() {
- horizontalBuffer.clear()
- verticalBuffer.clear()
- horizontalPeaks.clear()
- horizontalTroughs.clear()
- verticalPeaks.clear()
- verticalTroughs.clear()
- peakIntervals.clear()
- movementSpeedIntervals.clear()
- horizontalIncreasing = null
- verticalIncreasing = null
- lastPeakTime = 0
- significantMotion = false
- lastSignificantMotionTime = 0L
+ synchronized(lock) {
+ verticalAxis.reset()
+ horizontalAxis.reset()
+ }
+ hasRawSample = false
+ prevRawHorizontal = 0.0
+ prevRawVertical = 0.0
}
-
- private fun Double.pow(exponent: Int): Double = this.pow(exponent.toDouble())
}
diff --git a/android/app/src/main/res/drawable/progress_bar_background.xml b/android/app/src/main/res/drawable/progress_bar_background.xml
index bcf1c41f6..5fdf3e5e8 100644
--- a/android/app/src/main/res/drawable/progress_bar_background.xml
+++ b/android/app/src/main/res/drawable/progress_bar_background.xml
@@ -8,4 +8,4 @@
-
\ No newline at end of file
+
diff --git a/android/app/src/main/res/drawable/widget_background.xml b/android/app/src/main/res/drawable/widget_background.xml
index b9414e08a..613a03511 100644
--- a/android/app/src/main/res/drawable/widget_background.xml
+++ b/android/app/src/main/res/drawable/widget_background.xml
@@ -2,7 +2,7 @@
-
-
+
diff --git a/android/app/src/main/res/drawable/widget_button_checked_shape_end.xml b/android/app/src/main/res/drawable/widget_button_checked_shape_end.xml
index 89e4b9b72..1b11a0cec 100644
--- a/android/app/src/main/res/drawable/widget_button_checked_shape_end.xml
+++ b/android/app/src/main/res/drawable/widget_button_checked_shape_end.xml
@@ -1,8 +1,15 @@
+ -
+
+
+
+
+
+
-
-
-
+
+
diff --git a/android/app/src/main/res/drawable/widget_button_checked_shape_middle.xml b/android/app/src/main/res/drawable/widget_button_checked_shape_middle.xml
index 9e108c3eb..28f709fe7 100644
--- a/android/app/src/main/res/drawable/widget_button_checked_shape_middle.xml
+++ b/android/app/src/main/res/drawable/widget_button_checked_shape_middle.xml
@@ -1,8 +1,15 @@
+ -
+
+
+
+
+
+
-
-
-
+
+
diff --git a/android/app/src/main/res/drawable/widget_button_checked_shape_start.xml b/android/app/src/main/res/drawable/widget_button_checked_shape_start.xml
index efc3f2dec..1aa700dfa 100644
--- a/android/app/src/main/res/drawable/widget_button_checked_shape_start.xml
+++ b/android/app/src/main/res/drawable/widget_button_checked_shape_start.xml
@@ -1,8 +1,15 @@
+ -
+
+
+
+
+
+
-
-
-
+
+
diff --git a/android/app/src/main/res/drawable/widget_button_shape_end.xml b/android/app/src/main/res/drawable/widget_button_shape_end.xml
index 83818448b..659ed8bdd 100644
--- a/android/app/src/main/res/drawable/widget_button_shape_end.xml
+++ b/android/app/src/main/res/drawable/widget_button_shape_end.xml
@@ -1,15 +1,15 @@
-
-
-
+
+
-
-
-
+
+
diff --git a/android/app/src/main/res/drawable/widget_button_shape_middle.xml b/android/app/src/main/res/drawable/widget_button_shape_middle.xml
index f81adc42d..a2996276b 100644
--- a/android/app/src/main/res/drawable/widget_button_shape_middle.xml
+++ b/android/app/src/main/res/drawable/widget_button_shape_middle.xml
@@ -1,15 +1,15 @@
-
-
-
+
+
-
-
-
+
+
diff --git a/android/app/src/main/res/drawable/widget_button_shape_start.xml b/android/app/src/main/res/drawable/widget_button_shape_start.xml
index f3958bafd..d95db60b5 100644
--- a/android/app/src/main/res/drawable/widget_button_shape_start.xml
+++ b/android/app/src/main/res/drawable/widget_button_shape_start.xml
@@ -1,15 +1,15 @@
-
-
-
+
+
-
-
-
+
+
diff --git a/android/app/src/main/res/font/montserrat.ttf b/android/app/src/main/res/font/montserrat.ttf
new file mode 100644
index 000000000..92b1284e8
Binary files /dev/null and b/android/app/src/main/res/font/montserrat.ttf differ
diff --git a/android/app/src/main/res/font/roboto_flex.ttf b/android/app/src/main/res/font/roboto_flex.ttf
new file mode 100644
index 000000000..0abe2ee29
Binary files /dev/null and b/android/app/src/main/res/font/roboto_flex.ttf differ
diff --git a/android/app/src/main/res/layout/battery_widget.xml b/android/app/src/main/res/layout/battery_widget.xml
index df54f5d45..6f5090e71 100644
--- a/android/app/src/main/res/layout/battery_widget.xml
+++ b/android/app/src/main/res/layout/battery_widget.xml
@@ -15,6 +15,7 @@
android:baselineAligned="false"
android:orientation="horizontal"
android:layout_margin="0dp"
+ android:padding="8dp"
android:id="@android:id/background"
android:layout_gravity="center">
@@ -57,13 +58,13 @@
android:importantForAccessibility="no"
android:src="@drawable/ic_power"
android:visibility="gone"
- android:tint="@color/white"
+ android:tint="@color/widget_on_surface"
tools:ignore="HardcodedText" />
@@ -73,7 +74,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="24sp"
- android:textColor="@color/white"
+ android:textColor="@color/widget_on_surface_variant"
android:fontFamily="@font/sf_pro"
android:gravity="center"
android:textFontWeight="300"
@@ -119,13 +120,13 @@
android:importantForAccessibility="no"
android:src="@drawable/ic_power"
android:visibility="gone"
- android:tint="@color/white"
+ android:tint="@color/widget_on_surface"
tools:ignore="HardcodedText" />
@@ -135,7 +136,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="24sp"
- android:textColor="@color/white"
+ android:textColor="@color/widget_on_surface_variant"
android:fontFamily="@font/sf_pro"
android:gravity="center"
android:textFontWeight="300"
@@ -180,14 +181,14 @@
android:importantForAccessibility="no"
android:src="@drawable/ic_power"
android:visibility="gone"
- android:tint="@color/white"
+ android:tint="@color/widget_on_surface"
tools:ignore="HardcodedText" />
@@ -197,7 +198,7 @@
android:layout_height="wrap_content"
android:textSize="24sp"
android:textFontWeight="300"
- android:textColor="@color/white"
+ android:textColor="@color/widget_on_surface_variant"
android:fontFamily="@font/sf_pro"
android:gravity="center"
android:text="Right"
@@ -241,14 +242,14 @@
android:layout_gravity="top|center_horizontal"
android:src="@drawable/ic_power"
android:visibility="gone"
- android:tint="@color/white"
+ android:tint="@color/widget_on_surface"
tools:ignore="HardcodedText" />
@@ -257,7 +258,7 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="24sp"
- android:textColor="@color/white"
+ android:textColor="@color/widget_on_surface_variant"
android:gravity="center"
android:fontFamily="@font/sf_pro"
android:textFontWeight="300"
@@ -265,4 +266,4 @@
tools:ignore="HardcodedText" />
-
\ No newline at end of file
+
diff --git a/android/app/src/main/res/layout/island_window.xml b/android/app/src/main/res/layout/island_window.xml
deleted file mode 100644
index c804737b5..000000000
--- a/android/app/src/main/res/layout/island_window.xml
+++ /dev/null
@@ -1,124 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/android/app/src/main/res/layout/noise_control_widget.xml b/android/app/src/main/res/layout/noise_control_widget.xml
index baa2f4bdd..d50454287 100644
--- a/android/app/src/main/res/layout/noise_control_widget.xml
+++ b/android/app/src/main/res/layout/noise_control_widget.xml
@@ -11,6 +11,7 @@
android:id="@android:id/background"
android:layout_width="match_parent"
android:layout_height="match_parent"
+ android:background="@drawable/widget_background"
android:baselineAligned="false"
android:orientation="horizontal">
@@ -18,8 +19,8 @@
android:id="@+id/widget_off_button"
android:layout_width="0dp"
android:layout_height="match_parent"
- android:layout_marginVertical="12dp"
- android:layout_marginStart="12dp"
+ android:layout_marginVertical="14dp"
+ android:layout_marginStart="14dp"
android:layout_marginEnd="2dp"
android:layout_weight="1"
android:background="@drawable/widget_button_shape_start"
@@ -28,28 +29,27 @@
android:orientation="vertical">
+ android:tint="@color/widget_on_surface" />
+ android:textColor="@color/widget_on_surface"
+ android:textSize="13sp"
+ android:textFontWeight="500" />
+ android:tint="@color/widget_on_surface" />
@@ -80,7 +79,7 @@
android:id="@+id/widget_adaptive_button"
android:layout_width="0dp"
android:layout_height="match_parent"
- android:layout_marginVertical="12dp"
+ android:layout_marginVertical="14dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
android:layout_weight="1"
@@ -90,22 +89,21 @@
android:orientation="vertical">
+ android:textSize="13sp"
+ android:tint="@color/widget_on_surface" />
@@ -113,9 +111,9 @@
android:id="@+id/widget_anc_button"
android:layout_width="0dp"
android:layout_height="match_parent"
- android:layout_marginVertical="12dp"
+ android:layout_marginVertical="14dp"
android:layout_marginStart="2dp"
- android:layout_marginEnd="12dp"
+ android:layout_marginEnd="14dp"
android:layout_weight="1"
android:background="@drawable/widget_button_shape_end"
android:clickable="true"
@@ -123,21 +121,20 @@
android:orientation="vertical">
+ android:tint="@color/widget_on_surface" />
diff --git a/android/app/src/main/res/layout/popup_window.xml b/android/app/src/main/res/layout/popup_window.xml
deleted file mode 100644
index f00068db0..000000000
--- a/android/app/src/main/res/layout/popup_window.xml
+++ /dev/null
@@ -1,109 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml
index 952e4c9a0..0b9f65950 100644
--- a/android/app/src/main/res/values-night/colors.xml
+++ b/android/app/src/main/res/values-night/colors.xml
@@ -5,4 +5,19 @@
#1C1B1E
@color/white
#0B0B0B
+
+
+
+ @android:color/system_neutral1_900
+
+ @android:color/system_neutral2_800
+ @android:color/system_neutral2_700
+
+ @android:color/system_accent1_700
+ @android:color/system_accent1_600
+
+ @android:color/system_neutral1_50
+ @android:color/system_neutral2_200
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
index 5f4931dc0..b287b2098 100644
--- a/android/app/src/main/res/values/colors.xml
+++ b/android/app/src/main/res/values/colors.xml
@@ -11,4 +11,23 @@
#FF039BE5
#FF01579B
#FFFFFF
+
+
+
+ @android:color/system_neutral1_50
+
+ @android:color/system_neutral2_100
+ @android:color/system_neutral2_200
+
+ @android:color/system_accent1_100
+ @android:color/system_accent1_200
+
+ @android:color/system_neutral1_900
+ @android:color/system_neutral2_700
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
index a45307354..dc9b2b3ef 100644
--- a/android/gradle/libs.versions.toml
+++ b/android/gradle/libs.versions.toml
@@ -7,6 +7,8 @@ lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.13.0"
composeBom = "2026.05.00"
annotations = "26.1.0"
+material3Expressive = "1.5.0-alpha21"
+graphicsShapes = "1.0.1"
navigationCompose = "2.9.8"
constraintlayout = "2.2.1"
haze = "1.7.2"
@@ -36,7 +38,7 @@ androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", versi
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
-androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3Expressive" }
annotations = { group = "org.jetbrains", name = "annotations", version.ref = "annotations" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
@@ -49,6 +51,8 @@ androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
aboutlibraries = { group = "com.mikepenz", name = "aboutlibraries", version.ref = "aboutLibraries" }
aboutlibraries-compose-m3 = { group = "com.mikepenz", name = "aboutlibraries-compose-m3", version.ref = "aboutLibraries" }
androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core", version.ref = "materialIconsCore" }
+androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended", version.ref = "materialIconsCore" }
+androidx-graphics-shapes = { group = "androidx.graphics", name = "graphics-shapes", version.ref = "graphicsShapes" }
backdrop = { group = "io.github.kyant0", name = "backdrop", version.ref = "backdrop" }
hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
diff --git a/android/wear/build.gradle.kts b/android/wear/build.gradle.kts
index 95f4bf6a3..35db43901 100644
--- a/android/wear/build.gradle.kts
+++ b/android/wear/build.gradle.kts
@@ -40,7 +40,7 @@ android {
// same application id and signing key on both devices.
applicationId = "me.kavishdevar.librepods"
minSdk = 33
- targetSdk = 36
+ targetSdk = 37
versionCode = 55
versionName = appVersionName
}
@@ -76,6 +76,9 @@ android {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
+ // Wear OS 7 Wear Widgets compile against the wear-sdk system library.
+ useLibrary("wear-sdk")
+
buildFeatures {
compose = true
}
@@ -104,4 +107,21 @@ dependencies {
implementation(libs.kotlinx.coroutines.play.services)
implementation(libs.kotlinx.coroutines.guava)
debugImplementation(libs.androidx.compose.ui.tooling)
+
+ // --- Wear OS 7 "Wear Widgets" (ALPHA, Wear 7+ only) -----------------------
+ // Jetpack Glance for Wear + RemoteCompose. Versions follow the CURRENT
+ // get-started docs (glance.wear alpha13 / compose.remote alpha14 /
+ // remote-material3 alpha07 — the July 2026 trainset; these three MUST move
+ // together, remote-material3 is compiled against compose.remote at the same
+ // commit). Alpha APIs churn — widget code is isolated under wear/.../widget
+ // so the blast radius stays small. On Wear <=6 the widget service is simply
+ // never bound. The watch additionally needs the protolayout renderer APK
+ // >= 1.6.1 (Play-updated) or widget requests hang at "Starting…".
+ implementation("androidx.glance.wear:wear:1.0.0-alpha13")
+ implementation("androidx.glance.wear:wear-core:1.0.0-alpha13")
+ implementation("androidx.compose.remote:remote-creation-compose:1.0.0-alpha14")
+ implementation("androidx.compose.remote:remote-creation:1.0.0-alpha14")
+ implementation("androidx.compose.remote:remote-creation-core:1.0.0-alpha14")
+ implementation("androidx.compose.remote:remote-core:1.0.0-alpha14")
+ implementation("androidx.wear.compose.remote:remote-material3:1.0.0-alpha07")
}
diff --git a/android/wear/src/main/AndroidManifest.xml b/android/wear/src/main/AndroidManifest.xml
index fcb60ea1d..8031eb252 100644
--- a/android/wear/src/main/AndroidManifest.xml
+++ b/android/wear/src/main/AndroidManifest.xml
@@ -14,6 +14,12 @@
android:name="com.google.android.wearable"
android:required="false" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/wear/src/main/java/me/kavishdevar/librepods/wear/WatchDataListenerService.kt b/android/wear/src/main/java/me/kavishdevar/librepods/wear/WatchDataListenerService.kt
index f979cc252..fce60ec42 100644
--- a/android/wear/src/main/java/me/kavishdevar/librepods/wear/WatchDataListenerService.kt
+++ b/android/wear/src/main/java/me/kavishdevar/librepods/wear/WatchDataListenerService.kt
@@ -16,24 +16,58 @@
along with this program. If not, see .
*/
+@file:SuppressLint("RestrictedApi")
+
package me.kavishdevar.librepods.wear
+import android.annotation.SuppressLint
+import android.util.Log
+import androidx.wear.tiles.TileService
import com.google.android.gms.wearable.MessageEvent
import com.google.android.gms.wearable.WearableListenerService
-import androidx.wear.tiles.TileService
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
import me.kavishdevar.librepods.wear.tile.LibrePodsTileService
+import me.kavishdevar.librepods.wear.widget.triggerLibrePodsWidgetUpdate
/**
* Receives state pushes from the phone even when the watch app isn't running,
- * caches them, and refreshes the tile when something actually changed.
- * The dedupe check also breaks the tile-refresh -> request-state -> push cycle.
+ * caches them, and refreshes both glanceable surfaces (tile + Wear OS 7 widget)
+ * when something actually changed. The dedupe check also breaks the
+ * refresh -> request-state -> push cycle.
*/
class WatchDataListenerService : WearableListenerService() {
+ private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+
override fun onMessageReceived(event: MessageEvent) {
if (event.path != WearProtocol.PATH_STATE) return
val json = String(event.data, Charsets.UTF_8)
if (WatchStateCache.write(this, json)) {
TileService.getUpdater(this).requestUpdate(LibrePodsTileService::class.java)
+ refreshWidget()
}
}
+
+ /**
+ * Push the new state into any active Wear OS 7 widget instances. Guarded:
+ * on Wear OS <=6 the wear-sdk widget runtime is absent, so this throws and we
+ * simply no-op (the tile already covers those devices).
+ */
+ private fun refreshWidget() {
+ scope.launch {
+ try {
+ triggerLibrePodsWidgetUpdate(this@WatchDataListenerService)
+ } catch (t: Throwable) {
+ Log.d("WatchDataListener", "Widget refresh skipped (no Wear 7 widget host): ${t.message}")
+ }
+ }
+ }
+
+ override fun onDestroy() {
+ scope.cancel()
+ super.onDestroy()
+ }
}
diff --git a/android/wear/src/main/java/me/kavishdevar/librepods/wear/widget/LibrePodsWidget.kt b/android/wear/src/main/java/me/kavishdevar/librepods/wear/widget/LibrePodsWidget.kt
new file mode 100644
index 000000000..65753fcb1
--- /dev/null
+++ b/android/wear/src/main/java/me/kavishdevar/librepods/wear/widget/LibrePodsWidget.kt
@@ -0,0 +1,290 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+// Wear OS 7 "Wear Widget" centerpiece — the noise-control card.
+//
+// Visual parity with the tile (Phase 3.5): RemoteMaterialTheme + RemoteIconButton
+// grid with the real mode glyphs, active mode filled with the theme primary,
+// mode name + battery line below (battery is the widget's edge over the tile).
+// The flat literal-colour rendering from the bring-up phase is gone — themed
+// rendering was only deferred until basic rendering was confirmed on hardware,
+// which it now is (PW4, renderer >= 1.6.1).
+//
+// Interaction: taps fire a PendingIntent into WidgetActionReceiver, which relays
+// the mode to the phone exactly like the tile does (Wear Widgets are client-side
+// documents — there is no service callback on tap).
+//
+// Glyph tinting: the mode icons are PNGs and RemoteImage has no tint parameter,
+// so glyphs are tinted at document-creation time (we run in-process) via
+// Drawable.setTint onto a canvas. Container colours stay theme-dynamic through
+// RemoteIconButtonColors; glyphs use concrete black/white, which is safe because
+// Wear M3 dark schemes always pair a light primary with dark onPrimary.
+
+@file:OptIn(ExperimentalRemoteCreationComposeApi::class)
+@file:SuppressLint("RestrictedApi")
+
+package me.kavishdevar.librepods.wear.widget
+
+import android.annotation.SuppressLint
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.graphics.Canvas
+import android.util.Log
+import androidx.compose.remote.creation.compose.ExperimentalRemoteCreationComposeApi
+import androidx.compose.remote.creation.compose.action.pendingIntentAction
+import androidx.compose.remote.creation.compose.layout.RemoteAlignment
+import androidx.compose.remote.creation.compose.layout.RemoteBox
+import androidx.compose.remote.creation.compose.layout.RemoteColumn
+import androidx.compose.remote.creation.compose.layout.RemoteComposable
+import androidx.compose.remote.creation.compose.layout.RemoteImage
+import androidx.compose.remote.creation.compose.layout.RemoteRow
+import androidx.compose.remote.creation.compose.modifier.RemoteModifier
+import androidx.compose.remote.creation.compose.modifier.fillMaxSize
+import androidx.compose.remote.creation.compose.modifier.fillMaxWidth
+import androidx.compose.remote.creation.compose.modifier.padding
+import androidx.compose.remote.creation.compose.modifier.size
+import androidx.compose.remote.creation.compose.state.rc
+import androidx.compose.remote.creation.compose.state.rdp
+import androidx.compose.remote.creation.compose.state.rs
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.core.content.ContextCompat
+import androidx.core.graphics.createBitmap
+import androidx.glance.wear.AssociateWithGlanceWearWidget
+import androidx.glance.wear.GlanceWearWidget
+import androidx.glance.wear.GlanceWearWidgetManager
+import androidx.glance.wear.GlanceWearWidgetService
+import androidx.glance.wear.WearWidgetBrush
+import androidx.glance.wear.WearWidgetData
+import androidx.glance.wear.WearWidgetDocument
+import androidx.glance.wear.color
+import androidx.glance.wear.core.WearWidgetParams
+import androidx.wear.compose.remote.material3.RemoteIconButton
+import androidx.wear.compose.remote.material3.RemoteIconButtonDefaults
+import androidx.wear.compose.remote.material3.RemoteMaterialTheme
+import androidx.wear.compose.remote.material3.RemoteText
+import me.kavishdevar.librepods.R
+import me.kavishdevar.librepods.wear.WatchBattery
+import me.kavishdevar.librepods.wear.WatchBatteryComponent
+import me.kavishdevar.librepods.wear.WatchNoiseMode
+import me.kavishdevar.librepods.wear.WatchStateCache
+import kotlin.math.roundToInt
+
+private const val TAG = "LibrePodsWidget"
+
+/** One tap target in the mode grid, with its glyph pre-tinted for its state. */
+private data class WidgetModeButton(
+ val value: Int,
+ val contentDescription: String,
+ val selected: Boolean,
+ val icon: ImageBitmap,
+)
+
+/**
+ * Refreshes every active instance of the LibrePods widget. Callers must guard
+ * with runCatching: on Wear <=6 the wear-sdk widget runtime is absent and the
+ * manager constructor throws (the tile already covers those devices).
+ */
+suspend fun triggerLibrePodsWidgetUpdate(context: Context) {
+ val appContext = context.applicationContext
+ val manager = GlanceWearWidgetManager(appContext)
+ val widget = LibrePodsWidget()
+ manager.fetchActiveWidgets(widget::class).forEach { handle ->
+ widget.triggerUpdate(appContext, handle.instanceId)
+ }
+}
+
+/** System entry point — the service the Wear 7 widget host binds to. */
+@AssociateWithGlanceWearWidget(LibrePodsWidget::class)
+class LibrePodsWidgetService : GlanceWearWidgetService() {
+ override val widget: GlanceWearWidget = LibrePodsWidget()
+}
+
+class LibrePodsWidget : GlanceWearWidget() {
+ override suspend fun provideWidgetData(
+ context: Context,
+ params: WearWidgetParams,
+ ): WearWidgetData {
+ return try {
+ // Synchronous SharedPreferences read — same cache the tile renders from.
+ val state = WatchStateCache.read(context)?.takeIf { it.airPodsConnected }
+ Log.d(TAG, "provideWidgetData (connected=${state != null}, mode=${state?.mode})")
+ // Black base like every tile; the themed content sits on top.
+ val background = WearWidgetBrush.color(Color.Black.rc)
+ WearWidgetDocument(background = background) {
+ RemoteMaterialTheme {
+ if (state != null) {
+ val buttons = WatchNoiseMode.entries
+ .filter { it != WatchNoiseMode.OFF || state.offAllowed }
+ .map { mode ->
+ val selected = mode.value == state.mode
+ WidgetModeButton(
+ value = mode.value,
+ contentDescription = context.getString(mode.labelRes),
+ selected = selected,
+ icon = tintedGlyph(context, mode.iconRes, selected),
+ )
+ }
+ LibrePodsWidgetContent(
+ title = context.getString(R.string.noise_control),
+ modeName = modeLabel(context, state.mode),
+ batteryLine = batteryLine(state.batteries),
+ buttons = buttons,
+ )
+ } else {
+ LibrePodsWidgetSimple(context.getString(R.string.tile_not_connected))
+ }
+ }
+ }.also { Log.d(TAG, "widget document built ok") }
+ } catch (t: Throwable) {
+ // Never leave the widget-request future hanging — return a minimal valid document.
+ Log.e(TAG, "widget build failed, returning fallback", t)
+ WearWidgetDocument(background = WearWidgetBrush.color(Color.Black.rc)) {
+ RemoteMaterialTheme { LibrePodsWidgetSimple("LibrePods") }
+ }
+ }
+ }
+}
+
+private fun modeLabel(context: Context, mode: Int): String =
+ WatchNoiseMode.fromValue(mode)?.let { context.getString(it.labelRes) }
+ ?: context.getString(R.string.noise_control)
+
+/**
+ * Rasterizes a mode glyph tinted for its button state. Wear M3 dark schemes pair
+ * a light primary container with dark content, so black-on-selected /
+ * white-on-tonal is always legible even though the container colours are
+ * theme-dynamic at render time.
+ */
+private fun tintedGlyph(context: Context, resId: Int, selected: Boolean): ImageBitmap {
+ val sizePx = (GLYPH_SIZE_DP * context.resources.displayMetrics.density).roundToInt()
+ val drawable = requireNotNull(ContextCompat.getDrawable(context, resId)).mutate()
+ drawable.setTint(if (selected) android.graphics.Color.BLACK else android.graphics.Color.WHITE)
+ val bitmap = createBitmap(sizePx, sizePx)
+ drawable.setBounds(0, 0, sizePx, sizePx)
+ drawable.draw(Canvas(bitmap))
+ return bitmap.asImageBitmap()
+}
+
+private const val GLYPH_SIZE_DP = 26
+
+/** Builds a compact "L 80% R 75% C 60%" line, skipping components with no valid reading. */
+private fun batteryLine(batteries: List): String {
+ fun levelFor(component: Int): Int? =
+ batteries.firstOrNull { it.component == component && it.level in 0..100 }?.level
+
+ val parts = buildList {
+ levelFor(WatchBatteryComponent.LEFT)?.let { add("L $it%") }
+ levelFor(WatchBatteryComponent.RIGHT)?.let { add("R $it%") }
+ levelFor(WatchBatteryComponent.CASE)?.let { add("C $it%") }
+ }
+ return if (parts.isEmpty()) "—" else parts.joinToString(" ")
+}
+
+@RemoteComposable
+@Composable
+private fun LibrePodsWidgetContent(
+ title: String,
+ modeName: String,
+ batteryLine: String,
+ buttons: List,
+) {
+ val scheme = RemoteMaterialTheme.colorScheme
+ RemoteBox(modifier = RemoteModifier.fillMaxSize(), contentAlignment = RemoteAlignment.Center) {
+ RemoteColumn(
+ modifier = RemoteModifier.fillMaxWidth(),
+ horizontalAlignment = RemoteAlignment.CenterHorizontally,
+ ) {
+ RemoteText(
+ text = title.rs,
+ color = scheme.onSurfaceVariant,
+ style = RemoteMaterialTheme.typography.labelMedium,
+ modifier = RemoteModifier.padding(bottom = 8.rdp),
+ )
+ // Same shape as the tile: 2x2 grid with Off, single row of 3 without.
+ if (buttons.size == 4) {
+ ModeButtonRow(buttons.subList(0, 2))
+ ModeButtonRow(buttons.subList(2, 4), topPadding = 6)
+ } else {
+ ModeButtonRow(buttons)
+ }
+ RemoteText(
+ text = modeName.rs,
+ color = scheme.primary,
+ style = RemoteMaterialTheme.typography.titleSmall,
+ modifier = RemoteModifier.padding(top = 10.rdp, bottom = 2.rdp),
+ )
+ RemoteText(
+ text = batteryLine.rs,
+ color = scheme.onSurfaceVariant,
+ style = RemoteMaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+}
+
+@RemoteComposable
+@Composable
+private fun ModeButtonRow(buttons: List, topPadding: Int = 0) {
+ val scheme = RemoteMaterialTheme.colorScheme
+ RemoteRow(modifier = RemoteModifier.padding(top = topPadding.rdp)) {
+ buttons.forEach { button ->
+ val sendMode = pendingIntentAction { ctx ->
+ PendingIntent.getBroadcast(
+ ctx,
+ button.value, // distinct requestCode per mode so the intents don't collapse
+ Intent(ctx, WidgetActionReceiver::class.java)
+ .setAction(WidgetActionReceiver.ACTION_SET_MODE)
+ .putExtra(WidgetActionReceiver.EXTRA_MODE, button.value),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+ val colors = if (button.selected) {
+ RemoteIconButtonDefaults.iconButtonColors(scheme.primary, scheme.onPrimary)
+ } else {
+ RemoteIconButtonDefaults.iconButtonColors()
+ }
+ RemoteIconButton(
+ sendMode,
+ RemoteModifier.padding(horizontal = 3.rdp),
+ colors,
+ ) {
+ RemoteImage(
+ button.icon,
+ button.contentDescription.rs,
+ RemoteModifier.size(GLYPH_SIZE_DP.rdp),
+ )
+ }
+ }
+ }
+}
+
+@RemoteComposable
+@Composable
+private fun LibrePodsWidgetSimple(text: String) {
+ RemoteBox(modifier = RemoteModifier.fillMaxSize(), contentAlignment = RemoteAlignment.Center) {
+ RemoteText(
+ text = text.rs,
+ color = RemoteMaterialTheme.colorScheme.onSurface,
+ style = RemoteMaterialTheme.typography.titleSmall,
+ )
+ }
+}
diff --git a/android/wear/src/main/java/me/kavishdevar/librepods/wear/widget/WidgetActionReceiver.kt b/android/wear/src/main/java/me/kavishdevar/librepods/wear/widget/WidgetActionReceiver.kt
new file mode 100644
index 000000000..f5508860d
--- /dev/null
+++ b/android/wear/src/main/java/me/kavishdevar/librepods/wear/widget/WidgetActionReceiver.kt
@@ -0,0 +1,74 @@
+/*
+ LibrePods - AirPods liberated from Apple’s ecosystem
+ Copyright (C) 2025 LibrePods contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+*/
+
+package me.kavishdevar.librepods.wear.widget
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+import androidx.wear.tiles.TileService
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import me.kavishdevar.librepods.wear.PhoneMessenger
+import me.kavishdevar.librepods.wear.WatchStateCache
+import me.kavishdevar.librepods.wear.WearProtocol
+import me.kavishdevar.librepods.wear.tile.LibrePodsTileService
+
+/**
+ * Handles taps on the Wear Widget's mode buttons. Wear Widgets are client-side
+ * documents — a tap fires the [android.app.PendingIntent] baked into the button,
+ * which lands here. Mirrors the tile's flow: patch the cache optimistically,
+ * redraw both glanceable surfaces, and relay the mode to the phone; the
+ * confirmed state push corrects the surfaces if delivery fails.
+ */
+class WidgetActionReceiver : BroadcastReceiver() {
+
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action != ACTION_SET_MODE) return
+ val mode = intent.getIntExtra(EXTRA_MODE, -1)
+ if (mode !in 1..4) return
+ Log.d(TAG, "Widget tap: mode=$mode")
+
+ WatchStateCache.writeMode(context, mode)
+ val pendingResult = goAsync()
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ // Optimistic redraw first — the phone round trip can take a moment.
+ runCatching {
+ TileService.getUpdater(context).requestUpdate(LibrePodsTileService::class.java)
+ }
+ runCatching { triggerLibrePodsWidgetUpdate(context) }
+ PhoneMessenger.send(
+ context,
+ WearProtocol.PATH_SET_ANC_MODE,
+ byteArrayOf(mode.toByte()),
+ )
+ } finally {
+ pendingResult.finish()
+ }
+ }
+ }
+
+ companion object {
+ private const val TAG = "WidgetActionReceiver"
+ const val ACTION_SET_MODE = "me.kavishdevar.librepods.wear.widget.SET_MODE"
+ const val EXTRA_MODE = "mode"
+ }
+}
diff --git a/android/wear/src/main/res/values/strings.xml b/android/wear/src/main/res/values/strings.xml
index 5182ff6d3..7b1bbc9ba 100644
--- a/android/wear/src/main/res/values/strings.xml
+++ b/android/wear/src/main/res/values/strings.xml
@@ -19,4 +19,6 @@
Case
AirPods not connected
Open
+ Glanceable noise-control mode and AirPods battery
+ LibrePods Widget
diff --git a/android/wear/src/main/res/xml/librepods_anc_widget_info.xml b/android/wear/src/main/res/xml/librepods_anc_widget_info.xml
new file mode 100644
index 000000000..08fb1fb7a
--- /dev/null
+++ b/android/wear/src/main/res/xml/librepods_anc_widget_info.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+