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 @@ + + + + + +