diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 40385faa..c6f3b729 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,6 +1,6 @@ import java.util.Properties -val appVersionName = "0.3.0" +val appVersionName = "1.0.0-rc1" plugins { alias(libs.plugins.android.application) @@ -24,6 +24,14 @@ val releaseSigningAvailable = listOf( "RELEASE_KEY_PASSWORD" ).all { props[it]?.toString()?.isNotBlank() == true } +kotlin { + compilerOptions { + optIn.add( + "androidx.compose.material3.ExperimentalMaterial3ExpressiveApi" + ) + } +} + android { signingConfigs { if (releaseSigningAvailable) { @@ -41,7 +49,7 @@ android { defaultConfig { applicationId = "me.kavishdevar.librepods" targetSdk = 37 - versionCode = 56 + versionCode = 61 versionName = appVersionName } buildTypes { @@ -117,6 +125,7 @@ android { dependencies { implementation(platform(libs.androidx.compose.bom)) implementation(libs.accompanist.permissions) + implementation(libs.androidx.compose.ui.text.google.fonts) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.lifecycle.runtime.ktx) @@ -145,6 +154,10 @@ dependencies { implementation(libs.libxposed.service) implementation(libs.play.review) implementation(libs.play.review.ktx) + implementation(libs.androidx.navigation3.ui) + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) + implementation(libs.androidx.navigationevent) } aboutLibraries { 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 f9924eae..bcb8459a 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/MainActivity.kt @@ -31,125 +31,32 @@ import android.content.Context import android.content.Context.MODE_PRIVATE import android.content.Intent import android.content.ServiceConnection -import android.os.Build +import android.content.SharedPreferences import android.os.Bundle import android.os.IBinder -import android.provider.Settings import android.util.Log import androidx.activity.ComponentActivity 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.animateFloat -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -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.Canvas -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.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.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Notifications -import androidx.compose.material.icons.filled.Phone -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.scale -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.rotate -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalWindowInfo -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -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 androidx.core.content.edit -import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController import com.google.accompanist.permissions.ExperimentalPermissionsApi -import com.google.accompanist.permissions.MultiplePermissionsState -import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.android.play.core.review.ReviewManagerFactory -import com.kyant.backdrop.backdrops.layerBackdrop -import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import dev.chrisbanes.haze.rememberHazeState -import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager import me.kavishdevar.librepods.data.AirPodsNotifications import me.kavishdevar.librepods.data.ControlCommandRepository -import me.kavishdevar.librepods.presentation.components.AppInfoCard -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 -import me.kavishdevar.librepods.presentation.screens.EqualizerScreen -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.PurchaseScreen -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.navigation.NavigationRoot import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel -import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel -import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel import me.kavishdevar.librepods.services.AirPodsService import me.kavishdevar.librepods.utils.XposedState -import me.kavishdevar.librepods.utils.isSupported import kotlin.io.encoding.ExperimentalEncodingApi lateinit var serviceConnection: ServiceConnection @@ -173,7 +80,29 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { - LibrePodsTheme { + val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE) + val m3eEnabled = remember { mutableStateOf(sharedPreferences.getBoolean("m3e_enabled", true)) } + + val sharedPreferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key -> + when (key) { + "m3e_enabled" -> m3eEnabled.value = sharedPreferences.getBoolean(key, true) + } + } + + DisposableEffect(Unit) { + sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPreferenceChangeListener) + onDispose { + sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPreferenceChangeListener) + } + } + LibrePodsTheme( + m3eEnabled = m3eEnabled.value + ) { +// For demo screenshots +// val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView) +// windowInsetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE +// windowInsetsController.hide(WindowInsetsCompat.Type.statusBars()) + Main() } } @@ -220,323 +149,64 @@ class MainActivity : ComponentActivity() { 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)), - contentAlignment = Alignment.Center - ) { - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .verticalScroll(scrollState), - 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() - ) - - 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)) - 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 - ), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 16.dp) - ) - DeviceInfoCard() - AppInfoCard() - } - Spacer(modifier = Modifier.height(48.dp)) - } - } - return - } - - 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", - "android.permission.BLUETOOTH_SCAN", - "android.permission.BLUETOOTH", - "android.permission.BLUETOOTH_ADMIN", - "android.permission.BLUETOOTH_ADVERTISE" - ) - } else { - listOf( - "android.permission.BLUETOOTH", - "android.permission.BLUETOOTH_ADMIN", - "android.permission.ACCESS_FINE_LOCATION" - ) - } - val otherPermissions = listOf( - "android.permission.POST_NOTIFICATIONS", - "android.permission.READ_PHONE_STATE", - "android.permission.ANSWER_PHONE_CALLS" - ) - val allPermissions = bluetoothPermissions + otherPermissions - - val permissionState = rememberMultiplePermissionsState( - permissions = allPermissions - ) val airPodsService = remember { mutableStateOf(null) } - val airPodsViewModel = remember(airPodsService.value) { - airPodsService.value?.let { service -> - AirPodsViewModel( - service = service, - sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE), - controlRepo = ControlCommandRepository(service.aacpManager), - appContext = context.applicationContext - ) - } - } + val airPodsViewModel: AirPodsViewModel = viewModel() LaunchedEffect(Unit) { - canDrawOverlays = Settings.canDrawOverlays(context) + if (BuildConfig.PLAY_BUILD) { + val now = System.currentTimeMillis() + val firstConn = + sharedPreferences.getLong("first_connection_successful_time", 0L) + + val alreadyPrompted = + sharedPreferences.getBoolean("review_prompted", false) + + val oneDay = 24 * 60 * 60 * 1000L + + if ( + firstConn != 0L && + !alreadyPrompted && + (now - firstConn) > oneDay + ) { + triggerReviewFlow(context as? Activity ?: return@LaunchedEffect) + + sharedPreferences.edit { + putBoolean("review_prompted", true) + } + } + } } - if (permissionState.allPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) { + val onboardingComplete = sharedPreferences.getBoolean("onboarding_complete", false) - val navController = rememberNavController() - - LaunchedEffect(Unit) { - if (BuildConfig.PLAY_BUILD) { - val now = System.currentTimeMillis() - val firstConn = - sharedPreferences.getLong("first_connection_successful_time", 0L) - - val alreadyPrompted = - sharedPreferences.getBoolean("review_prompted", false) - - val oneDay = 24 * 60 * 60 * 1000L - - if ( - firstConn != 0L && - !alreadyPrompted && - (now - firstConn) > oneDay - ) { - triggerReviewFlow(context as? Activity ?: return@LaunchedEffect) - - sharedPreferences.edit { - putBoolean("review_prompted", true) - } - } - } - } - - 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) - } - composable("purchase_screen") { - val purchaseViewModel: PurchaseViewModel = viewModel() - PurchaseScreen(purchaseViewModel, navController) - } - composable("equalizer_screen") { - if (airPodsViewModel != null) EqualizerScreen(airPodsViewModel) - } - } - } - - val showBackButton = remember { mutableStateOf(false) } - - 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 - ) - } - } + val releaseNotesShownPrefKey = "release_notes_shown_${BuildConfig.VERSION_NAME.removeSuffix("-debug")}" + val releaseNotesShown = sharedPreferences.getBoolean(releaseNotesShownPrefKey, false) + fun bindService() { context.startForegroundService(Intent(context, AirPodsService::class.java)) + serviceConnection = object: ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + val binder = service as AirPodsService.LocalBinder + val service = binder.getService() + airPodsService.value = service + airPodsViewModel.init( + service = service, + controlRepo = ControlCommandRepository(service.aacpManager), + sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE), + appContext = context.applicationContext + ) - serviceConnection = remember { - object : ServiceConnection { - override fun onServiceConnected(name: ComponentName?, service: IBinder?) { - val binder = service as AirPodsService.LocalBinder - airPodsService.value = binder.getService() - - if (!sharedPreferences.contains("first_connection_successful_time")) { - sharedPreferences.edit { - putLong("first_connection_successful_time", System.currentTimeMillis()) - } + if (!sharedPreferences.contains("first_connection_successful_time")) { + sharedPreferences.edit { + putLong("first_connection_successful_time", System.currentTimeMillis()) } } + } - override fun onServiceDisconnected(name: ComponentName?) { - airPodsService.value = null - } + override fun onServiceDisconnected(name: ComponentName?) { + airPodsService.value = null } } @@ -545,16 +215,22 @@ fun Main() { serviceConnection, Context.BIND_AUTO_CREATE ) - - if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) { - isConnected.value = true - } - } else { - PermissionsScreen( - permissionState = permissionState, - canDrawOverlays = canDrawOverlays, - onOverlaySettingsReturn = { canDrawOverlays = Settings.canDrawOverlays(context) }) } + + if (onboardingComplete) { + bindService() + } + + NavigationRoot( + showReleaseNotes = !releaseNotesShown, + updatesShown = { sharedPreferences.edit { putBoolean(releaseNotesShownPrefKey, true) } }, + showOnboarding = !onboardingComplete, + onboardingComplete = { + sharedPreferences.edit { putBoolean("onboarding_complete", true) } + bindService() + }, + airPodsViewModel = airPodsViewModel + ) } private fun triggerReviewFlow(activity: Activity) { @@ -567,318 +243,3 @@ private fun triggerReviewFlow(activity: Activity) { } } } - -@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class) -@Composable -fun PermissionsScreen( - permissionState: MultiplePermissionsState, - canDrawOverlays: Boolean, - onOverlaySettingsReturn: () -> Unit -) { - 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 scrollState = rememberScrollState() - - val basicPermissionsGranted = permissionState.permissions.all { it.status.isGranted } - - val infiniteTransition = rememberInfiniteTransition(label = "pulse") - val pulseScale by infiniteTransition.animateFloat( - initialValue = 1f, targetValue = 1.05f, animationSpec = infiniteRepeatable( - animation = tween(1000), repeatMode = RepeatMode.Reverse - ), label = "pulse scale" - ) - - Column( - modifier = Modifier - .fillMaxSize() - .background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)) - .padding(16.dp) - .verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier - .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( - modifier = Modifier - .size(120.dp) - .scale(pulseScale) - ) { - 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) - ) - } - } - } - - 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() - ) - - 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() - ) - - Spacer(modifier = Modifier.height(32.dp)) - - PermissionCard( - title = "Bluetooth Permissions", - description = "Required to communicate with your AirPods", - icon = ImageVector.vectorResource(id = R.drawable.ic_bluetooth), - isGranted = permissionState.permissions.filter { - it.permission.contains("BLUETOOTH") - }.all { it.status.isGranted }, - backgroundColor = backgroundColor, - textColor = textColor, - accentColor = accentColor - ) - - PermissionCard( - title = "Notification Permission", - description = "To show battery status", - icon = Icons.Default.Notifications, - isGranted = permissionState.permissions.find { - it.permission == "android.permission.POST_NOTIFICATIONS" - }?.status?.isGranted == true, - backgroundColor = backgroundColor, - textColor = textColor, - accentColor = accentColor - ) - - PermissionCard( - title = "Phone Permissions", - description = "For answering calls with Head Gestures", - icon = Icons.Default.Phone, - isGranted = permissionState.permissions.filter { - it.permission.contains("PHONE") || it.permission.contains("CALLS") - }.all { it.status.isGranted }, - backgroundColor = backgroundColor, - textColor = textColor, - 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) - ) { - 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 - ), - ) - } - - 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 - ), - ) - } - } - } -} - -@Composable -fun PermissionCard( - title: String, - description: String, - icon: ImageVector, - isGranted: Boolean, - backgroundColor: Color, - textColor: Color, - accentColor: Color -) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 6.dp), - colors = CardDefaults.cardColors( - containerColor = backgroundColor - ), - shape = RoundedCornerShape(12.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(40.dp) - .clip(RoundedCornerShape(8.dp)) - .background( - if (isGranted) accentColor.copy(alpha = 0.15f) else Color.Gray.copy( - alpha = 0.15f - ) - ), contentAlignment = Alignment.Center - ) { - Icon( - imageVector = icon, - contentDescription = title, - tint = if (isGranted) accentColor else Color.Gray, - modifier = Modifier.size(24.dp) - ) - } - - Column( - modifier = Modifier - .weight(1f) - .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( - text = description, style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Normal, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor.copy(alpha = 0.6f) - ) - ) - } - - 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 - ) - ) - } - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt index 0467ef84..ac6d356b 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/AACPManager.kt @@ -1159,7 +1159,7 @@ class AACPManager { ) } - val socket = BluetoothConnectionManager.getAACPSocket() ?: return false + val socket = BluetoothConnectionManager.aacpSocket ?: return false if (socket.isConnected) { socket.outputStream?.write(packet) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt index 4dac27fa..753c4d3d 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/ATTManager.kt @@ -34,7 +34,7 @@ enum class ATTHandles(val value: Int) { enum class ATTCCCDHandles(val value: Int) { TRANSPARENCY(ATTHandles.TRANSPARENCY.value + 1), -// LOUD_SOUND_REDUCTION(ATTHandles.LOUD_SOUND_REDUCTION.value + 1), // doesn't work + // LOUD_SOUND_REDUCTION(ATTHandles.LOUD_SOUND_REDUCTION.value + 1), // doesn't work HEARING_AID(ATTHandles.HEARING_AID.value + 1) } @@ -86,7 +86,7 @@ class ATTManagerv2 { } fun readCharacteristic(handle: ATTHandles, timeoutMillis: Long = 2000): ByteArray? { - val socket = BluetoothConnectionManager.getATTSocket() ?: return null + val socket = BluetoothConnectionManager.attSocket ?: return null try { val output = socket.outputStream val pdu = byteArrayOf(0x0A, handle.value.toByte(), 0x00) @@ -117,7 +117,7 @@ class ATTManagerv2 { } fun writeCharacteristic(handle: Byte, data: ByteArray, timeoutMillis: Long = 2000) { - val socket = BluetoothConnectionManager.getATTSocket() ?: return + val socket = BluetoothConnectionManager.attSocket ?: return try { val output = socket.outputStream val pdu = byteArrayOf(0x12, handle, 0x00) + data // 0x00 for LE @@ -141,7 +141,7 @@ class ATTManagerv2 { fun disconnected() { characteristicList.clear() stopReader() - val socket = BluetoothConnectionManager.getATTSocket() ?: return + val socket = BluetoothConnectionManager.attSocket?: return try { socket.close() } catch (e: Exception) { @@ -151,7 +151,7 @@ class ATTManagerv2 { } private fun runReaderLoop() { - val socket = BluetoothConnectionManager.getATTSocket() ?: run { + val socket = BluetoothConnectionManager.attSocket ?: run { Log.w(TAG, "ATT socket not available. stopping reader") readerRunning.set(false) return diff --git a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BluetoothConnectionManager.kt b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BluetoothConnectionManager.kt index 012b587d..95d5d655 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BluetoothConnectionManager.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/bluetooth/BluetoothConnectionManager.kt @@ -18,22 +18,59 @@ package me.kavishdevar.librepods.bluetooth +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothSocket +import android.os.ParcelUuid +import android.util.Log object BluetoothConnectionManager { - private var aacpSocket: BluetoothSocket? = null - private var attSocket: BluetoothSocket? = null - - fun setCurrentConnection(aacpSocket: BluetoothSocket?, attSocket: BluetoothSocket?) { - BluetoothConnectionManager.aacpSocket = aacpSocket - BluetoothConnectionManager.attSocket = attSocket - } - - fun getAACPSocket(): BluetoothSocket? { - return aacpSocket - } - - fun getATTSocket(): BluetoothSocket? { - return attSocket - } + var aacpSocket: BluetoothSocket? = null + var attSocket: BluetoothSocket? = null +} + +fun createBluetoothSocket( + adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid, psm: Int +): BluetoothSocket { + val type = 3 // L2CAP + val constructorSpecs = listOf( + arrayOf(adapter, device, type, true, true, psm, uuid), // A16QPR3 + arrayOf(device, type, true, true, psm, uuid), + arrayOf(device, type, 1, true, true, psm, uuid), + arrayOf(type, 1, true, true, device, psm, uuid), + arrayOf(type, true, true, device, psm, uuid) + ) + + val constructors = BluetoothSocket::class.java.declaredConstructors + Log.d("createSocket", "BluetoothSocket has ${constructors.size} constructors:") + + constructors.forEachIndexed { index, constructor -> + val params = constructor.parameterTypes.joinToString(", ") { it.simpleName } + Log.d("createSocket", "Constructor $index: ($params)") + } + + var lastException: Exception? = null + var attemptedConstructors = 0 + + for ((index, params) in constructorSpecs.withIndex()) { + try { + Log.d("createSocket", "Trying constructor signature #${index + 1}") + attemptedConstructors++ + + val paramTypes = + params.map { it::class.javaPrimitiveType ?: it::class.java }.toTypedArray() + val constructor = BluetoothSocket::class.java.getDeclaredConstructor(*paramTypes) + constructor.isAccessible = true + return constructor.newInstance(*params) as BluetoothSocket + + } catch (e: Exception) { + Log.e("createSocket", "Constructor signature #${index + 1} failed: ${e.message}") + lastException = e + } + } + + val errorMessage = + "Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures" + Log.e("createSocket", errorMessage) + throw lastException ?: IllegalStateException(errorMessage) } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/HearingAid.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/HearingAid.kt index 2e416f5c..7b989d6d 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/data/HearingAid.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/data/HearingAid.kt @@ -29,6 +29,7 @@ import me.kavishdevar.librepods.bluetooth.ATTHandles import java.io.IOException import java.nio.ByteBuffer import java.nio.ByteOrder +import kotlin.time.Duration.Companion.milliseconds private const val TAG = "HearingAidUtils" @@ -144,7 +145,7 @@ fun sendHearingAidSettings( ) { debounceJob.value?.cancel() debounceJob.value = CoroutineScope(Dispatchers.IO).launch { - delay(100) + delay(100.milliseconds) try { Log.d(TAG, "Current data before update: ${currentData.joinToString(" ") { String.format("%02X", it) }}") if (currentData.size < 104) { diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/updates/UpdateItem.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/updates/UpdateItem.kt new file mode 100644 index 00000000..9fcf15f7 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/data/updates/UpdateItem.kt @@ -0,0 +1,9 @@ +package me.kavishdevar.librepods.data.updates + +import androidx.compose.runtime.Composable + +data class UpdateItem( + val titleRes: Int, + val descriptionRes: Int, + val demoComposeable: @Composable () -> Unit +) diff --git a/android/app/src/main/java/me/kavishdevar/librepods/data/updates/Updates.kt b/android/app/src/main/java/me/kavishdevar/librepods/data/updates/Updates.kt new file mode 100644 index 00000000..e0de6716 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/data/updates/Updates.kt @@ -0,0 +1,35 @@ +package me.kavishdevar.librepods.data.updates + +import androidx.compose.runtime.Composable +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsScreenPreviewMaterial +import me.kavishdevar.librepods.presentation.screens.EqualizerScreenPreviewApple +import me.kavishdevar.librepods.presentation.screens.EqualizerScreenPreviewMaterial +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem + +val update0_3_1 = listOf( + UpdateItem( + titleRes = R.string.material3e, + descriptionRes = R.string.update_m3e_description, + demoComposeable = @Composable { + AirPodsSettingsScreenPreviewMaterial() + } + ), + UpdateItem( + titleRes = R.string.equalizer, + descriptionRes = R.string.update_equalizer_description, + demoComposeable = @Composable { + when (LocalDesignSystem.current) { + DesignSystem.Apple -> { + EqualizerScreenPreviewApple() + } + DesignSystem.Material -> { + EqualizerScreenPreviewMaterial() + } + } + } + ), + ) + +val updates = update0_3_1 diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/MaterialIcons.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/MaterialIcons.kt new file mode 100644 index 00000000..f4d6bb4a --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/MaterialIcons.kt @@ -0,0 +1,528 @@ +package me.kavishdevar.librepods.presentation + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +object MaterialIcons { + val notifications: ImageVector + get() { + if (_notifications != null) { + return _notifications!! + } + _notifications = + ImageVector.Builder( + name = "notifications", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(4f, 19f) + verticalLineTo(17f) + horizontalLineTo(6f) + verticalLineTo(10f) + quadTo(6f, 7.93f, 7.25f, 6.31f) + reflectiveQuadTo(10.5f, 4.2f) + verticalLineTo(3.5f) + quadToRelative(0f, -0.63f, 0.44f, -1.06f) + reflectiveQuadTo(12f, 2f) + reflectiveQuadToRelative(1.06f, 0.44f) + reflectiveQuadTo(13.5f, 3.5f) + verticalLineTo(4.2f) + quadToRelative(2f, 0.5f, 3.25f, 2.11f) + reflectiveQuadTo(18f, 10f) + verticalLineToRelative(7f) + horizontalLineToRelative(2f) + verticalLineToRelative(2f) + horizontalLineTo(4f) + close() + moveToRelative(8f, -7.5f) + close() + moveTo(12f, 22f) + quadToRelative(-0.82f, 0f, -1.41f, -0.59f) + reflectiveQuadTo(10f, 20f) + horizontalLineToRelative(4f) + quadToRelative(0f, 0.82f, -0.59f, 1.41f) + reflectiveQuadTo(12f, 22f) + close() + moveTo(8f, 17f) + horizontalLineToRelative(8f) + verticalLineTo(10f) + quadTo(16f, 8.35f, 14.83f, 7.18f) + reflectiveQuadTo(12f, 6f) + reflectiveQuadTo(9.18f, 7.18f) + reflectiveQuadTo(8f, 10f) + verticalLineToRelative(7f) + close() + } + } + .build() + return _notifications!! + } + + private var _notifications: ImageVector? = null + + val headset_off: ImageVector + get() { + if (_headset_off != null) { + return _headset_off!! + } + _headset_off = + ImageVector.Builder( + name = "headset_off", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(21f, 18.15f) + lineToRelative(-2f, -2f) + verticalLineTo(14f) + horizontalLineTo(16.85f) + lineToRelative(-2f, -2f) + horizontalLineTo(19f) + verticalLineTo(11f) + quadTo(19f, 8.05f, 16.95f, 6.02f) + reflectiveQuadTo(12f, 4f) + quadTo(10.9f, 4f, 9.91f, 4.31f) + reflectiveQuadTo(8.1f, 5.2f) + lineTo(6.65f, 3.8f) + quadTo(7.78f, 2.92f, 9.14f, 2.46f) + reflectiveQuadTo(12f, 2f) + quadToRelative(1.85f, 0f, 3.49f, 0.7f) + reflectiveQuadToRelative(2.86f, 1.93f) + reflectiveQuadToRelative(1.94f, 2.86f) + reflectiveQuadTo(21f, 11f) + verticalLineToRelative(7.15f) + close() + moveTo(12f, 23f) + verticalLineTo(21f) + horizontalLineToRelative(6.18f) + lineToRelative(-1f, -1f) + horizontalLineTo(15f) + verticalLineTo(17.83f) + lineTo(5.53f, 8.35f) + quadTo(5.3f, 8.95f, 5.15f, 9.64f) + reflectiveQuadTo(5f, 11f) + verticalLineToRelative(1f) + horizontalLineTo(9f) + verticalLineToRelative(8f) + horizontalLineTo(5f) + quadTo(4.18f, 20f, 3.59f, 19.41f) + reflectiveQuadTo(3f, 18f) + verticalLineTo(11f) + quadTo(3f, 9.88f, 3.26f, 8.82f) + reflectiveQuadToRelative(0.76f, -2f) + lineTo(0.68f, 3.5f) + lineTo(2.1f, 2.1f) + lineTo(21.88f, 21.9f) + verticalLineTo(23f) + horizontalLineTo(12f) + close() + moveTo(5f, 18f) + horizontalLineTo(7f) + verticalLineTo(14f) + horizontalLineTo(5f) + verticalLineToRelative(4f) + close() + moveTo(5f, 14f) + horizontalLineTo(7f) + horizontalLineTo(5f) + close() + moveToRelative(11.85f, 0f) + horizontalLineTo(19f) + horizontalLineTo(16.85f) + close() + } + } + .build() + return _headset_off!! + } + + private var _headset_off: ImageVector? = null + + val pause: ImageVector + get() { + if (_pause != null) { + return _pause!! + } + _pause = + ImageVector.Builder( + name = "pause", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(13f, 19f) + verticalLineTo(5f) + horizontalLineToRelative(6f) + verticalLineTo(19f) + horizontalLineTo(13f) + close() + moveTo(5f, 19f) + verticalLineTo(5f) + horizontalLineToRelative(6f) + verticalLineTo(19f) + horizontalLineTo(5f) + close() + moveTo(15f, 17f) + horizontalLineToRelative(2f) + verticalLineTo(7f) + horizontalLineTo(15f) + verticalLineTo(17f) + close() + moveTo(7f, 17f) + horizontalLineTo(9f) + verticalLineTo(7f) + horizontalLineTo(7f) + verticalLineTo(17f) + close() + moveTo(7f, 7f) + verticalLineTo(17f) + verticalLineTo(7f) + close() + moveToRelative(8f, 0f) + verticalLineTo(17f) + verticalLineTo(7f) + close() + } + } + .build() + return _pause!! + } + + private var _pause: ImageVector? = null + + val bluetooth: ImageVector + get() { + if (_bluetooth != null) { + return _bluetooth!! + } + _bluetooth = + ImageVector.Builder( + name = "bluetooth", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(11f, 22f) + verticalLineTo(14.4f) + lineTo(6.4f, 19f) + lineTo(5f, 17.6f) + lineTo(10.6f, 12f) + lineTo(5f, 6.4f) + lineTo(6.4f, 5f) + lineTo(11f, 9.6f) + verticalLineTo(2f) + horizontalLineToRelative(1f) + lineToRelative(5.7f, 5.7f) + lineTo(13.4f, 12f) + lineToRelative(4.3f, 4.3f) + lineTo(12f, 22f) + horizontalLineTo(11f) + close() + moveTo(13f, 9.6f) + lineTo(14.9f, 7.7f) + lineTo(13f, 5.85f) + verticalLineTo(9.6f) + close() + moveToRelative(0f, 8.55f) + lineTo(14.9f, 16.3f) + lineTo(13f, 14.4f) + verticalLineToRelative(3.75f) + close() + } + } + .build() + return _bluetooth!! + } + + private var _bluetooth: ImageVector? = null + + val bluetooth_searching: ImageVector + get() { + if (_bluetooth_searching != null) { + return _bluetooth_searching!! + } + _bluetooth_searching = + ImageVector.Builder( + name = "bluetooth_searching", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.NonZero, + ) { + moveTo(9f, 22f) + verticalLineTo(14.4f) + lineTo(4.4f, 19f) + lineTo(3f, 17.6f) + lineTo(8.6f, 12f) + lineTo(3f, 6.4f) + lineTo(4.4f, 5f) + lineTo(9f, 9.6f) + verticalLineTo(2f) + horizontalLineToRelative(1f) + lineToRelative(5.7f, 5.7f) + lineTo(11.4f, 12f) + lineToRelative(4.3f, 4.3f) + lineTo(10f, 22f) + horizontalLineTo(9f) + close() + moveTo(11f, 9.6f) + lineTo(12.9f, 7.7f) + lineTo(11f, 5.85f) + verticalLineTo(9.6f) + close() + moveToRelative(0f, 8.55f) + lineTo(12.9f, 16.3f) + lineTo(11f, 14.4f) + verticalLineToRelative(3.75f) + close() + moveToRelative(5.55f, -3.8f) + lineTo(14.25f, 12f) + lineToRelative(2.3f, -2.3f) + quadToRelative(0.23f, 0.55f, 0.36f, 1.13f) + reflectiveQuadTo(17.05f, 12f) + reflectiveQuadToRelative(-0.14f, 1.19f) + quadToRelative(-0.14f, 0.59f, -0.36f, 1.16f) + close() + moveTo(19.5f, 17.2f) + lineTo(18.25f, 16f) + quadToRelative(0.5f, -0.93f, 0.78f, -1.94f) + reflectiveQuadTo(19.3f, 12f) + reflectiveQuadTo(19.03f, 9.94f) + quadTo(18.75f, 8.92f, 18.25f, 8f) + lineTo(19.5f, 6.75f) + quadToRelative(0.73f, 1.2f, 1.11f, 2.52f) + reflectiveQuadTo(21f, 12f) + reflectiveQuadToRelative(-0.39f, 2.71f) + quadTo(20.23f, 16.02f, 19.5f, 17.2f) + close() + } + } + .build() + return _bluetooth_searching!! + } + + private var _bluetooth_searching: ImageVector? = null + + val call: ImageVector + get() { + if (_call != null) { + return _call!! + } + _call = + ImageVector.Builder( + name = "call", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.Companion.NonZero, + ) { + moveTo(19.95f, 21f) + quadToRelative(-3.13f, 0f, -6.18f, -1.36f) + reflectiveQuadTo(8.23f, 15.78f) + quadTo(5.73f, 13.27f, 4.36f, 10.23f) + reflectiveQuadTo(3f, 4.05f) + quadTo(3f, 3.6f, 3.3f, 3.3f) + reflectiveQuadTo(4.05f, 3f) + horizontalLineTo(8.1f) + quadTo(8.45f, 3f, 8.73f, 3.24f) + reflectiveQuadTo(9.05f, 3.8f) + lineTo(9.7f, 7.3f) + quadTo(9.75f, 7.7f, 9.68f, 7.97f) + reflectiveQuadTo(9.4f, 8.45f) + lineTo(6.98f, 10.9f) + quadToRelative(0.5f, 0.93f, 1.19f, 1.79f) + reflectiveQuadToRelative(1.51f, 1.66f) + quadToRelative(0.78f, 0.78f, 1.63f, 1.44f) + reflectiveQuadTo(13.1f, 17f) + lineToRelative(2.35f, -2.35f) + quadToRelative(0.22f, -0.23f, 0.59f, -0.34f) + reflectiveQuadToRelative(0.71f, -0.06f) + lineToRelative(3.45f, 0.7f) + quadToRelative(0.35f, 0.1f, 0.57f, 0.36f) + reflectiveQuadTo(21f, 15.9f) + verticalLineToRelative(4.05f) + quadToRelative(0f, 0.45f, -0.3f, 0.75f) + reflectiveQuadTo(19.95f, 21f) + close() + moveTo(6.03f, 9f) + lineTo(7.68f, 7.35f) + lineTo(7.25f, 5f) + horizontalLineTo(5.03f) + quadTo(5.15f, 6.02f, 5.38f, 7.02f) + reflectiveQuadTo(6.03f, 9f) + close() + moveToRelative(8.95f, 8.95f) + quadToRelative(0.97f, 0.43f, 1.99f, 0.68f) + reflectiveQuadTo(19f, 18.95f) + verticalLineToRelative(-2.2f) + lineTo(16.65f, 16.27f) + lineToRelative(-1.68f, 1.68f) + close() + moveTo(6.03f, 9f) + close() + moveToRelative(8.95f, 8.95f) + close() + } + } + .build() + return _call!! + } + + private var _call: ImageVector? = null + + val stack: ImageVector + get() { + if (_stack != null) { + return _stack!! + } + _stack = + ImageVector.Builder( + name = "stack", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ) + .apply { + path( + fill = SolidColor(Color.Black), + fillAlpha = 1f, + stroke = null, + strokeAlpha = 1f, + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Bevel, + strokeLineMiter = 1f, + pathFillType = PathFillType.Companion.NonZero, + ) { + moveTo(6f, 14f) + verticalLineToRelative(2f) + horizontalLineTo(4f) + quadTo(3.18f, 16f, 2.59f, 15.41f) + reflectiveQuadTo(2f, 14f) + verticalLineTo(4f) + quadTo(2f, 3.17f, 2.59f, 2.59f) + reflectiveQuadTo(4f, 2f) + horizontalLineTo(14f) + quadToRelative(0.83f, 0f, 1.41f, 0.59f) + reflectiveQuadTo(16f, 4f) + verticalLineTo(6f) + horizontalLineTo(14f) + verticalLineTo(4f) + horizontalLineTo(4f) + verticalLineTo(14f) + horizontalLineTo(6f) + close() + moveToRelative(4f, 8f) + quadTo(9.18f, 22f, 8.59f, 21.41f) + reflectiveQuadTo(8f, 20f) + verticalLineTo(10f) + quadTo(8f, 9.17f, 8.59f, 8.59f) + reflectiveQuadTo(10f, 8f) + horizontalLineTo(20f) + quadToRelative(0.83f, 0f, 1.41f, 0.59f) + reflectiveQuadTo(22f, 10f) + verticalLineTo(20f) + quadToRelative(0f, 0.82f, -0.59f, 1.41f) + reflectiveQuadTo(20f, 22f) + horizontalLineTo(10f) + close() + moveToRelative(0f, -2f) + horizontalLineTo(20f) + verticalLineTo(10f) + horizontalLineTo(10f) + verticalLineTo(20f) + close() + moveToRelative(5f, -5f) + close() + } + } + .build() + return _stack!! + } + + private var _stack: ImageVector? = null +} 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 f0669bad..61ee5e56 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 @@ -20,187 +20,60 @@ 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.fillMaxWidth -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.runtime.Composable import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.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 me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import kotlin.io.encoding.ExperimentalEncodingApi @Composable fun AboutCard( - navController: NavController, modelName: String, actualModel: String, serialNumbers: List, - version: String? + version: String?, + navigateToVersion: () -> 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) - ){ - 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)) - ) - ) - } - - 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) - ) { - 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)) - ) - ) - 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)) - ) - ) - } - 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)) - ) - ) - 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)) - ) - ) - } - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier - .padding(horizontal = 12.dp) - ) - val serialNumbers = listOf( + val serialNumbers = when (LocalDesignSystem.current) { + DesignSystem.Apple -> 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)) - ), - ) - 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)) - ), - modifier = Modifier - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { - serialNumber.intValue = (serialNumber.intValue + 1) % serialNumbers.size - } - ) - } - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier - .padding(horizontal = 12.dp) + + DesignSystem.Material -> listOf( + serialNumbers[0], + stringResource(R.string.left) + " " + {serialNumbers[1]}, + stringResource(R.string.right) + " " + {serialNumbers[2]}, ) - NavigationButton( - to = "version_info", - navController = navController, + } + + val serialNumber = remember { mutableIntStateOf(0) } + + StyledList (title = stringResource(R.string.about)) { + StyledListItem( + name = stringResource(R.string.model_name), + description = modelName + ) + + StyledListItem( + name = stringResource(R.string.model_number), + description = actualModel + ) + + StyledListItem ( + name = stringResource(R.string.serial_number), + description = serialNumbers[serialNumber.intValue], + onClick = { serialNumber.intValue = (serialNumber.intValue + 1) % serialNumbers.size } + ) + + StyledListItem( name = stringResource(R.string.version), - currentState = version, - independent = false, - height = rowHeight.value + 32.dp + description = version, + onClick = navigateToVersion, ) } } 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 bc4d0871..992cf85d 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,176 +18,35 @@ 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.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 @Composable -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 +fun AppInfoCard( + navigateToReleaseNotesScreen: (() -> Unit)? = null, +) { + StyledList(title = stringResource(R.string.about)) { + StyledListItem( + name = stringResource(R.string.version), + description = BuildConfig.VERSION_NAME, + onClick = navigateToReleaseNotesScreen + ) - 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)) - ) - ) - } + StyledListItem( + name = stringResource(R.string.version_code), + description = BuildConfig.VERSION_CODE.toString(), + ) - 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.version), style = TextStyle( - fontSize = 16.sp, - color = textColor, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ) - ) - 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)) - ) - ) - } - 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)) - ) - ) - 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)) - ) - ) - } - 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)) - ) - ) - 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)) - ) - ) - } - 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)) - ) - ) - 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)) - ) - ) - } - } + StyledListItem( + name = stringResource(R.string.flavor), + description = BuildConfig.FLAVOR, + ) + + StyledListItem( + name = stringResource(R.string.build_type), + description = BuildConfig.BUILD_TYPE, + ) } } 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 6d8b91ac..396c72cf 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,35 +20,13 @@ 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.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 import kotlin.io.encoding.ExperimentalEncodingApi @Composable fun AudioSettings( - navController: NavController, adaptiveVolumeCapability: Boolean, conversationalAwarenessCapability: Boolean, loudSoundReductionCapability: Boolean, @@ -64,135 +42,57 @@ fun AudioSettings( loudSoundReductionChecked: Boolean, onLoudSoundReductionCheckedChange: (Boolean) -> Unit, + navigateToAdaptiveStrength: () -> Unit, + navigateToEqualizer: () -> Unit, + vendorIdHook: Boolean, isPremium: Boolean ) { - val isDarkTheme = isSystemInDarkTheme() - val textColor = if (isDarkTheme) Color.White else Color.Black + if (adaptiveVolumeCapability || conversationalAwarenessCapability || loudSoundReductionCapability || adaptiveAudioCapability) { + StyledList(title = stringResource(R.string.audio)) { + if (adaptiveVolumeCapability) { + StyledToggle( + label = stringResource(R.string.personalized_volume), + description = stringResource(R.string.personalized_volume_description), + checked = adaptiveVolumeChecked, + onCheckedChange = onAdaptiveVolumeCheckedChange, + enabled = isPremium, + ) + } - 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)) - ) - ) - } + if (conversationalAwarenessCapability) { + StyledToggle( + label = stringResource(R.string.conversational_awareness), + description = stringResource(R.string.conversational_awareness_description), + checked = conversationalAwarenessChecked, + onCheckedChange = onConversationalAwarenessCheckedChange, + enabled = isPremium, + ) + } - val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + if (loudSoundReductionCapability && vendorIdHook) { + StyledToggle( + label = stringResource(R.string.loud_sound_reduction), + description = stringResource(R.string.loud_sound_reduction_description), + checked = loudSoundReductionChecked, + onCheckedChange = onLoudSoundReductionCheckedChange, + enabled = isPremium, + ) + } - Column( - modifier = Modifier - .clip(RoundedCornerShape(28.dp)) - .fillMaxWidth() - .background(backgroundColor, RoundedCornerShape(28.dp)) - .padding(top = 2.dp) - ) { + if (adaptiveAudioCapability) { + StyledListItem( + name = stringResource(R.string.adaptive_audio), + onClick = navigateToAdaptiveStrength, + ) + } - 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 - ) - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier - .padding(horizontal = 12.dp) - ) - } - if (customEqCapability) { - NavigationButton( - to = "equalizer_screen", - name = stringResource(R.string.equalizer), - navController = navController, - independent = false - ) + if (customEqCapability) { + StyledListItem( + name = stringResource(R.string.equalizer), + onClick = navigateToEqualizer, + ) + } } } } - -@Preview -@Composable -fun AudioSettingsPreview() { - AudioSettings( - navController = rememberNavController(), - adaptiveVolumeCapability = true, - conversationalAwarenessCapability = true, - loudSoundReductionCapability = true, - adaptiveAudioCapability = true, - customEqCapability = true, - adaptiveVolumeChecked = true, - onAdaptiveVolumeCheckedChange = { }, - conversationalAwarenessChecked = true, - onConversationalAwarenessCheckedChange = { }, - loudSoundReductionChecked = true, - onLoudSoundReductionCheckedChange = { }, - vendorIdHook = true, - isPremium = true - ) -} 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 a2a5804e..4c0d4351 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 @@ -31,6 +31,7 @@ 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.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -53,6 +54,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import me.kavishdevar.librepods.R import me.kavishdevar.librepods.data.BatteryStatus +import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme import kotlin.math.cos import kotlin.math.min import kotlin.math.sin @@ -66,7 +68,6 @@ fun BatteryIndicator( 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) @@ -82,7 +83,7 @@ fun BatteryIndicator( } Column( - modifier = Modifier.background(backgroundColor).padding(4.dp), // just for haze to work + modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer).padding(4.dp), // just for haze to work horizontalAlignment = Alignment.CenterHorizontally ) { Box( @@ -200,10 +201,7 @@ 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) - ) { + LibrePodsTheme(m3eEnabled = false) { BatteryIndicator( batteryPercentage = 50, status = BatteryStatus.OPTIMIZED_CHARGING, 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 7accabee..a3f9dffa 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 @@ -64,9 +64,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( 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 2b00c06e..e21b7efb 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 @@ -20,413 +20,70 @@ 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 -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.material3.HorizontalDivider -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect 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.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 -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 import me.kavishdevar.librepods.R import kotlin.io.encoding.ExperimentalEncodingApi @ExperimentalHazeMaterialsApi @Composable fun CallControlSettings( - hazeState: HazeState, flipped: Boolean, - onCallControlValueChanged: (Boolean) -> Unit + navigateToCallControlScreen: (action: String) -> 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) - ){ - 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)) - ) + val pressOnceText = stringResource(R.string.press_once) + val pressTwiceText = stringResource(R.string.press_twice) + + var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) } + var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) } + + val muteUnmuteText = stringResource(R.string.mute_unmute) + val hangUpText = stringResource(R.string.hang_up) + + StyledList(title = stringResource(R.string.call_controls)) { + StyledListItem( + name = stringResource(R.string.answer_call), + description = stringResource(R.string.press_once), + enabled = false ) - } - Column( - modifier = Modifier - .fillMaxWidth() - .background(backgroundColor, RoundedCornerShape(28.dp)) - .padding(top = 2.dp) - ) { + StyledListItem( + name = muteUnmuteText, + description = singlePressAction, + onClick = { navigateToCallControlScreen(muteUnmuteText) } , + ) - val scope = rememberCoroutineScope() - val haptics = LocalHapticFeedback.current + StyledListItem( + name = hangUpText, + description = doublePressAction, + onClick = { navigateToCallControlScreen(hangUpText) } + ) - val pressOnceText = stringResource(R.string.press_once) - val pressTwiceText = stringResource(R.string.press_twice) - - var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) } - var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) } - - var showSinglePressDropdown by remember { mutableStateOf(false) } - var touchOffsetSingle by remember { mutableStateOf(null) } - var boxPositionSingle by remember { mutableStateOf(Offset.Zero) } - var lastDismissTimeSingle by remember { mutableLongStateOf(0L) } - var parentHoveredIndexSingle by remember { mutableStateOf(null) } - var parentDragActiveSingle by remember { mutableStateOf(false) } - var previousIdxSingle by remember { mutableStateOf(null) } - - var showDoublePressDropdown by remember { mutableStateOf(false) } - var touchOffsetDouble by remember { mutableStateOf(null) } - var boxPositionDouble by remember { mutableStateOf(Offset.Zero) } - var lastDismissTimeDouble by remember { mutableLongStateOf(0L) } - var parentHoveredIndexDouble by remember { mutableStateOf(null) } - var parentDragActiveDouble by remember { mutableStateOf(false) } - var previousIdxDouble by remember { mutableStateOf(null) } - - LaunchedEffect(flipped) { - Log.d("CallControlSettings", "Call control flipped: $flipped") - } - - val density = LocalDensity.current - val itemHeightPx = with(density) { 48.dp.toPx() } - - Column( - modifier = Modifier - .fillMaxWidth() - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .height(58.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.answer_call), - fontSize = 16.sp, - color = textColor, - modifier = Modifier.padding(bottom = 4.dp) - ) - Text( - text = stringResource(R.string.press_once), - fontSize = 16.sp, - color = textColor.copy(alpha = 0.6f) - ) - } - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier - .padding(horizontal = 12.dp) - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .height(58.dp) - .pointerInput(Unit) { - detectTapGestures { offset -> - val now = System.currentTimeMillis() - if (showSinglePressDropdown) { - showSinglePressDropdown = false - lastDismissTimeSingle = now - } else { - if (now - lastDismissTimeSingle > 250L) { - touchOffsetSingle = offset - showSinglePressDropdown = true - } - } - } - } - .pointerInput(Unit) { - detectDragGesturesAfterLongPress( - onDragStart = { offset -> - val now = System.currentTimeMillis() - touchOffsetSingle = offset - if (!showSinglePressDropdown && now - lastDismissTimeSingle > 250L) { - showSinglePressDropdown = true - } - lastDismissTimeSingle = now - parentDragActiveSingle = true - parentHoveredIndexSingle = 0 - }, - onDrag = { change, _ -> - val current = change.position - val touch = touchOffsetSingle ?: current - val posInPopupY = current.y - touch.y - val idx = (posInPopupY / itemHeightPx).toInt() - if (idx != previousIdxSingle) { - scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) } - } - parentHoveredIndexSingle = idx - previousIdxSingle = idx - }, - onDragEnd = { - parentDragActiveSingle = false - parentHoveredIndexSingle?.let { idx -> - val options = listOf(pressOnceText, pressTwiceText) - if (idx in options.indices) { - val option = options[idx] - singlePressAction = option - doublePressAction = - if (option == pressOnceText) pressTwiceText else pressOnceText - showSinglePressDropdown = false - lastDismissTimeSingle = System.currentTimeMillis() - onCallControlValueChanged(option != pressOnceText) - - } - } - if (parentHoveredIndexSingle != null && parentHoveredIndexSingle in 0..1) { - scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) } - } - parentHoveredIndexSingle = null - }, - onDragCancel = { - parentDragActiveSingle = false - parentHoveredIndexSingle = null - } - ) - }, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.mute_unmute), - fontSize = 16.sp, - color = textColor, - modifier = Modifier.padding(bottom = 4.dp) - ) - Box( - modifier = Modifier.onGloballyPositioned { coordinates -> - boxPositionSingle = coordinates.positionInParent() - } - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = singlePressAction, - 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 = showSinglePressDropdown, - onDismissRequest = { - showSinglePressDropdown = false - lastDismissTimeSingle = System.currentTimeMillis() - }, - options = listOf(pressOnceText, pressTwiceText), - selectedOption = singlePressAction, - touchOffset = touchOffsetSingle, - boxPosition = boxPositionSingle, - externalHoveredIndex = parentHoveredIndexSingle, - externalDragActive = parentDragActiveSingle, - onOptionSelected = { option -> - singlePressAction = option - doublePressAction = - if (option == pressOnceText) pressTwiceText else pressOnceText - showSinglePressDropdown = false - val flipped = option != pressOnceText - onCallControlValueChanged(flipped) - }, - hazeState = hazeState - ) - } - } - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier - .padding(horizontal = 12.dp) - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .height(58.dp) - .pointerInput(Unit) { - detectTapGestures { offset -> - val now = System.currentTimeMillis() - if (showDoublePressDropdown) { - showDoublePressDropdown = false - lastDismissTimeDouble = now - } else { - if (now - lastDismissTimeDouble > 250L) { - touchOffsetDouble = offset - showDoublePressDropdown = true - } - } - } - } - .pointerInput(Unit) { - detectDragGesturesAfterLongPress( - onDragStart = { offset -> - val now = System.currentTimeMillis() - touchOffsetDouble = offset - if (!showDoublePressDropdown && now - lastDismissTimeDouble > 250L) { - showDoublePressDropdown = true - } - lastDismissTimeDouble = now - parentDragActiveDouble = true - parentHoveredIndexDouble = 0 - }, - onDrag = { change, _ -> - val current = change.position - val touch = touchOffsetDouble ?: current - val posInPopupY = current.y - touch.y - val idx = (posInPopupY / itemHeightPx).toInt() - if (idx != previousIdxDouble) { - scope.launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) } - } - parentHoveredIndexDouble = idx - previousIdxDouble = idx - }, - onDragEnd = { - parentDragActiveDouble = false - parentHoveredIndexDouble?.let { idx -> - val options = listOf(pressOnceText, pressTwiceText) - if (idx in options.indices) { - val option = options[idx] - doublePressAction = option - singlePressAction = - if (option == pressOnceText) pressTwiceText else pressOnceText - showDoublePressDropdown = false - lastDismissTimeDouble = System.currentTimeMillis() - val flipped = option == pressOnceText - onCallControlValueChanged(flipped) - } - } - if (parentHoveredIndexDouble != null && parentHoveredIndexDouble in 0..1) { - scope.launch { haptics.performHapticFeedback(HapticFeedbackType.GestureEnd) } - } - parentHoveredIndexDouble = null - }, - onDragCancel = { - parentDragActiveDouble = false - parentHoveredIndexDouble = null - } - ) - }, - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.hang_up), - fontSize = 16.sp, - color = textColor, - modifier = Modifier.padding(bottom = 4.dp) - ) - Box( - modifier = Modifier.onGloballyPositioned { coordinates -> - boxPositionDouble = coordinates.positionInParent() - } - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = doublePressAction, - 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 = showDoublePressDropdown, - onDismissRequest = { - showDoublePressDropdown = false - lastDismissTimeDouble = System.currentTimeMillis() - }, - options = listOf(pressOnceText, pressTwiceText), - selectedOption = doublePressAction, - touchOffset = touchOffsetDouble, - boxPosition = boxPositionDouble, - externalHoveredIndex = parentHoveredIndexDouble, - externalDragActive = parentDragActiveDouble, - onOptionSelected = { option -> - doublePressAction = option - singlePressAction = - if (option == pressOnceText) pressTwiceText else pressOnceText - showDoublePressDropdown = false - val flipped = option == pressOnceText - onCallControlValueChanged(flipped) - }, - hazeState = hazeState - ) - } - } - } +// StyledListItem( +// name = pressOnceText, +// selected = doublePressAction == pressOnceText, +// onClick = { +// doublePressAction = pressOnceText +// singlePressAction = pressTwiceText +// +// onCallControlValueChanged(true) +// } +// ) +// +// StyledListItem( +// name = pressTwiceText, +// selected = doublePressAction == pressTwiceText, +// onClick = { +// doublePressAction = pressTwiceText +// singlePressAction = pressOnceText +// +// onCallControlValueChanged(false) +// } +// ) } } 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 35e538ad..3d7fc287 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 @@ -37,7 +37,11 @@ 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.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState import androidx.compose.ui.Alignment @@ -51,6 +55,7 @@ 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 androidx.compose.ui.window.DialogProperties import com.kyant.backdrop.backdrops.LayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.drawBackdrop @@ -59,7 +64,10 @@ import com.kyant.backdrop.effects.lens import com.kyant.backdrop.effects.vibrancy import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +@OptIn(ExperimentalMaterial3Api::class) @ExperimentalHazeMaterialsApi @Composable fun ConfirmationDialog( @@ -72,105 +80,136 @@ fun ConfirmationDialog( onDismiss: () -> Unit = { showDialog.value = false }, 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() ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - val innerBackdrop = rememberLayerBackdrop() - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.4f)) - .clickable(enabled = false, onClick = {}), - contentAlignment = Alignment.Center - ) { - Box( - modifier = Modifier - .requiredWidthIn(min = 200.dp, max = 360.dp) - .clip(RoundedCornerShape(48.dp)) - .drawBackdrop( - 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)) + when (LocalDesignSystem.current) { + DesignSystem.Material -> { + BasicAlertDialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = false + ) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.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) + text = title, + style = MaterialTheme.typography.titleMediumEmphasized ) - 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) + text = message, + style = MaterialTheme.typography.bodyMedium ) - Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = Modifier.fillMaxWidth(0.9f), - horizontalArrangement = Arrangement.spacedBy(24.dp) - ) { - StyledButton( - onClick = onDismiss, - backdrop = innerBackdrop, - modifier = Modifier.weight(1f), + Row(modifier = Modifier.align(Alignment.End)) { + TextButton( + onClick = onDismiss ) { Text( - text = dismissText, style = TextStyle( - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - color = textColor - ) + text = dismissText, + style = MaterialTheme.typography.labelMedium ) } - StyledButton( - onClick = onConfirm, - backdrop = innerBackdrop, - modifier = Modifier.weight(1f), - surfaceColor = accentColor + TextButton( + onClick = onConfirm ) { Text( - text = confirmText, style = TextStyle( - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - color = Color.White - ) + text = confirmText, + style = MaterialTheme.typography.labelMedium ) } } - Spacer(modifier = Modifier.height(24.dp)) + } + } + } + DesignSystem.Apple -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + val innerBackdrop = rememberLayerBackdrop() + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.4f)) + .clickable(enabled = false, onClick = {}), + contentAlignment = Alignment.Center + ) { + val isDarkTheme = isSystemInDarkTheme() + Box( + modifier = Modifier + .requiredWidthIn(min = 200.dp, max = 360.dp) + .clip(RoundedCornerShape(48.dp)) + .drawBackdrop( + 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 = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 16.dp) + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + message, + style = MaterialTheme.typography.bodyMedium, + 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) + ) { + StyledButton( + onClick = onDismiss, + backdrop = innerBackdrop, + modifier = Modifier.weight(1f), + materialButtonStyle = MaterialButtonStyle.Outlined, + ) { + Text( + text = dismissText, + style = MaterialTheme.typography.bodyMedium + ) + } + StyledButton( + onClick = onConfirm, + backdrop = innerBackdrop, + modifier = Modifier.weight(1f), + materialButtonStyle = MaterialButtonStyle.Filled, + surfaceColor = MaterialTheme.colorScheme.primary + ) { + Text( + text = confirmText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimary + ) + } + } + 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 ba548185..72f36952 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,18 +20,8 @@ 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.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 import kotlin.io.encoding.ExperimentalEncodingApi @@ -42,32 +32,16 @@ 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) - ) { + StyledList { StyledToggle( label = stringResource(R.string.ear_detection), - independent = false, checked = automaticEarDetectionEnabled, onCheckedChange = onAutomaticEarDetectionChanged ) - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier - .padding(horizontal = 12.dp) - ) 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/DeviceInfoCard.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/DeviceInfoCard.kt index 18ab494d..b5a99688 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,56 @@ 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.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() + StyledList(title = stringResource(R.string.device_info)) { + StyledListItem( + name = stringResource(R.string.manufacturer), + description = Build.MANUFACTURER, + enabled = false + ) - val textColor = if (isDarkTheme) Color.White else Color.Black - val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + StyledListItem( + name = stringResource(R.string.model_number), + description = Build.MODEL, + enabled = false + ) - val rowHeight = remember { mutableStateOf(0.dp) } - val density = LocalDensity.current + StyledListItem( + name = stringResource(R.string.build_id), + description = Build.DISPLAY, + enabled = false + ) - 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 = 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)) - ) { - 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)) - ) - ) - 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)) - ) - ) - } - 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)) - ) - ) - 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)) - ) - ) - } - 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)) - ) - ) - } - } + StyledListItem( + name = stringResource(R.string.version), + description = "${Build.ID} (${Build.VERSION.SDK_INT_FULL})", + enabled = false + ) + + StyledListItem( + name = stringResource(R.string.xposed_available), + description = if (XposedState.isAvailable) { + stringResource(R.string.yes) + } else { + stringResource(R.string.no) + }, + enabled = false + ) + + StyledListItem( + name = stringResource(R.string.app_enabled_in_xposed), + description = if (XposedState.bluetoothScopeEnabled) { + stringResource(R.string.yes) + } else { + stringResource(R.string.no) + }, + enabled = false + ) } } 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 a444ddae..ae554995 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,99 +20,43 @@ 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.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 @Composable fun HearingHealthSettings( - navController: NavController, hasPPECapability: Boolean, hasHearingAidCapability: Boolean, - vendorIdHook: Boolean + vendorIdHook: Boolean, + navigateToHearingProtection: () -> Unit, + navigateToHearingAid: () -> Unit ) { - 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) - ) { - NavigationButton( - to = "hearing_protection", + StyledList(title = stringResource(R.string.hearing_health)) { + StyledListItem( name = stringResource(R.string.hearing_protection), - navController = navController, - independent = false + onClick = navigateToHearingProtection ) - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier - .padding(horizontal = 12.dp) - ) - - - NavigationButton( - to = "hearing_aid", + StyledListItem( name = stringResource(R.string.hearing_aid), - navController = navController, - independent = false + onClick = navigateToHearingAid ) } } else if (shouldShowHearingAid) { - NavigationButton( - to = "hearing_aid", + StyledListItem( name = stringResource(R.string.hearing_aid), - navController = navController + onClick = navigateToHearingAid ) } else if (hasPPECapability) { - NavigationButton( - to = "hearing_protection", - name = stringResource(R.string.hearing_protection), + StyledListItem( title = stringResource(R.string.hearing_health), - navController = navController + name = stringResource(R.string.hearing_protection), + onClick = navigateToHearingProtection ) } } 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 deleted file mode 100644 index 5f693e9b..00000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/MicrophoneSettings.kt +++ /dev/null @@ -1,263 +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.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 -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.material3.Text -import androidx.compose.runtime.Composable -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.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 -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 -import me.kavishdevar.librepods.R -import kotlin.io.encoding.ExperimentalEncodingApi - -@ExperimentalHazeMaterialsApi -@Composable -fun MicrophoneSettings( - hazeState: HazeState, - 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) - ) { - 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( - 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 - } - } - } - } - .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()) - } - } - 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() - } - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = selectedMode, - 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) - ) - } - - 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 deleted file mode 100644 index ceff731a..00000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/NavigationButton.kt +++ /dev/null @@ -1,179 +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.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.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.padding -import androidx.compose.foundation.shape.RoundedCornerShape -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.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 - -@Composable -fun NavigationButton( - to: String, - name: String, - navController: NavController, onClick: (() -> Unit)? = null, - independent: Boolean = true, - title: String? = null, - description: String? = null, - currentState: String? = null, - 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() - - 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)) - ) - ) - } - } - Row( - 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) - } - } - ) - } - .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) { - 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( - 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) - ) - } - } - } -} - -@Preview -@Composable -fun NavigationButtonPreview() { - NavigationButton("to", "Name", NavController(LocalContext.current)) -} 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 453c35c7..de02ac73 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 @@ -30,6 +30,7 @@ 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.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -40,10 +41,16 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset 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.ButtonGroupDefaults +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.ToggleButton +import androidx.compose.material3.ToggleButtonDefaults import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -51,6 +58,7 @@ 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 @@ -59,19 +67,22 @@ 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.tooling.preview.Preview 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 me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.theme.sectionHeader import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.math.roundToInt +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope") @Composable fun NoiseControlSettings( @@ -79,319 +90,433 @@ fun NoiseControlSettings( 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 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 - } 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)) - ) - ) - } - - BoxWithConstraints( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) { - val density = LocalDensity.current - val buttonCount = if (showOffListeningMode) 4 else 3 - val buttonWidth = maxWidth / buttonCount - - 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 - } - - val animatedOffset by animateFloatAsState( - targetValue = with(density) { - if (isDragging.value) dragOffset else targetOffset.toPx() - }, - animationSpec = animationSpec, - label = "selector" - ) - - Column( - modifier = Modifier.fillMaxWidth() - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(60.dp) - .background(backgroundColor, RoundedCornerShape(28.dp)) - ) { - 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 + when (LocalDesignSystem.current) { + DesignSystem.Material -> { + val options = buildList { + if (showOffListeningMode) { + add( + Triple( + NoiseControlMode.OFF, + R.string.off, + R.drawable.noise_cancellation ) - 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 ) } + add( + Triple( + NoiseControlMode.TRANSPARENCY, + R.string.transparency, + R.drawable.transparency + ) + ) + add( + Triple( + NoiseControlMode.ADAPTIVE, + R.string.adaptive, + R.drawable.adaptive + ) + ) + add( + Triple( + NoiseControlMode.NOISE_CANCELLATION, + R.string.noise_cancellation, + R.drawable.noise_cancellation + ) + ) + } + + val selectedMode = NoiseControlMode.entries[(noiseControlModeValue - 1).coerceIn(0, NoiseControlMode.entries.lastIndex)] + + Column { Box( modifier = Modifier - .width(buttonWidth) - .fillMaxHeight() - .offset { IntOffset(animatedOffset.roundToInt(), 0) } - .zIndex(0f) - .draggable( - orientation = Orientation.Horizontal, - state = rememberDraggableState { delta -> - dragOffset = (dragOffset + delta).coerceIn( - 0f, - with(density) { (buttonWidth * (buttonCount - 1)).toPx() } + .background(Color.Transparent) + .padding(horizontal = 16.dp) + .padding(top = 4.dp, bottom = 12.dp) + ) { + Text( + text = stringResource(R.string.noise_control), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelSmallEmphasized + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween), + ) { + options.forEachIndexed { index, (mode, labelRes, iconRes) -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f), + ) { + ToggleButton( + checked = selectedMode == mode, + onCheckedChange = { + if (it) { + onNoiseControlModeChanged(mode.ordinal + 1) + } + }, + shapes = when (index) { + 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes() + options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes() + else -> ButtonGroupDefaults.connectedMiddleButtonShapes() + }, + colors = ToggleButtonDefaults.toggleButtonColors() + .copy(containerColor = MaterialTheme.colorScheme.surface), + modifier = Modifier.fillMaxWidth() + ) { + Icon( + bitmap = ImageBitmap.imageResource(iconRes), + contentDescription = null, + modifier = Modifier.size(42.dp) ) - }, - onDragStarted = { isDragging.value = true }, - 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) } - ) + + Text( + text = stringResource(labelRes), + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center, + maxLines = 2, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } + } + + DesignSystem.Apple -> { + 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 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 + } 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(MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 16.dp) + .padding(top = 4.dp, bottom = 4.dp) + ) { + Text( + text = stringResource(R.string.noise_control), + color = MaterialTheme.colorScheme.sectionHeader, + style = MaterialTheme.typography.labelSmallEmphasized + ) + } + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + val density = LocalDensity.current + val buttonCount = if (showOffListeningMode) 4 else 3 + val buttonWidth = maxWidth / buttonCount + + 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 + } + + val animatedOffset by animateFloatAsState( + targetValue = with(density) { + if (isDragging.value) dragOffset else targetOffset.toPx() + }, + animationSpec = animationSpec, + label = "selector" + ) + + Column( + modifier = Modifier.fillMaxWidth() ) { Box( modifier = Modifier - .fillMaxSize() - .padding(3.dp) - .background(selectedBackground, RoundedCornerShape(26.dp)) - ) - } + .fillMaxWidth() + .height(60.dp) + .background(backgroundColor, RoundedCornerShape(28.dp)) + ) { + 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 + ) + } - Row( - modifier = Modifier - .fillMaxWidth() - .zIndex(1f) - ) { - 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, + Box( modifier = Modifier - .padding(vertical = 10.dp) - .alpha(d1a.floatValue), - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) + .width(buttonWidth) + .fillMaxHeight() + .offset { IntOffset(animatedOffset.roundToInt(), 0) } + .zIndex(0f) + .draggable( + orientation = Orientation.Horizontal, + state = rememberDraggableState { delta -> + dragOffset = (dragOffset + delta).coerceIn( + 0f, + with(density) { (buttonWidth * (buttonCount - 1)).toPx() } + ) + }, + onDragStarted = { isDragging.value = true }, + 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) + } + ) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(3.dp) + .background(selectedBackground, RoundedCornerShape(26.dp)) + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .zIndex(1f) + ) { + 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 + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp) + ) { + if (showOffListeningMode) { + Text( + text = stringResource(R.string.off), + style = TextStyle(fontSize = 12.sp, color = textColor), + 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) ) } - 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) - ) { - if (showOffListeningMode) { - Text( - text = stringResource(R.string.off), - style = TextStyle(fontSize = 12.sp, color = textColor), - 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) - ) - } + } + } +} + + +@Preview +@Composable +fun NoiseControlSettingsPreview() { + LibrePodsTheme( + m3eEnabled = true + ) { + Box( + modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer) + ) { + NoiseControlSettings( + showOffListeningMode = false, + noiseControlModeValue = 2, + onNoiseControlModeChanged = { } + ) } } } 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 a29ea725..d0df910e 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,40 +18,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.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 @Composable fun PressAndHoldSettings( - navController: NavController, leftAction: StemAction, - rightAction: StemAction + rightAction: StemAction, + navigateToLeftLongPress: () -> Unit, + navigateToRightLongPress: () -> Unit ) { - 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 +41,19 @@ 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)) + + StyledList( + title = stringResource(R.string.press_and_hold_airpods) ) { - NavigationButton( - to = "long_press/Left", + StyledListItem( name = stringResource(R.string.left), - navController = navController, - independent = false, - currentState = leftActionText, + description = leftActionText, + onClick = navigateToLeftLongPress ) - HorizontalDivider( - thickness = 1.dp, - color = dividerColor, - modifier = Modifier - .padding(horizontal = 16.dp) - ) - NavigationButton( - to = "long_press/Right", + StyledListItem( name = stringResource(R.string.right), - navController = navController, - independent = false, - currentState = rightActionText, + description = rightActionText, + onClick = navigateToRightLongPress, ) } } 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 14b30f3f..aeafa332 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 @@ -32,6 +32,11 @@ 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.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -61,6 +66,8 @@ 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.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.utils.inspectDragGestures import kotlin.math.abs import kotlin.math.atan2 @@ -68,6 +75,13 @@ import kotlin.math.cos import kotlin.math.sin import kotlin.math.tanh +enum class MaterialButtonStyle { + Tonal, + Normal, + Outlined, + Filled +} + @Composable fun StyledButton( onClick: () -> Unit, @@ -78,20 +92,58 @@ fun StyledButton( surfaceColor: Color = Color.Unspecified, maxScale: Float = 0.1f, enabled: Boolean = true, + materialButtonStyle: MaterialButtonStyle = MaterialButtonStyle.Tonal, // picking tonal because most usages assume a transparent/gray background, tonal will give a slightly less vibrant background 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) } + when (LocalDesignSystem.current) { + DesignSystem.Material -> { + when (materialButtonStyle) { + MaterialButtonStyle.Filled -> { + Button( + modifier = modifier.height(48.dp), + onClick = onClick, + content = content + ) + } + MaterialButtonStyle.Tonal -> { + FilledTonalButton( + modifier = modifier.height(48.dp), + onClick = onClick, + content = content, + colors = ButtonDefaults.filledTonalButtonColors(containerColor = surfaceColor) + ) + } - val interactiveHighlightShader = remember { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - RuntimeShader( - """ + MaterialButtonStyle.Outlined -> { + OutlinedButton( + modifier = modifier.height(48.dp), + onClick = onClick, + content = content + ) + } + + MaterialButtonStyle.Normal -> { + TextButton( + modifier = modifier.height(48.dp), + onClick = onClick, + content = content + ) + } + } + } + DesignSystem.Apple -> { + 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 interactiveHighlightShader = remember { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + RuntimeShader( + """ uniform float2 size; layout(color) uniform half4 color; uniform float radius; @@ -103,238 +155,250 @@ half4 main(float2 coord) { 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 && enabled) { - 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 + 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 && enabled) { + drawRect(Color.Black.copy(alpha = 0.4f)) + drawRect(Color.White.copy(alpha = 0.2f)) + } + } + }, + onDrawFront = null, + highlight = { Highlight.Ambient.copy(alpha = 0f) } ) - }, - layerBlock = { - val width = size.width - val height = size.height + } 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 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 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) - ) + 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)) } - 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 = { - if (enabled) { - scope.launch { - launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) } - launch { - progressAnimation.animateTo( - 0f, - progressAnimationSpec - ) + 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) } - launch { - offsetAnimation.animateTo( - Offset.Zero, - offsetAnimationSpec - ) - } - } - } - } - inspectDragGestures( - onDragStart = { down -> - pressStartPosition = down.position - if (enabled) { - scope.launch { - launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) } - launch { - progressAnimation.animateTo( - 1f, - progressAnimationSpec + }, + 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 ) } - launch { offsetAnimation.snapTo(Offset.Zero) } } } - }, - onDragEnd = { - onDragStop() - }, - onDragCancel = onDragStop - ) { _, dragAmount -> + ) + } + ) + .clickable( + interactionSource = null, + indication = null, + role = Role.Button, + onClick = { if (enabled) { - scope.launch { - if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback( - HapticFeedbackType.SegmentFrequentTick - ) - offsetAnimation.snapTo(offsetAnimation.value + dragAmount) - } + haptics.performHapticFeedback(HapticFeedbackType.ContextClick) + onClick() } } - } - } else { - Modifier.pointerInput(Unit) { - detectTapGestures( - onPress = { - isPressed = true - tryAwaitRelease() - isPressed = false - }, - onTap = { - 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 = { + if (enabled) { + scope.launch { + launch { + haptics.performHapticFeedback( + HapticFeedbackType.Reject + ) + } + launch { + progressAnimation.animateTo( + 0f, + progressAnimationSpec + ) + } + launch { + offsetAnimation.animateTo( + Offset.Zero, + offsetAnimationSpec + ) + } + } + } + } + inspectDragGestures( + onDragStart = { down -> + pressStartPosition = down.position + if (enabled) { + scope.launch { + launch { + haptics.performHapticFeedback( + HapticFeedbackType.SegmentFrequentTick + ) + } + launch { + progressAnimation.animateTo( + 1f, + progressAnimationSpec + ) + } + launch { offsetAnimation.snapTo(Offset.Zero) } + } + } + }, + onDragEnd = { + onDragStop() + }, + onDragCancel = onDragStop + ) { _, dragAmount -> + if (enabled) { + 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 ) - .height(48f.dp) - .padding(horizontal = 16f.dp), - horizontalArrangement = Arrangement.spacedBy(8f.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically, - content = content - ) + } + } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledDropdown.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledDropdown.kt deleted file mode 100644 index 4446f085..00000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledDropdown.kt +++ /dev/null @@ -1,259 +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.components - -import android.annotation.SuppressLint -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.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.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Checkbox -import androidx.compose.material3.CheckboxDefaults -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -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 -fun StyledDropdown( - expanded: Boolean, - onDismissRequest: () -> Unit, - options: List, - selectedOption: String, - touchOffset: Offset?, - boxPosition: Offset, - onOptionSelected: (String) -> Unit, - externalHoveredIndex: Int? = null, - externalDragActive: Boolean = false, - hazeState: HazeState, - @SuppressLint("ModifierParameter") modifier: Modifier = Modifier -) { - if (expanded) { - val relativeOffset = touchOffset?.let { it - boxPosition } ?: Offset.Zero - Popup( - offset = IntOffset(relativeOffset.x.toInt(), relativeOffset.y.toInt()), - onDismissRequest = onDismissRequest - ) { - AnimatedVisibility( - visible = true, - enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut() - ) { - Card( - modifier = modifier - .padding(8.dp) - .width(300.dp) - .background(Color.Transparent) - .clip(RoundedCornerShape(8.dp)), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) - ) { - var hoveredIndex by remember { mutableStateOf(null) } - val itemHeight = 48.dp - - val scope = rememberCoroutineScope() - val haptics = LocalHapticFeedback.current - - var popupSize by remember { mutableStateOf(IntSize(0, 0)) } - var lastDragPosition by remember { mutableStateOf(null) } - - LaunchedEffect(externalHoveredIndex, externalDragActive) { - if (externalDragActive) { - hoveredIndex = externalHoveredIndex - } - } - - Column( - modifier = Modifier - .onGloballyPositioned { coordinates -> - popupSize = coordinates.size - } - .pointerInput(popupSize) { - detectDragGestures( - onDragStart = { offset -> - hoveredIndex = (offset.y / itemHeight.toPx()).toInt() - lastDragPosition = offset - }, - onDrag = { change, _ -> - val y = change.position.y - val newHoveredIndex = (y / itemHeight.toPx()).toInt() - if (newHoveredIndex != hoveredIndex) { - scope.launch { haptics.performHapticFeedback( - HapticFeedbackType.SegmentTick) } - } - hoveredIndex = newHoveredIndex - lastDragPosition = change.position - }, - onDragEnd = { - val pos = lastDragPosition - val withinBounds = pos != null && - pos.x >= 0f && pos.y >= 0f && - pos.x <= popupSize.width.toFloat() && pos.y <= popupSize.height.toFloat() - - if (withinBounds) { - hoveredIndex?.let { idx -> - if (idx in options.indices) { - scope.launch { haptics.performHapticFeedback( - HapticFeedbackType.GestureEnd) } - onOptionSelected(options[idx]) - } - } - onDismissRequest() - } else { - hoveredIndex = null - } - } - ) - } - ) { - options.forEachIndexed { index, text -> - val isHovered = - if (externalDragActive && externalHoveredIndex != null) { - index == externalHoveredIndex - } else { - index == hoveredIndex - } - val isSystemInDarkTheme = isSystemInDarkTheme() - Box( - modifier = Modifier - .fillMaxWidth() - .height(itemHeight) - .background( - Color.Transparent - ) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { - scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) } - 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), - contentAlignment = Alignment.CenterStart - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - 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)) - ) - ) - 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 (index != options.lastIndex) { - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier.padding(start = 12.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 ca50300f..ecd3765c 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 @@ -34,6 +34,10 @@ 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.FilledIconButton +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedIconButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -81,6 +85,8 @@ import com.kyant.backdrop.highlight.Highlight import com.kyant.backdrop.shadow.InnerShadow import kotlinx.coroutines.launch import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.utils.inspectDragGestures import kotlin.math.abs import kotlin.math.atan2 @@ -96,25 +102,92 @@ fun StyledIconButton( surfaceColor: Color = Color.Unspecified, backdrop: LayerBackdrop = rememberLayerBackdrop(), onClick: () -> Unit, - enabled: Boolean = true + enabled: Boolean = true, + materialButtonStyle: MaterialButtonStyle = MaterialButtonStyle.Normal ) { - 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 + when (LocalDesignSystem.current) { + DesignSystem.Material -> { + when (materialButtonStyle) { + MaterialButtonStyle.Tonal -> { + FilledTonalIconButton( + onClick = onClick, + enabled = enabled, + modifier = Modifier.size(52.dp) + ) { + Text( + text = icon, + style = TextStyle( + fontSize = 20.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + } + MaterialButtonStyle.Filled -> { + FilledIconButton( + onClick = onClick, + enabled = enabled, + modifier = Modifier.size(52.dp) + ) { + Text( + text = icon, + style = TextStyle( + fontSize = 20.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + } + MaterialButtonStyle.Outlined -> { + OutlinedIconButton( + onClick = onClick, + enabled = enabled, + modifier = Modifier.size(52.dp) + ) { + Text( + text = icon, + style = TextStyle( + fontSize = 20.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + } + MaterialButtonStyle.Normal -> { + IconButton( + onClick = onClick, + enabled = enabled, + modifier = Modifier.size(52.dp) + ) { + Text( + text = icon, + style = TextStyle( + fontSize = 20.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ) + ) + } + } + } + } + DesignSystem.Apple -> { + 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 interactiveHighlightShader = remember { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - RuntimeShader( - """ + val interactiveHighlightShader = remember { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + RuntimeShader( + """ uniform float2 size; layout(color) uniform half4 color; uniform float radius; @@ -126,203 +199,222 @@ half4 main(float2 coord) { float intensity = smoothstep(radius, radius * 0.5, dist); return color * intensity; }""" - ) - } else { - null - } - } - val isDarkTheme = isSystemInDarkTheme() - TextButton( - onClick = { - if (enabled) { - scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) } - onClick() + ) + } else { + null + } } - }, - 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) }, - innerShadow = { - if (isDarkTheme) { - InnerShadow( - radius = 0.5.dp, - offset = DpOffset(1.dp, 1.dp), - color = Color.White.copy(0.6f), - ) - } else InnerShadow() - }, - 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) - } - - if (!isDarkTheme) { - drawOutline( - outline = outline, - color = Color.Black.copy(0.4f), - style = Stroke(1f), - ) - } - - 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 = { + val isDarkTheme = isSystemInDarkTheme() + TextButton( + onClick = { if (enabled) { - scope.launch { - launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) } - launch { progressAnimation.animateTo(0f, progressAnimationSpec) } - launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) } - } + scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) } + onClick() } - } - 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) } + }, + 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) }, + innerShadow = { + if (isDarkTheme) { + InnerShadow( + radius = 0.5.dp, + offset = DpOffset(1.dp, 1.dp), + color = Color.White.copy(0.6f), + ) + } else InnerShadow() + }, + 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) + } + + if (!isDarkTheme) { + drawOutline( + outline = outline, + color = Color.Black.copy(0.4f), + style = Stroke(1f), + ) + } + + 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 + ) + } + } } } - }, - onDragEnd = { onDragStop() }, - onDragCancel = onDragStop - ) { _, dragAmount -> - scope.launch { - if (enabled) { - if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback( - HapticFeedbackType.SegmentFrequentTick - ) - offsetAnimation.snapTo(offsetAnimation.value + dragAmount) + 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() }), + ) { + 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)) + ) + ) } - .size(with(density) { 48.sp.toDp() }), - ) { - 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)) - ) - ) + } } } @@ -330,7 +422,13 @@ half4 main(float2 coord) { @Preview(uiMode = UI_MODE_NIGHT_YES, name = "Dark") @Composable fun StyledIconButtonPreview() { - Box(modifier = Modifier.height(120.dp).width(200.dp).background(if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7), RoundedCornerShape(28.dp)), contentAlignment = Alignment.Center) { + Box(modifier = Modifier + .height(120.dp) + .width(200.dp) + .background( + if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7), + RoundedCornerShape(28.dp) + ), contentAlignment = Alignment.Center) { StyledIconButton( icon = "􀍟", onClick = { } 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 86eae6a6..fa99e6cd 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,6 +1,5 @@ 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 @@ -21,7 +20,9 @@ import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.clearText import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -39,6 +40,8 @@ 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.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem @Composable @@ -46,109 +49,130 @@ fun StyledInputField( inputState: TextFieldState, focusRequester: FocusRequester, 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 spacerHeight by animateDpAsState( - targetValue = if (hasText) with(density) { 32.sp.toDp() } else 0.dp, - label = "labelSpacer" - ) + singleLine: Boolean = true, + forceApple: Boolean = false +) { + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material && !forceApple - val transition = updateTransition(hasText, label = "floating") - val yOffset by transition.animateDp(label = "y") { - if (it) with (density) { (-48).sp.toDp() } else 0.dp - } - - Spacer(modifier = Modifier.height(spacerHeight)) - - Box( - modifier = Modifier.fillMaxWidth() - ) { - Row( - verticalAlignment = verticalAlignment, + if(m3eEnabled) { + TextField( + state = inputState, + placeholder = { + Text( + text = placeholder, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f) + ) + }, + lineLimits = if (singleLine) TextFieldLineLimits.SingleLine else TextFieldLineLimits.Default, modifier = Modifier .fillMaxWidth() - .heightIn(min = minHeight) - .background( - backgroundColor, - RoundedCornerShape(28.dp) - ) - .padding(horizontal = 16.dp, vertical = 8.dp) - .pointerInput(Unit) { - detectTapGestures { - focusRequester.requestFocus() - } - } - ) { - 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( - modifier = Modifier - .weight(1f) - ) { - Box( - modifier = Modifier - .weight(1f), - contentAlignment = if (singleLine) Alignment.CenterStart else Alignment.TopStart - ) { - Text( - text = placeholder, - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Light, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor.copy(alpha = 0.8f) - ), - modifier = Modifier - .offset(y = yOffset) - ) + .padding(horizontal = 16.dp) + ) + } + else { + 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 spacerHeight by animateDpAsState( + targetValue = if (hasText) with(density) { 32.sp.toDp() } else 0.dp, + label = "labelSpacer" + ) - innerTextField() - } - } - if (singleLine && !inputState.text.isEmpty()) { - IconButton( - onClick = { - inputState.clearText() - } - ) { - 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 - ) - ), - ) - } - } - } - }, + val transition = updateTransition(hasText, label = "floating") + val yOffset by transition.animateDp(label = "y") { + if (it) with(density) { (-48).sp.toDp() } else 0.dp + } + + Spacer(modifier = Modifier.height(spacerHeight)) + + Box( + modifier = Modifier.fillMaxWidth() + ) { + Row( + verticalAlignment = verticalAlignment, modifier = Modifier .fillMaxWidth() - .padding(start = 8.dp) - .focusRequester(focusRequester) - ) + .heightIn(min = minHeight) + .background( + backgroundColor, + RoundedCornerShape(28.dp) + ) + .padding(horizontal = 16.dp, vertical = 8.dp) + .pointerInput(Unit) { + detectTapGestures { + focusRequester.requestFocus() + } + } + ) { + 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( + modifier = Modifier + .weight(1f) + ) { + Box( + modifier = Modifier + .weight(1f), + contentAlignment = if (singleLine) Alignment.CenterStart else Alignment.TopStart + ) { + Text( + text = placeholder, + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Light, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = textColor.copy(alpha = 0.8f) + ), + modifier = Modifier + .offset(y = yOffset) + ) + + innerTextField() + } + } + if (singleLine && !inputState.text.isEmpty()) { + IconButton( + onClick = { + inputState.clearText() + } + ) { + 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) + ) + } } } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledList.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledList.kt new file mode 100644 index 00000000..1edcd893 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledList.kt @@ -0,0 +1,132 @@ +package me.kavishdevar.librepods.presentation.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +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.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_YES +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.Wallpapers.GREEN_DOMINATED_EXAMPLE +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.theme.sectionHeader + +@Composable +fun StyledList( + modifier: Modifier = Modifier, + title: String? = null, + description: String? = null, + content: @Composable StyledListScope.() -> Unit +) { + val scope = StyledListScope() + scope.content() + + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + + Column (modifier = modifier) { + title?.let { + Box( + modifier = Modifier + .background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 16.dp) + .padding(top = 4.dp, bottom = if (m3eEnabled) 12.dp else 4.dp) + ) { + Text( + text = it, + color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader, + style = MaterialTheme.typography.labelSmallEmphasized + ) + } + } + Column( + modifier = Modifier + .fillMaxWidth() + .background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surface, RoundedCornerShape(if (m3eEnabled) 24.dp else 28.dp)) + .clip(RoundedCornerShape(if (m3eEnabled) 24.dp else 28.dp)) + ) { + if (m3eEnabled && description != null) { + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onBackground.copy(0.8f), + modifier = Modifier.padding(horizontal = 16.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + } + scope.items.forEachIndexed { index, item -> + item(index, scope.items.size) + } + Spacer(modifier = Modifier.height(if(m3eEnabled) 4.dp else 0.dp)) + } + } + if (!m3eEnabled && description != null) { + Text( + text = description, + style = MaterialTheme.typography.bodySmallEmphasized, + color = MaterialTheme.colorScheme.onBackground.copy(0.6f), + modifier = Modifier.padding(horizontal = 16.dp) + ) + Spacer(modifier = Modifier.height(4.dp)) + } +} + +class StyledListScope { + internal val items = + mutableListOf<@Composable (Int, Int) -> Unit>() + + fun item( + content: @Composable (index: Int, count: Int) -> Unit + ) { + items += content + } +} + +@Preview(showBackground = true, wallpaper = GREEN_DOMINATED_EXAMPLE, uiMode = UI_MODE_NIGHT_YES) +@Composable +fun StyledListDemo() { + LibrePodsTheme( + m3eEnabled = true + ) { + val backgroundC = MaterialTheme.colorScheme.background + StyledScaffold( + title = "${backgroundC.red}, ${backgroundC.green}, ${backgroundC.blue}" + ) { + Column ( + modifier = Modifier.padding(horizontal = 12.dp) + ) { + Spacer(modifier = Modifier.height(56.dp)) + StyledList( + title = "hello" + ) { + for (i in 0..2) { + StyledListItem( + name = i.toString(), + onClick = {} + ) + } + val checked = remember { mutableStateOf(false) } + StyledToggle( + label = "Test", + description = "Lorem ipsum dolor sit amet, consectetur adipiscing elit mollit anim id est laborum.", + checked = checked.value, + onCheckedChange = { checked.value = it }, + ) + } + } + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledListItem.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledListItem.kt new file mode 100644 index 00000000..7a92106e --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledListItem.kt @@ -0,0 +1,409 @@ +/* + 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.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.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.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +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.material.icons.filled.Check +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedListItem +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +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.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.launch +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.theme.sectionHeader + +@Composable +fun StyledListItem( + modifier: Modifier = Modifier, + title: String? = null, + name: String, + onClick: (() -> Unit)?, + description: String? = null, + height: Dp = 58.dp, + enabled: Boolean = true, + orientation: ListItemOrientation = ListItemOrientation.Horizontal, + leadingContent: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null +) { + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + Column { + title?.let { + Box( + modifier = Modifier + .background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 16.dp) + .padding(top = 4.dp, bottom = if (m3eEnabled) 8.dp else 4.dp) + ) { + Text( + text = it, + color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader, + style = MaterialTheme.typography.labelSmallEmphasized + ) + } + } + Column( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .background( + if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surface, + RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp) + ) + .clip(RoundedCornerShape(if (m3eEnabled) 16.dp else 28.dp)) + ) { + StyledListItemContent( + name = name, + onClick = onClick, + description = description, + height = height, + enabled = enabled, + index = 0, + count = 1, + orientation = orientation, + leadingContent = leadingContent, + trailingContent = trailingContent + ) + } + } +} + +@Composable +fun StyledListScope.StyledListItem( + modifier: Modifier = Modifier, + name: String, + onClick: (() -> Unit)? = null, + description: String? = null, + enabled: Boolean = onClick != null, + orientation: ListItemOrientation = ListItemOrientation.Horizontal, + selected: Boolean? = null, + leadingContent: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null +) { + item { index, count -> + StyledListItemContent( + name = name, + onClick = onClick, + description = description, + enabled = enabled, + index = index, + count = count, + orientation = orientation, + modifier = modifier, + selected = selected, + leadingContent = leadingContent, + trailingContent = trailingContent + ) + } +} + +enum class ListItemOrientation{ + Horizontal, + Vertical +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun StyledListItemContent( + modifier: Modifier = Modifier, + name: String, + onClick: (() -> Unit)?, + description: String? = null, + height: Dp = 58.dp, + enabled: Boolean = true, + index: Int, + count: Int, + orientation: ListItemOrientation = ListItemOrientation.Horizontal, + selected: Boolean? = null, + leadingContent: (@Composable () -> Unit)? = null, + trailingContent: (@Composable () -> Unit)? = null +) { + val isDarkTheme = isSystemInDarkTheme() + val surfaceColor = MaterialTheme.colorScheme.surface + val surfaceDimColor = MaterialTheme.colorScheme.surfaceDim + var backgroundColor by remember { mutableStateOf(surfaceColor) } + val animatedBackgroundColor by animateColorAsState(targetValue = backgroundColor, animationSpec = tween(durationMillis = 500)) + val haptics = LocalHapticFeedback.current + val scope = rememberCoroutineScope() + + when (LocalDesignSystem.current) { + DesignSystem.Apple -> { + val trailingContentDefault: @Composable () -> Unit = { + if (trailingContent == null) { + if (onClick != null) { + if (selected != null) { + val floatAnimateState by animateFloatAsState( + targetValue = if (selected) 1f else 0f, + animationSpec = tween(durationMillis = 300) + ) + + Text( + text = "􀆅", + style = TextStyle( + fontSize = 20.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = MaterialTheme.colorScheme.primary.copy(alpha = floatAnimateState), + ), + modifier = Modifier.padding(end = 4.dp) + ) + } else { + Text( + text = "􀯻", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(0.6f), + modifier = Modifier + .padding(start = if (description != null) 6.dp else 0.dp) + ) + } + } + } else { + trailingContent() + } + } + Column ( + modifier = Modifier + .background( + animatedBackgroundColor, + when { + (index == 0 && count == 1) -> { + RoundedCornerShape(28.dp) + } + + (index == 0) -> { + RoundedCornerShape( + topStart = 28.dp, + topEnd = 28.dp, + bottomStart = 0.dp, + bottomEnd = 0.dp + ) + } + + (index + 1 == count) -> { + RoundedCornerShape( + topStart = 0.dp, + topEnd = 0.dp, + bottomStart = 28.dp, + bottomEnd = 28.dp + ) + } + + else -> { + RectangleShape + } + } + ) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + if (enabled) { + backgroundColor = surfaceDimColor + tryAwaitRelease() + backgroundColor = surfaceColor + } + }, + onTap = { + if (enabled) { + scope.launch { + haptics.performHapticFeedback( + HapticFeedbackType.ContextClick + ) + } + onClick?.invoke() + } + } + ) + } + .heightIn(min = height) + .padding(horizontal = 16.dp) + ) { + Row( + modifier = Modifier + .heightIn(min = height) + .padding(vertical = if (orientation == ListItemOrientation.Vertical) 12.dp else 0.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (leadingContent != null) { + leadingContent() + Spacer(modifier = Modifier.width(12.dp)) + } + Column (verticalArrangement = Arrangement.Center) { + Text( + text = name, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + if (description != null && orientation == ListItemOrientation.Vertical) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(if (isDarkTheme) 0.6f else 0.8f), // TODO: move to color scheme + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + + if (orientation == ListItemOrientation.Horizontal && description != null) { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface.copy(if (isDarkTheme) 0.6f else 0.8f) // TODO: move to color scheme + ) + } + + trailingContentDefault() + } + if (index+1 != count) { + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier + .padding(start = if (leadingContent != null) 12.dp else 0.dp) + ) + } + } + } + + DesignSystem.Material -> { + val defaultShape = when { + count == 1 -> RoundedCornerShape(24.dp) + + index == 0 -> RoundedCornerShape( + topStart = 24.dp, + topEnd = 24.dp, + bottomStart = 8.dp, + bottomEnd = 8.dp + ) + + index == count - 1 -> RoundedCornerShape( + topStart = 8.dp, + topEnd = 8.dp, + bottomStart = 24.dp, + bottomEnd = 24.dp + ) + + else -> RoundedCornerShape(8.dp) + } + Column { + SegmentedListItem( + modifier = modifier.heightIn(min = 64.dp), + shapes = ListItemDefaults.shapes().copy( + shape = defaultShape, + pressedShape = RoundedCornerShape(24.dp), + selectedShape = RoundedCornerShape(24.dp), + hoveredShape = RoundedCornerShape(24.dp), + ), + onClick = onClick ?: {}, + leadingContent = leadingContent, + trailingContent = { + if (trailingContent == null) { + if (onClick != null) { + if (selected == true) { + Icon(Icons.Default.Check, contentDescription = null) + } else if (selected == null) { + Icon( + Icons.AutoMirrored.Default.KeyboardArrowRight, + contentDescription = null + ) + } + } + } else { + trailingContent() + } + }, + supportingContent = { + if (description != null) Text( + description, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(bottom = 4.dp) + ) + }, + content = { + Text( + text = name, + style = MaterialTheme.typography.labelMediumEmphasized, + modifier = Modifier.padding( + top = 4.dp, + bottom = if (description != null) 0.dp else 4.dp + ) + ) + }, + verticalAlignment = Alignment.CenterVertically, + colors = if (onClick == null) { + ListItemDefaults.segmentedColors().run { + copy( + disabledContentColor = contentColor, + disabledSupportingContentColor = supportingContentColor, + disabledTrailingContentColor = trailingContentColor + ) + } + } else ListItemDefaults.segmentedColors(), + enabled = onClick != null && enabled, + selected = selected ?: false + ) + if (index+1 != count) { + Spacer(modifier = Modifier.height(2.dp)) + } + } + } + } +} 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 0175ad9f..656dd1e0 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,6 +18,15 @@ package me.kavishdevar.librepods.presentation.components +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +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.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -27,11 +36,23 @@ 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.layout.width import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +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.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -45,123 +66,230 @@ 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.text.style.TextOverflow 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.HazeState import dev.chrisbanes.haze.HazeTint import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +@OptIn(ExperimentalMaterial3Api::class) @Composable fun StyledScaffold( + modifier: Modifier = Modifier, + visible: Boolean = true, title: String, - actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(), - snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, - content: @Composable (spacerValue: Dp, hazeState: HazeState, bottomPadding: Dp) -> Unit -) { - val isDarkTheme = isSystemInDarkTheme() - val hazeState = rememberHazeState(blurEnabled = true) - - Scaffold( - containerColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7), - 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) - - Box( - modifier = Modifier - .fillMaxSize() - .padding(start = startPadding, end = endPadding) - ) { - val backdrop = rememberLayerBackdrop() - Box( - modifier = Modifier - .zIndex(2f) - .height(64.dp + topPadding) - .fillMaxWidth() - .layerBackdrop(backdrop) - .hazeEffect( - state = hazeState - ) { - backgroundColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7) - tints = listOf(HazeTint( - if (isDarkTheme) Color.Black.copy(0.55f) else Color(0xFFF2F2F7).copy(alpha = 0.85f) - )) - blurRadius = 6.dp - } - ) { - Column(modifier = Modifier.fillMaxSize()) { - Spacer(modifier = Modifier.height(topPadding + 12.dp)) - Text( - text = title, - style = TextStyle( - fontSize = 20.sp, - fontWeight = FontWeight.SemiBold, - 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) - } - } -} - - -@Composable -fun StyledScaffold( - title: String, + showBackButton: Boolean = false, + onNavigateBack: () -> Unit = {}, actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(), snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, content: @Composable () -> Unit ) { - StyledScaffold( - title = title, - actionButtons = actionButtons, - snackbarHostState = snackbarHostState, - ) { _, _, _-> - content() - } -} + val isDarkTheme = isSystemInDarkTheme() + val hazeState = rememberHazeState(blurEnabled = true) -@Composable -fun StyledScaffold( - title: String, - actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(), - snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, - content: @Composable (spacerValue: Dp) -> Unit -) { - StyledScaffold( - title = title, - actionButtons = actionButtons, - snackbarHostState = snackbarHostState, - ) { spacerValue, _, _ -> - content(spacerValue) + when (LocalDesignSystem.current) { + DesignSystem.Material -> { + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + AnimatedVisibility( + visible = visible, + enter = fadeIn() + slideInVertically(initialOffsetY = { -it }), + exit = fadeOut() + slideOutVertically(targetOffsetY = { -it }) + ) { + TopAppBar( + navigationIcon = { + if (showBackButton) { + Row { + Spacer(modifier = Modifier.width(12.dp)) + FilledTonalIconButton( + onClick = onNavigateBack, + modifier = Modifier + .minimumInteractiveComponentSize() + .size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Narrow)), + shape = IconButtonDefaults.mediumRoundShape + ) { + Icon( + Icons.AutoMirrored.Default.ArrowBack, + contentDescription = "", + modifier = Modifier.size(IconButtonDefaults.mediumIconSize), + ) + } + } + } + }, + title = { + Crossfade(targetState = title) { + Text( + text = it, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = if (showBackButton) 8.dp else 12.dp, end = 12.dp), + style = MaterialTheme.typography.titleSmall + ) + } + }, + actions = { + actionButtons.forEach { actionButton -> + actionButton(rememberLayerBackdrop()) + } + Spacer(modifier = Modifier.width(12.dp)) + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainer) + ) + } + }, + ) { paddingValues -> + Box( + modifier = modifier + .then(if (visible) Modifier.padding(paddingValues) else Modifier) + .fillMaxSize() + .hazeSource(hazeState) + ) { + content() + } + } + } + DesignSystem.Apple -> { + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + 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 startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current) + val endPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current) + + Box( + modifier = Modifier + .fillMaxSize() + .padding(start = startPadding, end = endPadding) + ) { + val backdrop = rememberLayerBackdrop() + val bgColor = MaterialTheme.colorScheme.surfaceContainer + AnimatedVisibility( + visible = showBackButton, + enter = fadeIn() + scaleIn( + initialScale = 0f, + animationSpec = tween() + ), + exit = fadeOut() + scaleOut( + targetScale = 0.5f, + animationSpec = tween(100) + ), + modifier = Modifier + .zIndex(3f) + .padding(top = topPadding, start = 8.dp) + .align(Alignment.TopStart) + ) { + StyledIconButton( + onClick = onNavigateBack, + icon = "􀯶", + backdrop = backdrop + ) + } + + AnimatedVisibility( + visible = visible, + enter = fadeIn() + scaleIn( + initialScale = 0f, + animationSpec = tween() + ), + exit = fadeOut() + scaleOut( + targetScale = 0.5f, + animationSpec = tween(100) + ), + modifier = Modifier + .zIndex(2f) + .height(64.dp + topPadding) + .fillMaxWidth() + .layerBackdrop(backdrop) + ){ + Box( + modifier = Modifier.hazeEffect( + state = hazeState, + ) { + backgroundColor = bgColor + tints = listOf( + HazeTint( + if (isDarkTheme) Color.Black.copy(0.55f) else Color( + 0xFFF2F2F7 + ).copy(alpha = 0.85f) + ) + ) + blurRadius = 6.dp + } + ) { + + Column(modifier = Modifier.fillMaxSize()) { + Spacer(modifier = Modifier.height(topPadding + 12.dp)) + Crossfade(targetState = title) { + Text( + text = it, + style = TextStyle( + fontSize = 20.sp, + fontWeight = FontWeight.SemiBold, + color = if (isDarkTheme) Color.White else Color.Black, + fontFamily = FontFamily(Font(R.font.sf_pro)) + ), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + } + } + } + + AnimatedVisibility( + visible = visible && actionButtons.isNotEmpty(), + enter = fadeIn() + scaleIn( + initialScale = 0f, + animationSpec = tween() + ), + exit = fadeOut() + scaleOut( + targetScale = 0.5f, + animationSpec = tween(100) + ), + modifier = Modifier + .zIndex(3f) + .padding(top = topPadding, end = 8.dp) + .align(Alignment.TopEnd) + ) { + Row{ + actionButtons.forEach { actionButton -> + actionButton(backdrop) + } + } + } + + Box( + modifier = modifier + .hazeSource(hazeState) + .fillMaxSize() + ) { + 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 deleted file mode 100644 index b9f6c2f7..00000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/components/StyledSelectList.kt +++ /dev/null @@ -1,188 +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.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.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -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.wrapContentWidth -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -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.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, - val description: String? = null, - val iconRes: Int? = null, - val selected: Boolean, - val onClick: () -> Unit, - val visible: Boolean = true, - val enabled: Boolean = true -) - -@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 - ) { - 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 - - 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)) - - 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( - modifier = Modifier - .weight(1f) - .padding(vertical = 2.dp) - .padding(start = if (hasIcon) 8.dp else 4.dp) - ) { - 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)), - ) - } - } - 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) { - 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) - ) - } - } - } - } -} 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 58c9b571..5ce29a1c 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 @@ -34,14 +34,18 @@ 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.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf @@ -71,6 +75,7 @@ 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.tooling.preview.Wallpapers.GREEN_DOMINATED_EXAMPLE import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -90,6 +95,9 @@ import com.kyant.backdrop.shadow.Shadow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.utils.inspectDragGestures import kotlin.math.abs import kotlin.math.roundToInt @@ -221,363 +229,530 @@ fun StyledSlider( endLabel: String? = null, independent: Boolean = false, description: String? = null, - enabled: Boolean = true + enabled: Boolean = true, + index: Int = 0, + count: Int = 1 ) { - 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 + when (LocalDesignSystem.current) { + DesignSystem.Material -> { + val defaultShape = when { + count == 1 -> RoundedCornerShape(24.dp) - val fraction by derivedStateOf { - ((value - valueRange.start) / (valueRange.endInclusive - valueRange.start)) - .fastCoerceIn(0f, 1f) - } + index == 0 -> RoundedCornerShape( + topStart = 24.dp, + topEnd = 24.dp, + bottomStart = 8.dp, + bottomEnd = 8.dp + ) - 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) } + index == count - 1 -> RoundedCornerShape( + topStart = 8.dp, + topEnd = 8.dp, + bottomStart = 24.dp, + bottomEnd = 24.dp + ) - val momentumAnimation = rememberMomentumAnimation(maxScale = 1.5f) + else -> RoundedCornerShape(8.dp) + } - 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( + Column { + label?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelSmallEmphasized, 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) + .padding(horizontal = 16.dp) + .padding(top = 4.dp, bottom = 12.dp) + ) + } + SegmentedListItem( + shapes = ListItemDefaults.shapes().copy( + shape = defaultShape, + pressedShape = RoundedCornerShape(24.dp), + selectedShape = RoundedCornerShape(24.dp), + hoveredShape = RoundedCornerShape(24.dp), + ), + onClick = {}, + enabled = enabled, + modifier = Modifier.heightIn(min = 58.dp), + content = { + Column( + modifier = Modifier.fillMaxWidth() ) { - if (startIcon != null) { + description?.let { 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() - } + text = it, + style = MaterialTheme.typography.bodyMedium ) } - 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() + if (startLabel != null || endLabel != null) { + Row( + modifier = 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 - ) + startLabel?.let { + Text( + text = it, + style = MaterialTheme.typography.labelSmall + ) + } + + Spacer(Modifier.weight(1f)) + + endLabel?.let { + Text( + text = it, + style = MaterialTheme.typography.labelSmall ) } } } } + }, + supportingContent = { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + + startIcon?.let { + Text(it, fontFamily = FontFamily(Font(R.font.sf_pro))) + Spacer(Modifier.width(12.dp)) + } + + Slider( + modifier = Modifier.weight(1f), + value = value, + onValueChange = { newValue -> + val snapped = + if (snapPoints.isNotEmpty()) { + snapIfClose( + newValue, + snapPoints, + snapThreshold + ) + } else { + newValue + } + + onValueChange(snapped) + }, + valueRange = valueRange, + enabled = enabled + ) + + endIcon?.let { + Spacer(Modifier.width(12.dp)) + Text(it, fontFamily = FontFamily(Font(R.font.sf_pro))) + } + } + } } + ) + + if (index + 1 != count) { + Spacer( + modifier = Modifier.height(2.dp) + ) + } + } + } + + DesignSystem.Apple -> { + val backgroundColor = + if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + val isDarkTheme = isSystemInDarkTheme() + val trackColor = + if (isDarkTheme) Color(0xFF787880).copy(0.36f) + else Color(0xFF787878).copy(0.2f) + val accentColor = + if (enabled) { + if (isDarkTheme) Color(0xFF0091FF) + else Color(0xFF0088FF) + } else { + trackColor + } + val labelTextColor = if (isDarkTheme) Color.White else Color.Black + + 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) { 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.. - 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.., threshold: Float = 0. return if (abs(nearest - value) <= threshold) nearest else value } -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, wallpaper = GREEN_DOMINATED_EXAMPLE) @Composable fun StyledSliderPreview() { val a = remember { mutableFloatStateOf(0.5f) } - Box( - Modifier - .background(if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF0F0F0)) - .padding(16.dp) - .fillMaxSize() + LibrePodsTheme( + m3eEnabled = true ) { - 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 - ) + StyledScaffold( + title = "test", + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.height(72.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( + label = "Small label", + description = "This is a somewhat long descriptionRes", + value = a.floatValue, + onValueChange = { + a.floatValue = it + }, + valueRange = 0f..2f, + snapPoints = listOf(1f), + snapThreshold = 0.1f, + independent = true, + startIcon = "A", + endIcon = "B", + ) + } } } } 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 4028dc4a..0d115d8f 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 @@ -20,8 +20,6 @@ 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 @@ -33,8 +31,15 @@ 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.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedListItem +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -42,9 +47,9 @@ 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.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput @@ -52,12 +57,15 @@ 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.sp import kotlinx.coroutines.launch import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.theme.sectionHeader import kotlin.io.encoding.ExperimentalEncodingApi @Composable @@ -66,9 +74,102 @@ fun StyledToggle( label: String, description: String? = null, checked: Boolean = false, - independent: Boolean = true, enabled: Boolean = true, onCheckedChange: (Boolean) -> Unit, + header: Boolean = false +) { + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + Column(modifier = Modifier.padding(vertical = 12.dp)) { + title?.let { + Box( + modifier = Modifier + .background(if (m3eEnabled) Color.Transparent else MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 16.dp) + .padding(top = 4.dp, bottom = if (m3eEnabled) 12.dp else 4.dp) + ) { + Text( + text = it, + color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader, + style = MaterialTheme.typography.labelSmallEmphasized + ) + } + } + Column( + modifier = Modifier + .fillMaxWidth() + .background( + if (m3eEnabled) if (header) MaterialTheme.colorScheme.primaryContainer else Color.Transparent else MaterialTheme.colorScheme.surface, + RoundedCornerShape(if (m3eEnabled) (if (header) 64.dp else 16.dp) else 28.dp) + ) + .clip(RoundedCornerShape(if (m3eEnabled) (if (header) 64.dp else 16.dp) else 28.dp)) + ) { + if (m3eEnabled) { + StyledToggleContent( + label = label, + description = description, + checked = checked, + enabled = enabled, + onCheckedChange = onCheckedChange, + index = 0, + count = 1, + header = header + ) + } else { + StyledToggleContent( + label = label, + checked = checked, + enabled = enabled, + onCheckedChange = onCheckedChange, + index = 0, + count = 1 + ) + } + } + if (description != null && !m3eEnabled) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = description, style = TextStyle( + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onBackground.copy(0.6f), + fontFamily = FontFamily(Font(R.font.sf_pro)), + ), modifier = Modifier.padding(horizontal = 16.dp) + ) + } + } +} + +@Composable +fun StyledListScope.StyledToggle( + label: String, + description: String? = null, + checked: Boolean = false, + enabled: Boolean = true, + onCheckedChange: (Boolean) -> Unit, +) { + item { index, count -> + StyledToggleContent( + label = label, + description = description, + checked = checked, + enabled = enabled, + onCheckedChange = onCheckedChange, + index = index, + count = count + ) + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun StyledToggleContent( + label: String, + description: String? = null, + checked: Boolean = false, + enabled: Boolean = true, + onCheckedChange: (Boolean) -> Unit, + index: Int, + count: Int, + header: Boolean = false ) { val currentChecked by rememberUpdatedState(checked) @@ -78,196 +179,209 @@ fun StyledToggle( val haptics = LocalHapticFeedback.current val scope = rememberCoroutineScope() - var backgroundColor by remember { - mutableStateOf( - if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) - ) - } + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material - val animatedBackgroundColor by animateColorAsState( - targetValue = backgroundColor, - animationSpec = tween(durationMillis = 500) - ) + if (m3eEnabled) { + val defaultShape = when { + count == 1 -> RoundedCornerShape(24.dp) - if (independent) { - Column(modifier = Modifier.padding(vertical = 8.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 + index == 0 -> RoundedCornerShape( + topStart = 24.dp, + topEnd = 24.dp, + bottomStart = 8.dp, + bottomEnd = 8.dp + ) + + index == count - 1 -> RoundedCornerShape( + topStart = 8.dp, + topEnd = 8.dp, + bottomStart = 24.dp, + bottomEnd = 24.dp + ) + + else -> RoundedCornerShape(8.dp) + } + Column { + SegmentedListItem( + shapes = ListItemDefaults.shapes().copy( + shape = defaultShape, + pressedShape = RoundedCornerShape(24.dp), + selectedShape = RoundedCornerShape(24.dp), + hoveredShape = RoundedCornerShape(24.dp), + ), + onClick = { onCheckedChange(!currentChecked) }, + trailingContent = { + Switch( + checked = currentChecked, + onCheckedChange = onCheckedChange, + modifier = Modifier.padding(end = if (header) 8.dp else 0.dp), + enabled = enabled ) - ) - } - - Box( - 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) - } - } + }, + supportingContent = description?.let { + { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .padding(top = if (header) 2.dp else 4.dp, bottom = if (header) 8.dp else 4.dp) + .padding(horizontal = if (header) 8.dp else 0.dp), + color = if (header && enabled) MaterialTheme.colorScheme.onPrimaryContainer else Color.Unspecified ) } - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(55.dp) - .padding(horizontal = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { + }, + content = { 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.labelMediumEmphasized, + modifier = Modifier + .padding( + top = if (header) 8.dp else 4.dp, + bottom = if (header) 2.dp else 4.dp + ) + .padding(horizontal = if (header) 8.dp else 0.dp), + color = if (header && enabled) MaterialTheme.colorScheme.onPrimaryContainer else Color.Unspecified ) - - StyledSwitch( - checked = checked, - enabled = enabled, - onCheckedChange = { - if (enabled) { - scope.launch { haptics.performHapticFeedback(if (it) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) } - 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)) - ) - ) - } + }, + enabled = enabled, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.heightIn(min = 64.dp), + colors = if (header) ListItemDefaults.segmentedColors(containerColor = MaterialTheme.colorScheme.primaryContainer) else ListItemDefaults.segmentedColors() + ) + if (index+1 != count) { + Spacer(modifier = Modifier.height(2.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) - } - }, - verticalAlignment = Alignment.CenterVertically - ) { - Column( + Column { + Row( modifier = Modifier - .weight(1f) - .padding(end = 4.dp) - ) { - Text( - text = label, - style = TextStyle( - fontSize = 16.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Normal, - color = textColor + .fillMaxWidth() + .background( + shape = RoundedCornerShape(28.dp), + color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent ) - ) - - 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)), + .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) + } + }, + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 4.dp) + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = textColor, ) - } - } - StyledSwitch( - checked = checked, - enabled = enabled, - onCheckedChange = { - if (enabled) { - onCheckedChange(it) + if (description != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = textColor.copy(0.8f) + ) } } + + StyledSwitch( + checked = checked, + enabled = enabled, + onCheckedChange = { + if (enabled) { + onCheckedChange(it) + } + } + ) + } + if (index+1 != count) { + HorizontalDivider( + thickness = 1.dp, + color = Color(0x40888888), + modifier = Modifier + .padding(start = 12.dp,end = 12.dp) + ) + } + } + } +} + +@Preview(name = "List", group = "Apple") +@Composable +fun StyledToggleAppleListPreview() { + val checked = remember { mutableStateOf(false) } + LibrePodsTheme(m3eEnabled = false) { + StyledList { + StyledToggle( + label = "Apple Styled List", + description = "This is an example description for the styled toggle.", + checked = checked.value, + onCheckedChange = { checked.value = !checked.value } ) } } } -@Preview +@Preview(name = "Normal", group = "Apple") @Composable -fun StyledTogglePreview() { +fun StyledToggleApplePreview() { val checked = remember { mutableStateOf(false) } - StyledToggle( - label = "Example Toggle", - description = "This is an example description for the styled toggle.", - checked = checked.value, - onCheckedChange = { checked.value = !checked.value } - ) + LibrePodsTheme(m3eEnabled = false) { + StyledToggle( + label = "Apple", + description = "This is an example description for the styled toggle.", + checked = checked.value, + onCheckedChange = { checked.value = !checked.value } + ) + } +} + +@Preview(name = "List", group = "Apple") +@Composable +fun StyledToggleM3EListPreview() { + val checked = remember { mutableStateOf(false) } + LibrePodsTheme(m3eEnabled = true) { + StyledList { + StyledToggle( + label = "Apple Styled List", +// description = "This is an example description for the styled toggle.", + checked = checked.value, + onCheckedChange = { checked.value = !checked.value } + ) + } + } +} + +@Preview(name = "Normal", group = "Material") +@Composable +fun StyledToggleM3EPreview() { + val checked = remember { mutableStateOf(false) } + LibrePodsTheme(m3eEnabled = true) { + StyledToggle( + label = "Material", + description = "This is an example description for the styled toggle.", + checked = checked.value, + onCheckedChange = { checked.value = !checked.value } + ) + } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt new file mode 100644 index 00000000..14479eb5 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/AppNavGraph.kt @@ -0,0 +1,324 @@ +package me.kavishdevar.librepods.presentation.navigation + +import androidx.activity.BackEventCompat.Companion.EDGE_LEFT +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.runtime.Composable +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.ui.NavDisplay +import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi +import me.kavishdevar.librepods.bluetooth.AACPManager +import me.kavishdevar.librepods.data.updates.updates +import me.kavishdevar.librepods.presentation.screens.AccessibilitySettingsScreen +import me.kavishdevar.librepods.presentation.screens.AdaptiveStrengthScreen +import me.kavishdevar.librepods.presentation.screens.AirPodsSettingsRoute +import me.kavishdevar.librepods.presentation.screens.AppSettingsScreen +import me.kavishdevar.librepods.presentation.screens.CallControlScreen +import me.kavishdevar.librepods.presentation.screens.EqualizerRoute +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.LoadingScreen +import me.kavishdevar.librepods.presentation.screens.LongPress +import me.kavishdevar.librepods.presentation.screens.MicrophoneSettingsRoute +import me.kavishdevar.librepods.presentation.screens.OpenSourceLicensesScreen +import me.kavishdevar.librepods.presentation.screens.PurchaseScreen +import me.kavishdevar.librepods.presentation.screens.ReleaseNotesScreen +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.UpdateHearingTestRoute +import me.kavishdevar.librepods.presentation.screens.VersionScreen +import me.kavishdevar.librepods.presentation.screens.onboarding.OnboardingScreen +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel +import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel +import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel + +@OptIn(ExperimentalHazeMaterialsApi::class) +@Composable +fun AppNavGraph( + showReleaseNotes: Boolean = false, + updatesShown: () -> Unit = {}, + showOnboarding: Boolean = false, + onboardingComplete: () -> Unit = {}, + backStack: SnapshotStateList, + airPodsViewModel: AirPodsViewModel, +) { + val navigate: (Screen) -> Unit = { screen -> + backStack.add(screen) + } + + fun navigateToPurchase() { + navigate(Screen.Purchase) + } + + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + + SharedTransitionLayout { + NavDisplay( + sharedTransitionScope = this, + backStack = backStack, + onBack = { + if (backStack.size > 1) { + backStack.removeAt(backStack.lastIndex) + } + }, + entryProvider = { screen -> + when (screen) { + Screen.Onboarding -> + NavEntry(screen) { + OnboardingScreen { + onboardingComplete() + if (showReleaseNotes) navigate(Screen.ReleaseNotes) else navigate(Screen.AirPodsSettings) + backStack.remove(screen) + } + } + Screen.AirPodsSettings -> + NavEntry(screen) { + if (!airPodsViewModel.isReady) LoadingScreen() + AirPodsSettingsRoute( + viewModel = airPodsViewModel, + navigateToRename = { navigate(Screen.Rename) }, + navigateToHearingProtection = { navigate(Screen.HearingProtection) }, + navigateToHearingAid = { navigate(Screen.HearingAid) }, + navigateToLeftLongPress = { + navigate( + Screen.LongPress("Left") + ) + }, + navigateToRightLongPress = { + navigate( + Screen.LongPress("Right") + ) + }, + navigateToPurchase = { navigate(Screen.Purchase) }, + navigateToAdaptiveStrength = { navigate(Screen.AdaptiveStrength) }, + navigateToEqualizer = { navigate(Screen.Equalizer) }, + navigateToHeadTracking = { navigate(Screen.HeadTracking) }, + navigateToAccessibility = { navigate(Screen.Accessibility) }, + navigateToVersion = { navigate(Screen.VersionInfo) }, + navigateToTroubleshooting = { navigate(Screen.Troubleshooting) }, + navigateToCallControlScreen = { navigate(Screen.CallControl(it)) }, + navigateToMicrophoneSettings = { navigate(Screen.MicrophoneSettings) }, + ) + } + + Screen.Rename -> + NavEntry(screen) { + if (!airPodsViewModel.isReady) LoadingScreen() + RenameScreen(airPodsViewModel) + } + + Screen.AppSettings -> + NavEntry(screen) { + val vm: AppSettingsViewModel = viewModel() + AppSettingsScreen( + viewModel = vm, + navigateToPurchase = ::navigateToPurchase, + navigateToTroubleshooting = { navigate(Screen.Troubleshooting) }, + navigateToOpenSourceLicenses = { navigate(Screen.OpenSourceLicenses) }, + navigateToReleaseNotesScreen = { navigate(Screen.ReleaseNotes) } + ) + } + + Screen.Troubleshooting -> + NavEntry(screen) { + TroubleshootingScreen() + } + + Screen.HeadTracking -> + NavEntry(screen) { + if (!airPodsViewModel.isReady) LoadingScreen() + HeadTrackingScreen(airPodsViewModel, ::navigateToPurchase) + } + + Screen.Accessibility -> + NavEntry(screen) { + if (!airPodsViewModel.isReady) LoadingScreen() + AccessibilitySettingsScreen( + viewModel = airPodsViewModel, + navigateToPurchase = ::navigateToPurchase, + navigateToTransparencyCustomization = { navigate(Screen.TransparencyCustomization) } + ) + } + + Screen.TransparencyCustomization -> + NavEntry(screen) { + if (!airPodsViewModel.isReady) LoadingScreen() + TransparencySettingsScreen(airPodsViewModel) + } + + Screen.HearingAid -> + NavEntry(screen) { + if (!airPodsViewModel.isReady) LoadingScreen() + HearingAidScreen( + viewModel = airPodsViewModel, + onNavigateHearingAidAdjustments = { navigate(Screen.HearingAidAdjustments) }, + onNavigateHearingTest = { navigate(Screen.UpdateHearingTest) }, + ) + } + + Screen.HearingAidAdjustments -> + NavEntry(screen) { + if (!airPodsViewModel.isReady) LoadingScreen() + HearingAidAdjustmentsScreen(airPodsViewModel) + } + + Screen.AdaptiveStrength -> + NavEntry(screen) { + if (!airPodsViewModel.isReady) LoadingScreen() + AdaptiveStrengthScreen(airPodsViewModel, ::navigateToPurchase) + } + +// Screen.CameraControl -> +// NavEntry(screen) { +// CameraControlScreen(airPodsViewModel) +// } + + Screen.OpenSourceLicenses -> + NavEntry(screen) { + OpenSourceLicensesScreen() + } + + Screen.UpdateHearingTest -> + NavEntry(screen) { + UpdateHearingTestRoute(airPodsViewModel) + } + + Screen.VersionInfo -> + NavEntry(screen) { + if (!airPodsViewModel.isReady) LoadingScreen() + VersionScreen(airPodsViewModel) + } + + Screen.HearingProtection -> + NavEntry(screen) { + if (!airPodsViewModel.isReady) LoadingScreen() + HearingProtectionScreen( + viewModel = airPodsViewModel, + navigateToPurchase = ::navigateToPurchase + ) + } + + Screen.Purchase -> + NavEntry(screen) { + val vm: PurchaseViewModel = viewModel() + PurchaseScreen(vm, backStack) + } + + Screen.Equalizer -> + NavEntry(screen) { + if (!airPodsViewModel.isReady) LoadingScreen() + EqualizerRoute(airPodsViewModel) + } + + is Screen.LongPress -> + NavEntry(screen) { + if (!airPodsViewModel.isReady) LoadingScreen() + LongPress( + viewModel = airPodsViewModel, + name = screen.bud, + navigateToPurchase = ::navigateToPurchase + ) + } + + is Screen.CallControl -> + NavEntry(screen) { + if (!airPodsViewModel.isReady) LoadingScreen() + CallControlScreen( + viewModel = airPodsViewModel, + action = screen.action, + onCallControlValueChanged = { flipped -> + airPodsViewModel.setControlCommandValue( + AACPManager.Companion.ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG, + if (flipped) byteArrayOf(0x00, 0x02) else byteArrayOf( + 0x00, + 0x03 + ) + ) + } + ) + } + + is Screen.MicrophoneSettings -> + NavEntry(screen) { + if (!airPodsViewModel.isReady) LoadingScreen() + MicrophoneSettingsRoute(viewModel = airPodsViewModel) + } + + is Screen.ReleaseNotes -> + NavEntry(screen) { + ReleaseNotesScreen( + updates = updates, + releaseNotesShown = { + if (showReleaseNotes) { + navigate(Screen.AirPodsSettings) + backStack.remove(screen) + updatesShown() + } else { + backStack.removeAt(backStack.lastIndex) + } + } + ) + } + } + }, + transitionSpec = { + slideInHorizontally { it } togetherWith slideOutHorizontally { -it / 4 } + }, + popTransitionSpec = { + slideInHorizontally { -it / 4 } togetherWith slideOutHorizontally { it } + }, + predictivePopTransitionSpec = { swipeEdge -> + if (m3eEnabled) { + val enterOffset: (Int) -> Int = + if (swipeEdge == EDGE_LEFT) { + { -it / 6 } + } else { + { it / 6 } + } + + val exitOffset: (Int) -> Int = + if (swipeEdge == EDGE_LEFT) { + { it / 8 } + } else { + { -it / 8 } + } + + fadeIn( + animationSpec = tween(250) + ) + + slideInHorizontally( + initialOffsetX = enterOffset, + animationSpec = tween(250) + ) togetherWith + fadeOut( + targetAlpha = 0.75f, + animationSpec = tween(250) + ) + + scaleOut( + targetScale = 0.85f, + animationSpec = tween(250) + ) + + slideOutHorizontally( + targetOffsetX = exitOffset, + animationSpec = tween(250) + ) + } else { + slideInHorizontally { -it / 4 } togetherWith slideOutHorizontally { it } + } + }, + ) + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt new file mode 100644 index 00000000..2bca355a --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/NavigationRoot.kt @@ -0,0 +1,158 @@ +package me.kavishdevar.librepods.presentation.navigation + +import android.util.Log +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.FilledTonalIconToggleButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.kyant.backdrop.backdrops.LayerBackdrop +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.presentation.MaterialIcons +import me.kavishdevar.librepods.presentation.components.StyledIconButton +import me.kavishdevar.librepods.presentation.components.StyledScaffold +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel + +@Composable +fun NavigationRoot( + showReleaseNotes: Boolean = false, + updatesShown: () -> Unit = {}, + showOnboarding: Boolean = false, + onboardingComplete: () -> Unit = {}, + airPodsViewModel: AirPodsViewModel +) { + val backStack = remember { + mutableStateListOf( + when { + showOnboarding -> Screen.Onboarding + showReleaseNotes -> Screen.ReleaseNotes + else -> Screen.AirPodsSettings + } + ) + } + + val currentScreen = backStack.last() + + val state by airPodsViewModel.uiState.collectAsState() + + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + + val title = when (currentScreen) { + Screen.Onboarding -> "" + Screen.AirPodsSettings -> if (state.isLocallyConnected) state.deviceName else stringResource(R.string.app_name) + Screen.Accessibility -> stringResource(R.string.accessibility) + Screen.AdaptiveStrength -> stringResource(R.string.customize_adaptive_audio) + Screen.AppSettings -> stringResource(R.string.settings) +// Screen.CameraControl -> stringResource(R.string.camera_control) + Screen.Equalizer -> stringResource(R.string.equalizer) + Screen.HeadTracking -> stringResource(R.string.head_tracking) + Screen.HearingAid -> stringResource(R.string.hearing_aid) + Screen.HearingAidAdjustments -> stringResource(R.string.adjustments) + Screen.HearingProtection -> stringResource(R.string.hearing_protection) + is Screen.LongPress -> currentScreen.bud + Screen.OpenSourceLicenses -> stringResource(R.string.open_source_licenses) + Screen.Purchase -> stringResource(R.string.unlock_advanced_features) + Screen.Rename -> stringResource(R.string.name) + Screen.TransparencyCustomization -> stringResource(R.string.customize_transparency_mode) + Screen.Troubleshooting -> stringResource(R.string.troubleshooting) + Screen.UpdateHearingTest -> stringResource(R.string.update_hearing_test) + Screen.VersionInfo -> stringResource(R.string.version) + is Screen.CallControl -> currentScreen.action + Screen.MicrophoneSettings -> stringResource(R.string.microphone_mode) + Screen.ReleaseNotes -> "" + } + + // is this a bad idea? probably. I can't think of a better way without having to pass around a shouldShowBackButton to each screen to pass to each scaffold + val actionButtons = when (currentScreen) { + Screen.AirPodsSettings -> listOf<@Composable (backdrop: LayerBackdrop) -> Unit>( + { scaffoldBackdrop -> + if (m3eEnabled) { + FilledTonalIconButton( + onClick = { backStack.add(Screen.AppSettings) }, + modifier = Modifier + .minimumInteractiveComponentSize() + .size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)), + + ) { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = "settings", + modifier = Modifier.size(IconButtonDefaults.mediumIconSize) + ) + } + } else { + StyledIconButton( + onClick = { backStack.add(Screen.AppSettings) }, + icon = "􀍟", + backdrop = scaffoldBackdrop + ) + } + } + ) + Screen.HeadTracking -> listOf<@Composable (backdrop: LayerBackdrop) -> Unit>( + { scaffoldBackdrop -> + if (m3eEnabled) { + FilledTonalIconToggleButton( + checked = state.headTrackingActive, + onCheckedChange = { if (it) airPodsViewModel.startHeadTracking() else airPodsViewModel.stopHeadTracking() }, + modifier = Modifier + .minimumInteractiveComponentSize() + .size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Uniform)), + shape = IconButtonDefaults.mediumRoundShape + ) { + Icon( + imageVector = if (state.headTrackingActive) MaterialIcons.pause else Icons.Default.PlayArrow, + contentDescription = "Play/Pause", + modifier = Modifier.size(IconButtonDefaults.mediumIconSize) + ) + } + } else { + StyledIconButton( + onClick = { + if (!state.headTrackingActive) { + airPodsViewModel.startHeadTracking() + Log.d("HeadTrackingScreen", "Head tracking started") + } else { + airPodsViewModel.stopHeadTracking() + Log.d("HeadTrackingScreen", "Head tracking stopped") + } + }, + icon = if (state.headTrackingActive) "􀊅" else "􀊃", + backdrop = scaffoldBackdrop + ) + } + } + ) + else -> listOf() + } + + StyledScaffold( + visible = currentScreen.showTopBar, + title = title, + showBackButton = backStack.size > 1, + onNavigateBack = { backStack.removeAt(backStack.lastIndex) }, + actionButtons = actionButtons + ) { + AppNavGraph( + showReleaseNotes = showReleaseNotes, + updatesShown = updatesShown, + showOnboarding = showOnboarding, + onboardingComplete = onboardingComplete, + backStack = backStack, + airPodsViewModel = airPodsViewModel, + ) + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/Screen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/Screen.kt new file mode 100644 index 00000000..1a8959f3 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/navigation/Screen.kt @@ -0,0 +1,84 @@ +package me.kavishdevar.librepods.presentation.navigation + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Serializable +sealed interface Screen: NavKey { + val showTopBar: Boolean + get() = true + + @Serializable + data object Onboarding: Screen { + override val showTopBar: Boolean = false + } + + @Serializable + data object AirPodsSettings: Screen + + @Serializable + data object Rename: Screen + + @Serializable + data object AppSettings: Screen + + @Serializable + data object Troubleshooting: Screen + + @Serializable + data object HeadTracking: Screen + + @Serializable + data object Accessibility: Screen + + @Serializable + data object TransparencyCustomization: Screen + + @Serializable + data object HearingAid: Screen + + @Serializable + data object HearingAidAdjustments: Screen + + @Serializable + data object AdaptiveStrength: Screen + +// @Serializable +// data object CameraControl: Screen + + @Serializable + data object OpenSourceLicenses: Screen + + @Serializable + data object UpdateHearingTest: Screen + + @Serializable + data object VersionInfo: Screen + + @Serializable + data object HearingProtection: Screen + + @Serializable + data object Purchase: Screen + + @Serializable + data object Equalizer: Screen + + @Serializable + data class LongPress( + val bud: String + ): Screen + + @Serializable + data class CallControl( + val action: String + ): Screen + + @Serializable + data object MicrophoneSettings: Screen + + @Serializable + data object ReleaseNotes: Screen { + override val showTopBar: Boolean = false + } +} 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 7988d910..52e7c1f4 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 @@ -22,89 +22,67 @@ package me.kavishdevar.librepods.presentation.screens 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.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll 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 import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableLongStateOf +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.runtime.snapshotFlow 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.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch 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.StyledList +import me.kavishdevar.librepods.presentation.components.StyledListItem import me.kavishdevar.librepods.presentation.components.StyledSlider import me.kavishdevar.librepods.presentation.components.StyledToggle +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.time.Duration.Companion.milliseconds private var phoneMediaDebounceJob: Job? = null -private var toneVolumeDebounceJob: Job? = null -//private const val TAG = "AccessibilitySettings" @SuppressLint("DefaultLocale") @ExperimentalHazeMaterialsApi -@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class, FlowPreview::class) @Composable -fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavController) { +fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit, navigateToTransparencyCustomization: () -> Unit) { 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,294 +91,261 @@ 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)) + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - if (!state.isPremium) { - StyledButton( - onClick = { - navController.navigate("purchase_screen") - }, - backdrop = rememberLayerBackdrop(), - modifier = Modifier.fillMaxWidth(), - maxScale = 0.05f, - surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) - ) { - Text( - stringResource(R.string.unlock_advanced_features), - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = Color.White - ), - ) - } + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Spacer(modifier = Modifier.height(topPadding)) + + if (!state.isPremium) { + StyledButton( + onClick = navigateToPurchase, + backdrop = rememberLayerBackdrop(), + modifier = Modifier.fillMaxWidth(), + maxScale = 0.05f, + surfaceColor = MaterialTheme.colorScheme.primary + ) { + Text( + stringResource(R.string.unlock_advanced_features), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimary + ) } + Spacer(modifier = Modifier.height(16.dp)) + } // val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) } // val phoneEQEnabled = remember { mutableStateOf(false) } // val mediaEQEnabled = remember { mutableStateOf(false) } - 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 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 selectedPressSpeedValue = + state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL]?.getOrNull( + 0 ) - - val selectedPressSpeedValue = - state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL]?.getOrNull( - 0 - ) - var selectedPressSpeed by remember { - mutableStateOf( - pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0] - ) - } - - 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) + var selectedPressSpeed by remember { + mutableStateOf( + pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0] ) + } - val selectedPressAndHoldDurationValue = - state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull( - 0 - ) - var selectedPressAndHoldDuration by remember { - mutableStateOf( - pressAndHoldDurationOptions[selectedPressAndHoldDurationValue] - ?: pressAndHoldDurationOptions[0] - ) - } + 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 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 selectedPressAndHoldDurationValue = + state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull( + 0 ) - val selectedVolumeSwipeSpeedValue = - state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL]?.getOrNull( - 0 - ) - var selectedVolumeSwipeSpeed by remember { - mutableStateOf( - volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue] - ?: volumeSwipeSpeedOptions[1] - ) - } + var selectedPressAndHoldDuration by remember { + mutableStateOf( + pressAndHoldDurationOptions[selectedPressAndHoldDurationValue] + ?: pressAndHoldDurationOptions[0] + ) + } - val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) } - val phoneEQEnabled = remember { mutableStateOf(false) } - val mediaEQEnabled = remember { mutableStateOf(false) } + 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] + ) + } - 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})" - ) - viewModel.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte) - } catch (e: Exception) { - Log.w( - "AccessibilitySettingsScreen", - "Error sending phone/media EQ: ${e.message}" - ) - } + val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) } + val phoneEQEnabled = remember { mutableStateOf(false) } + val mediaEQEnabled = remember { mutableStateOf(false) } + + LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) { + phoneMediaDebounceJob?.cancel() + phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch { + delay(150.milliseconds) + 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})" + ) + viewModel.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte) + } catch (e: Exception) { + Log.w( + "AccessibilitySettingsScreen", + "Error sending phone/media EQ: ${e.message}" + ) } } - 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 + } + + StyledList( + title = stringResource(R.string.press_speed), + description = stringResource(R.string.press_speed_description) + ) { + pressSpeedOptions.forEach { (value, label) -> + StyledListItem( + name = label, + selected = selectedPressSpeed == label, + onClick = { + selectedPressSpeed = label + viewModel.setControlCommandByte( identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL, - value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() - ?: 0.toByte() + value = value ) - }, - textColor = textColor, - hazeState = hazeState, - independent = true + } ) } + } + + StyledList( + title = stringResource(R.string.press_and_hold_duration), + description = stringResource(R.string.press_and_hold_duration_description) + ) { + pressAndHoldDurationOptions.forEach { (value, label) -> + StyledListItem( + name = label, + selected = selectedPressAndHoldDuration == label, + onClick = { + selectedPressAndHoldDuration = label - 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_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() + value = value ) - }, - 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), + 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( - 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(), + label = stringResource(R.string.loud_sound_reduction), + description = stringResource(R.string.loud_sound_reduction_description), + checked = state.loudSoundReductionEnabled, onCheckedChange = { - viewModel.setControlCommandBoolean( - AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it + viewModel.setATTCharacteristicValue( + ATTHandles.LOUD_SOUND_REDUCTION, + if (it) byteArrayOf(0x01) else byteArrayOf(0x00) ) }, 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) - ) - }, - enabled = state.isPremium - ) + if (!hearingAidEnabled && state.vendorIdHook) { + StyledListItem( + name = stringResource(R.string.customize_transparency_mode), + onClick = navigateToTransparencyCustomization, + enabled = state.isPremium + ) + } + + val toneVolumeValue = remember { mutableFloatStateOf(state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(0)?.toFloat() ?: 75f) } + + LaunchedEffect(toneVolumeValue) { + snapshotFlow { + toneVolumeValue.floatValue } - - 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 = { + .debounce(100.milliseconds) + .collect { viewModel.setControlCommandValue( AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME, byteArrayOf(it.toInt().toByte(), 0x50) ) + } + } + + StyledSlider( + label = stringResource(R.string.tone_volume), + description = stringResource(R.string.tone_volume_description), + value = toneVolumeValue.floatValue, + onValueChange = { + toneVolumeValue.floatValue = it + }, + 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 + ) }, - 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 - ) - }, - enabled = state.isPremium - ) + StyledList( + title = stringResource(R.string.volume_swipe_speed), + description = stringResource(R.string.volume_swipe_speed_description) + ) { + volumeSwipeSpeedOptions.forEach { (value, label) -> + StyledListItem( + name = label, + selected = selectedVolumeSwipeSpeed == label, + onClick = { + selectedVolumeSwipeSpeed = label - 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() + value = value ) - }, - textColor = textColor, - hazeState = hazeState, - independent = true + } ) } } + } // if (!hearingAidEnabled && XposedState.isAvailable) { // Text( @@ -635,210 +580,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)) - ) - ) - } - } + Spacer(modifier = Modifier.height(bottomPadding)) } } 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 466a790d..37a097e4 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,19 @@ package me.kavishdevar.librepods.presentation.screens -import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -36,15 +41,8 @@ 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 @@ -53,74 +51,72 @@ 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.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel @Composable -fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navController: NavController) { +fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) { val state by viewModel.uiState.collectAsState() val backdrop = rememberLayerBackdrop() - StyledScaffold( - title = stringResource(R.string.customize_adaptive_audio) - ) { spacerHeight -> - Column( - modifier = Modifier - .fillMaxSize() - .layerBackdrop(backdrop) - .padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Spacer(modifier = Modifier.height(spacerHeight)) - if (!state.isPremium) { - StyledButton( - onClick = { - navController.navigate("purchase_screen") - }, - backdrop = rememberLayerBackdrop(), - modifier = Modifier.fillMaxWidth(), - maxScale = 0.05f, - surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) - ) { - Text( - stringResource(R.string.unlock_advanced_features), - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = Color.White - ), + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + + Column( + modifier = Modifier + .fillMaxSize() + .layerBackdrop(backdrop) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.height(topPadding)) + if (!state.isPremium) { + StyledButton( + onClick = navigateToPurchase, + backdrop = rememberLayerBackdrop(), + modifier = Modifier.fillMaxWidth(), + maxScale = 0.05f, + surfaceColor = MaterialTheme.colorScheme.primary + ) { + Text( + stringResource(R.string.unlock_advanced_features), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimary + ) + } + Spacer(modifier = Modifier.height(16.dp)) + } + 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()) ) } - } - 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()) - ) - } - }, - valueRange = 0f..100f, - snapPoints = listOf(0f, 50f, 100f), - startIcon = "􀊥", - endIcon = "􀊩", - independent = true, - description = stringResource(R.string.adaptive_audio_description), - enabled = state.isPremium - ) - } + }, + valueRange = 0f..100f, + snapPoints = listOf(0f, 50f, 100f), + startIcon = "􀊥", + endIcon = "􀊩", + independent = true, + description = stringResource(R.string.adaptive_audio_description), + enabled = state.isPremium + ) + Spacer(modifier = Modifier.height(bottomPadding)) } } 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 index aeb11e48..9583ccea 100644 --- 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 @@ -25,24 +25,41 @@ import android.annotation.SuppressLint import android.content.Context.MODE_PRIVATE import android.content.Intent import android.content.SharedPreferences +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.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.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues 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.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.material3.toPath import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -56,8 +73,11 @@ 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.draw.drawWithContent +import androidx.compose.ui.geometry.center import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.graphics.Matrix +import androidx.compose.ui.graphics.Path import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -67,15 +87,15 @@ 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.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.net.toUri -import androidx.navigation.NavController +import androidx.graphics.shapes.Morph 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 @@ -84,29 +104,138 @@ 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.MaterialIcons 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.MaterialButtonStyle 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.StyledListItem import me.kavishdevar.librepods.presentation.components.StyledToggle +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.viewmodel.AirPodsUiState import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel +import me.kavishdevar.librepods.presentation.viewmodel.demoState import java.util.concurrent.TimeUnit import kotlin.io.encoding.ExperimentalEncodingApi +import kotlin.math.min +import kotlin.time.Duration.Companion.seconds -@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) +@Composable +fun AirPodsSettingsRoute( + viewModel: AirPodsViewModel, + navigateToRename: () -> Unit, + navigateToHearingProtection: () -> Unit, + navigateToHearingAid: () -> Unit, + navigateToLeftLongPress: () -> Unit, + navigateToRightLongPress: () -> Unit, + navigateToPurchase: () -> Unit, + navigateToAdaptiveStrength: () -> Unit, + navigateToEqualizer: () -> Unit, + navigateToHeadTracking: () -> Unit, + navigateToAccessibility: () -> Unit, + navigateToVersion: () -> Unit, + navigateToTroubleshooting: () -> Unit, + navigateToCallControlScreen: (action: String) -> Unit, + navigateToMicrophoneSettings: () -> Unit +) { + val state by viewModel.uiState.collectAsState() + + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + if (m3eEnabled) 0.dp else 84.dp + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + + Box ( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + AirPodsSettingsScreen( + state = state, + + topPadding = topPadding, + bottomPadding = bottomPadding, + + setControlCommandInt = viewModel::setControlCommandInt, + setControlCommandBoolean = viewModel::setControlCommandBoolean, +// setControlCommandValue = viewModel::setControlCommandValue, + setControlCommandByte = viewModel::setControlCommandByte, + + setATTCharacteristicValue = viewModel::setATTCharacteristicValue, + + onAutomaticEarDetectionChanged = viewModel::setAutomaticEarDetectionEnabled, + onAutomaticConnectionChanged = viewModel::setAutomaticConnectionEnabled, + setDynamicEndOfCharge = viewModel::setDynamicEndOfCharge, + setOffListeningMode = viewModel::setOffListeningMode, + disconnect = viewModel::disconnect, + + navigateToRename = navigateToRename, + navigateToHearingProtection = navigateToHearingProtection, + navigateToHearingAid = navigateToHearingAid, + navigateToLeftLongPress = navigateToLeftLongPress, + navigateToRightLongPress = navigateToRightLongPress, + navigateToPurchase = navigateToPurchase, + navigateToAdaptiveStrength = navigateToAdaptiveStrength, + navigateToEqualizer = navigateToEqualizer, + navigateToHeadTracking = navigateToHeadTracking, + navigateToAccessibility = navigateToAccessibility, + navigateToVersion = navigateToVersion, + navigateToTroubleshooting = navigateToTroubleshooting, + navigateToCallControlScreen = navigateToCallControlScreen, + navigateToMicrophoneSettings = navigateToMicrophoneSettings, + + activateDemoMode = viewModel::activateDemoMode, + reconnectFromSavedMac = viewModel::reconnectFromSavedMac + ) + } +} + + @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") @Composable -fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavController) { - val state by viewModel.uiState.collectAsState() +fun AirPodsSettingsScreen( + state: AirPodsUiState, + + topPadding: Dp = 16.dp, + bottomPadding: Dp = 16.dp, + + setControlCommandInt: (AACPManager.Companion.ControlCommandIdentifiers, Int) -> Unit, + setControlCommandBoolean: (AACPManager.Companion.ControlCommandIdentifiers, Boolean) -> Unit, +// setControlCommandValue: (AACPManager.Companion.ControlCommandIdentifiers, ByteArray) -> Unit, + setControlCommandByte: (AACPManager.Companion.ControlCommandIdentifiers, Byte) -> Unit, + setATTCharacteristicValue: (ATTHandles, ByteArray) -> Unit, + + onAutomaticEarDetectionChanged: (Boolean) -> Unit, + onAutomaticConnectionChanged: (Boolean) -> Unit, + setDynamicEndOfCharge: (Boolean) -> Unit, + setOffListeningMode: (Boolean) -> Unit, + disconnect: () -> Unit, + + navigateToRename: () -> Unit, + navigateToHearingProtection: () -> Unit, + navigateToHearingAid: () -> Unit, + navigateToLeftLongPress: () -> Unit, + navigateToRightLongPress: () -> Unit, + navigateToPurchase: () -> Unit, + navigateToAdaptiveStrength: () -> Unit, + navigateToEqualizer: () -> Unit, + navigateToHeadTracking: () -> Unit, + navigateToAccessibility: () -> Unit, + navigateToVersion: () -> Unit, + navigateToTroubleshooting: () -> Unit, + navigateToCallControlScreen: (action: String) -> Unit, + navigateToMicrophoneSettings: () -> Unit, + + activateDemoMode: () -> Unit, + reconnectFromSavedMac: () -> Unit, +) { val sharedPreferences = LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE) var deviceName by remember { mutableStateOf( @@ -132,489 +261,762 @@ fun AirPodsSettingsScreen(viewModel: AirPodsViewModel, navController: NavControl } } - val snackbarHostState = remember { SnackbarHostState() } + if (state.isLocallyConnected) { + val capabilities = state.capabilities - 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 = "play_update_banner") { - if (state.timeUntilFOSSPremiumExpiry > 0L) { - val context = LocalContext.current - Box( - modifier = Modifier - .background(Color(0xFF32829B), RoundedCornerShape(28.dp)) - .clip(RoundedCornerShape(28.dp)) - .clickable { - val emailIntent = Intent(Intent.ACTION_SENDTO).apply { - data = "mailto:".toUri() - putExtra(Intent.EXTRA_EMAIL, arrayOf("billing@kavish.xyz")) - putExtra(Intent.EXTRA_SUBJECT, "LibrePods Play billing error") - putExtra( - Intent.EXTRA_TEXT, - "Please enter your GitHub username to restore your premium access:\n\nGitHub username: " - ) - } - context.startActivity(emailIntent) + LazyColumn( + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 16.dp) + ) { + item(key = "top_padding") { Spacer(modifier = Modifier.height(topPadding)) } + item(key = "play_update_banner") { + if (state.timeUntilFOSSPremiumExpiry > 0L) { + val context = LocalContext.current + Box( + modifier = Modifier + .background(Color(0xFF32829B), RoundedCornerShape(28.dp)) + .clip(RoundedCornerShape(28.dp)) + .clickable { + val emailIntent = Intent(Intent.ACTION_SENDTO).apply { + data = "mailto:".toUri() + putExtra(Intent.EXTRA_EMAIL, arrayOf("billing@kavish.xyz")) + putExtra(Intent.EXTRA_SUBJECT, "LibrePods Play billing error") + putExtra( + Intent.EXTRA_TEXT, + "Please enter your GitHub username to restore your premium access:\n\nGitHub username: " + ) } - ) { - Text( - text = stringResource( - R.string.play_foss_premium_banner, maxOf(1, TimeUnit.MILLISECONDS.toDays(state.timeUntilFOSSPremiumExpiry).toInt()) - ), - modifier = Modifier - .padding(16.dp), - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - color = Color.White, - fontFamily = FontFamily(Font(R.font.sf_pro)) + context.startActivity(emailIntent) + }) { + Text( + text = stringResource( + R.string.play_foss_premium_banner, + maxOf( + 1, + TimeUnit.MILLISECONDS.toDays(state.timeUntilFOSSPremiumExpiry) + .toInt() ) + ), modifier = Modifier.padding(16.dp), style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + fontFamily = FontFamily(Font(R.font.sf_pro)) ) - } + ) } } + } - 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 = "battery") { + BatteryView( + batteryList = state.battery, + budsRes = state.instance?.model?.budsRes ?: R.drawable.airpods_pro_2_buds, + caseRes = state.instance?.model?.caseRes ?: R.drawable.airpods_pro_2_case + ) + } + item(key = "spacer_battery") { + Spacer(modifier = Modifier.height(32.dp)) + } + + item(key = "name") { + StyledListItem( + name = stringResource(R.string.name), + description = deviceName.text, + onClick = navigateToRename, + ) + } + + 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) { + item(key = "spacer_hearing_health") { + Spacer(modifier = Modifier.height(24.dp)) + } + } + item(key = "hearing_health") { + HearingHealthSettings( + hasPPECapability = hasPPECapability, + hasHearingAidCapability = hasHearingAidCapability, + vendorIdHook = state.vendorIdHook, + navigateToHearingProtection = navigateToHearingProtection, + navigateToHearingAid = navigateToHearingAid ) } - 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 + 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 = { + setControlCommandInt( + AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE, it + ) + }, ) } + } - 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.STEM_CONFIG)) { + item(key = "spacer_press_hold") { + Spacer(modifier = Modifier.height(16.dp)) } - - 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 - ) - }, - ) - } + item(key = "press_hold") { + PressAndHoldSettings( + leftAction = state.leftAction, + rightAction = state.rightAction, + navigateToLeftLongPress = navigateToLeftLongPress, + navigateToRightLongPress = navigateToRightLongPress + ) } + } - 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) - ) - }) + 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 (_: Exception) { + false } + CallControlSettings( + flipped = flipped, + navigateToCallControlScreen = navigateToCallControlScreen + ) + } // if (capabilities.contains(Capability.STEM_CONFIG) && !BuildConfig.PLAY_BUILD) { // item(key = "spacer_camera") { Spacer(modifier = Modifier.height(16.dp)) } // item(key = "camera_control") { -// NavigationButton( +// StyledListItem( // to = "camera_control", // name = stringResource(R.string.camera_remote), -// description = stringResource(R.string.camera_control_description), -// title = stringResource(R.string.camera_control), +// descriptionRes = stringResource(R.string.camera_control_description), +// titleRes = stringResource(R.string.camera_control), // navController = navController // ) // } // } - item(key = "upgrade_button") { - if (!state.isPremium) { - Spacer(modifier = Modifier.height(28.dp)) - StyledButton( - onClick = { - navController.navigate("purchase_screen") - }, - backdrop = rememberLayerBackdrop(), - modifier = Modifier.fillMaxWidth(), - maxScale = 0.05f, - surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color( - 0xFFE59900 - ) - ) { - Text( - stringResource(R.string.unlock_advanced_features), - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = Color.White - ), - ) - } - Spacer(modifier = Modifier.height(16.dp)) - } - } - - 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, - customEqCapability = true, - 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") { + item(key = "upgrade_button") { + if (!state.isPremium) { + Spacer(modifier = Modifier.height(28.dp)) StyledButton( - onClick = viewModel::disconnect, + onClick = navigateToPurchase, backdrop = rememberLayerBackdrop(), - isInteractive = false, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 56.dp) + modifier = Modifier.fillMaxWidth(), + maxScale = 0.05f, + surfaceColor = MaterialTheme.colorScheme.primary ) { 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() + stringResource(R.string.unlock_advanced_features), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimary ) } + Spacer(modifier = Modifier.height(8.dp)) + } + } + + 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( + adaptiveVolumeCapability = adaptiveVolumeCapability, + conversationalAwarenessCapability = conversationalAwarenessCapability, + loudSoundReductionCapability = loudSoundReductionCapability, + adaptiveAudioCapability = adaptiveAudioCapability, + customEqCapability = true, + adaptiveVolumeChecked = adaptiveVolumeChecked, + onAdaptiveVolumeCheckedChange = { checked -> + setControlCommandBoolean( + AACPManager.Companion.ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG, + checked + ) + }, + conversationalAwarenessChecked = conversationalAwarenessChecked && state.isPremium, + onConversationalAwarenessCheckedChange = { checked -> + setControlCommandBoolean( + AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG, + checked + ) + }, + loudSoundReductionChecked = state.loudSoundReductionEnabled, + onLoudSoundReductionCheckedChange = { checked -> + setATTCharacteristicValue( + ATTHandles.LOUD_SOUND_REDUCTION, + byteArrayOf(if (checked) 0x01.toByte() else 0x00.toByte()) + ) + }, + navigateToAdaptiveStrength = navigateToAdaptiveStrength, + navigateToEqualizer = navigateToEqualizer, + vendorIdHook = state.vendorIdHook, + isPremium = state.isPremium + ) + } + + item(key = "spacer_connection") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "connection") { + ConnectionSettings( + automaticEarDetectionEnabled = state.automaticEarDetectionEnabled, + onAutomaticEarDetectionChanged = onAutomaticEarDetectionChanged, + automaticConnectionEnabled = state.automaticConnectionEnabled, + onAutomaticConnectionChanged = onAutomaticConnectionChanged + ) + } + + item(key = "spacer_microphone") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "microphone") { + val id = AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE + + val selectedModeText = when (state.controlStates[id]?.getOrNull(0) ?: 0x00.toByte()) { + 0x00.toByte() -> stringResource(R.string.microphone_automatic) + 0x01.toByte() -> stringResource(R.string.microphone_always_right) + 0x02.toByte() -> stringResource(R.string.microphone_always_left) + else -> stringResource(R.string.microphone_automatic) } -// 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)) } + StyledListItem( + name = stringResource(R.string.microphone_mode), + description = selectedModeText, + onClick = navigateToMicrophoneSettings + ) } - } 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( + + 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 = { 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") { + StyledListItem( + name = stringResource(R.string.head_gestures), + description = if (sharedPreferences.getBoolean( + "head_gestures", false + ) + ) stringResource(R.string.on) else stringResource(R.string.off), + onClick = navigateToHeadTracking + ) + } + } + + 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 = setDynamicEndOfCharge + ) + } + + item(key = "spacer_accessibility") { Spacer(modifier = Modifier.height(16.dp)) } + item(key = "accessibility") { + StyledListItem( + name = stringResource(R.string.accessibility), onClick = navigateToAccessibility + ) + } + + 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 = setOffListeningMode + ) + } + } + + item(key = "spacer_about") { Spacer(modifier = Modifier.height(32.dp)) } + item(key = "about") { + AboutCard( + modelName = state.modelName, + actualModel = state.actualModel, + serialNumbers = state.serialNumbers, + version = state.version3, + navigateToVersion = navigateToVersion + ) + } + + item(key = "spacer_disconnect") { Spacer(modifier = Modifier.height(28.dp)) } + item(key = "disconnect_button") { + StyledButton( + onClick = disconnect, + backdrop = rememberLayerBackdrop(), + isInteractive = false, 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() - } - }) - }) { + .heightIn(min = 56.dp) + ) { 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(), + text = stringResource(R.string.disconnect), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + textAlign = TextAlign.Start, + 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 - ) +// item(key = "spacer_debug") { Spacer(modifier = Modifier.height(16.dp)) } +// item(key = "debug") { StyledListItem("debug", "Debug", navController) } + + item(key = "bottom_padding") { Spacer(modifier = Modifier.height(bottomPadding)) } + } + } else { + val backdrop = rememberLayerBackdrop() + Box( + modifier = Modifier + .drawBackdrop( + backdrop = rememberLayerBackdrop(), + exportedBackdrop = backdrop, + shape = { RoundedCornerShape(0.dp) }, + highlight = { + Highlight.Ambient.copy(alpha = 0f) + }, + effects = {} + ) + .fillMaxSize() + .padding(start = 8.dp, end = 8.dp, bottom = bottomPadding), + contentAlignment = Alignment.Center + ) { + val tapCount = remember { mutableIntStateOf(0) } + val lastTapTime = remember { mutableLongStateOf(0L) } + + var reconnecting by remember { mutableStateOf(false) } + + LaunchedEffect(reconnecting) { + if (reconnecting) { + delay(5.seconds) + reconnecting = false + } + } + + when (LocalDesignSystem.current) { + DesignSystem.Material -> { + val polygons = remember { + listOf( + MaterialShapes.Cookie9Sided, + MaterialShapes.Clover4Leaf, + MaterialShapes.SoftBurst, + MaterialShapes.Sunny, + MaterialShapes.Pentagon, + MaterialShapes.Cookie4Sided, + MaterialShapes.Oval, ) } - 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 + + val morphs = remember { + buildList { + for (i in polygons.indices) { + add( + Morph( + polygons[i].normalized(), + polygons[(i + 1) % polygons.size].normalized() + ) + ) + } + } + } + + var currentMorphIndex by remember { mutableIntStateOf(0) } + + val morphProgress = remember { Animatable(0f) } + + LaunchedEffect(reconnecting) { + if (!reconnecting) { + currentMorphIndex = 0 + morphProgress.snapTo(0f) + return@LaunchedEffect + } + + while (reconnecting) { + morphProgress.snapTo(0f) + + morphProgress.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = 650, + easing = FastOutSlowInEasing + ) ) - ) + + currentMorphIndex = (currentMorphIndex + 1) % morphs.size + } + } + + val path = remember { Path() } + val scaleMatrix = remember { Matrix() } + + Box( + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectTapGestures( + onLongPress = { + activateDemoMode() + } + ) + } + ) { + Column( + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = 32.dp) + .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 + activateDemoMode() + } + } + ) + }, + horizontalAlignment = Alignment.CenterHorizontally + ) { + val primaryContainerColor = MaterialTheme.colorScheme.tertiaryContainer + val secondaryContainerColor = MaterialTheme.colorScheme.secondaryContainer + + val animatedShapeColor by animateColorAsState(if (reconnecting) primaryContainerColor else secondaryContainerColor) + + Box( + modifier = Modifier + .size(240.dp) + .background( + MaterialTheme.colorScheme.surfaceBright, + CircleShape + ) + .clickable( + interactionSource = null, + indication = ripple( + bounded = false, + radius = 120.dp + ), + enabled = !reconnecting, + onClick = {} + ) + .pointerInput(Unit) { + detectTapGestures( + onTap = { + if (!reconnecting) { + currentMorphIndex = 1 + reconnecting = true + reconnectFromSavedMac() + } + }, + onPress = { + if (!reconnecting) { + morphProgress.animateTo( + targetValue = 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ) + ) + tryAwaitRelease() + morphProgress.animateTo( + targetValue = 0f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ) + ) + } + } + ) + } + .drawWithContent { + val activeMorph = morphs[currentMorphIndex] + + val shapePath = activeMorph.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 = animatedShapeColor + ) + + drawContent() + }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = if (reconnecting) MaterialIcons.bluetooth_searching else MaterialIcons.headset_off, + contentDescription = null, + modifier = Modifier.size(84.dp), + tint = if (reconnecting) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer + ) + } + + Spacer(Modifier.height(40.dp)) + + Text( + text = if (reconnecting) stringResource(R.string.reconnecting) else stringResource(R.string.tap_to_reconnect), + style = MaterialTheme.typography.labelSmallEmphasized, + color = MaterialTheme.colorScheme.primary + ) + } + + if (!BuildConfig.PLAY_BUILD) { + OutlinedButton( + onClick = navigateToTroubleshooting, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(24.dp) + ) { + Text( + stringResource( + R.string.troubleshooting + ), + style = MaterialTheme.typography.labelMedium, + ) + } + } + } + } + + DesignSystem.Apple -> { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.Center + ) { + 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 + activateDemoMode() + } + }) + }) { + Text( + text = stringResource(R.string.airpods_not_connected), + style = MaterialTheme.typography.displaySmall, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Spacer(Modifier.height(24.dp)) + Text( + text = stringResource(R.string.airpods_not_connected_description), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } + + if (state.connectionSuccessful) { + StyledButton( + onClick = { reconnectFromSavedMac(); reconnecting = true }, + backdrop = backdrop, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .widthIn(max = 200.dp), + enabled = !reconnecting + ) { + Text( + text = stringResource(R.string.reconnect_to_last_device), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + + if (!BuildConfig.PLAY_BUILD) { + StyledButton( + onClick = navigateToTroubleshooting, + backdrop = backdrop, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .padding(16.dp) + .widthIn(max = 200.dp), + materialButtonStyle = MaterialButtonStyle.Outlined, + ) { + Text( + text = stringResource(R.string.troubleshooting), + style = MaterialTheme.typography.bodyMedium + ) + } } } } } } } + +@Preview(name = "Apple") +@Composable +fun AirPodsSettingsScreenPreviewApple() { + LibrePodsTheme( + m3eEnabled = false + ) { + Box( + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + AirPodsSettingsScreen( + state = demoState, + + setControlCommandInt = { _, _ -> }, + setControlCommandBoolean = { _, _ -> }, + setControlCommandByte = { _, _ -> }, + setATTCharacteristicValue = { _, _ -> }, + + onAutomaticEarDetectionChanged = {}, + onAutomaticConnectionChanged = {}, + setDynamicEndOfCharge = {}, + setOffListeningMode = {}, + disconnect = {}, + + navigateToRename = {}, + navigateToHearingProtection = {}, + navigateToHearingAid = {}, + navigateToLeftLongPress = {}, + navigateToRightLongPress = {}, + navigateToPurchase = {}, + navigateToAdaptiveStrength = {}, + navigateToEqualizer = {}, + navigateToHeadTracking = {}, + navigateToAccessibility = {}, + navigateToVersion = {}, + navigateToTroubleshooting = {}, + navigateToCallControlScreen = {}, + navigateToMicrophoneSettings = {}, + + activateDemoMode = {}, + reconnectFromSavedMac = {} + ) + } + } +} + + +@Preview(name = "Material") +@Composable +fun AirPodsSettingsScreenPreviewMaterial() { + LibrePodsTheme( + m3eEnabled = true + ) { + Box ( + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + AirPodsSettingsScreen( + state = demoState, + + setControlCommandInt = { _, _ -> }, + setControlCommandBoolean = { _, _ -> }, + setControlCommandByte = { _, _ -> }, + setATTCharacteristicValue = { _, _ -> }, + + onAutomaticEarDetectionChanged = {}, + onAutomaticConnectionChanged = {}, + setDynamicEndOfCharge = {}, + setOffListeningMode = {}, + disconnect = {}, + + navigateToRename = {}, + navigateToHearingProtection = {}, + navigateToHearingAid = {}, + navigateToLeftLongPress = {}, + navigateToRightLongPress = {}, + navigateToPurchase = {}, + navigateToAdaptiveStrength = {}, + navigateToEqualizer = {}, + navigateToHeadTracking = {}, + navigateToAccessibility = {}, + navigateToVersion = {}, + navigateToTroubleshooting = {}, + navigateToCallControlScreen = {}, + navigateToMicrophoneSettings = {}, + + activateDemoMode = {}, + reconnectFromSavedMac = {} + ) + } + } +} 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 af1973d1..20341b37 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 @@ -31,10 +31,14 @@ 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.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions @@ -43,7 +47,6 @@ import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults @@ -74,22 +77,23 @@ 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.StyledList +import me.kavishdevar.librepods.presentation.components.StyledListItem import me.kavishdevar.librepods.presentation.components.StyledSlider import me.kavishdevar.librepods.presentation.components.StyledToggle +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.theme.MaterialTypography import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel import me.kavishdevar.librepods.utils.XposedState import java.util.concurrent.TimeUnit @@ -97,7 +101,11 @@ import java.util.concurrent.TimeUnit @OptIn(ExperimentalMaterial3Api::class) @Composable fun AppSettingsScreen( - navController: NavController, viewModel: AppSettingsViewModel = viewModel() + viewModel: AppSettingsViewModel = viewModel(), + navigateToPurchase: () -> Unit, + navigateToTroubleshooting: () -> Unit, + navigateToOpenSourceLicenses: () -> Unit, + navigateToReleaseNotesScreen: () -> Unit ) { val context = LocalContext.current val scrollState = rememberScrollState() @@ -111,667 +119,495 @@ fun AppSettingsScreen( 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) - ) { - Spacer(modifier = Modifier.height(topPadding)) + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + val topPadding = if (m3eEnabled) 16.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - val isDarkTheme = isSystemInDarkTheme() - val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) - val textColor = if (isDarkTheme) Color.White else Color.Black + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + .layerBackdrop(backdrop) + .verticalScroll(scrollState) + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(topPadding)) - if (!state.isPremium && state.connectionSuccessful) { - StyledButton( - onClick = { - navController.navigate("purchase_screen") - }, - backdrop = rememberLayerBackdrop(), - modifier = Modifier.fillMaxWidth(), - maxScale = 0.05f, - surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) - ) { - Text( - stringResource(R.string.unlock_advanced_features), - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = Color.White - ), - ) - } + val isDarkTheme = isSystemInDarkTheme() + + if (!state.isPremium && state.connectionSuccessful) { + StyledButton( + onClick = navigateToPurchase, + backdrop = rememberLayerBackdrop(), + modifier = Modifier.fillMaxWidth(), + maxScale = 0.05f, + surfaceColor = MaterialTheme.colorScheme.primary + ) { + Text( + stringResource(R.string.unlock_advanced_features), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimary + ) } - if (state.timeUntilFOSSPremiumExpiry > 0L) { - Box( - modifier = Modifier - .background(Color(0xFF32829B), RoundedCornerShape(28.dp)) - .clip(RoundedCornerShape(28.dp)) - .clickable { - val emailIntent = Intent(Intent.ACTION_SENDTO).apply { - data = "mailto:".toUri() - putExtra(Intent.EXTRA_EMAIL, arrayOf("billing@kavish.xyz")) - putExtra(Intent.EXTRA_SUBJECT, "LibrePods Play billing error") - putExtra( - Intent.EXTRA_TEXT, - "Please enter your GitHub username to restore your premium access:\n\nGitHub username: " - ) - } - context.startActivity(emailIntent) + Spacer(modifier = Modifier.height(16.dp)) + } + if (state.timeUntilFOSSPremiumExpiry > 0L) { + Box( + modifier = Modifier + .background(Color(0xFF32829B), RoundedCornerShape(28.dp)) + .clip(RoundedCornerShape(28.dp)) + .clickable { + val emailIntent = Intent(Intent.ACTION_SENDTO).apply { + data = "mailto:".toUri() + putExtra(Intent.EXTRA_EMAIL, arrayOf("billing@kavish.xyz")) + putExtra(Intent.EXTRA_SUBJECT, "LibrePods Play billing error") + putExtra( + Intent.EXTRA_TEXT, + "Please enter your GitHub username to restore your premium access:\n\nGitHub username: " + ) } - ) { - Text( - text = stringResource( - R.string.play_foss_premium_banner, maxOf(1, TimeUnit.MILLISECONDS.toDays(state.timeUntilFOSSPremiumExpiry).toInt()) - ), - modifier = Modifier - .padding(16.dp), - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - color = Color.White, - fontFamily = FontFamily(Font(R.font.sf_pro)) - ) + context.startActivity(emailIntent) + } + ) { + Text( + text = stringResource( + R.string.play_foss_premium_banner, maxOf(1, TimeUnit.MILLISECONDS.toDays(state.timeUntilFOSSPremiumExpiry).toInt()) + ), + modifier = Modifier + .padding(16.dp), + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + fontFamily = FontFamily(Font(R.font.sf_pro)) ) - } + ) } - if (state.connectionSuccessful) { + } + + StyledToggle( + title = stringResource(R.string.appearance), + label = stringResource(R.string.use_material3e), + checked = state.m3eEnabled, + onCheckedChange = viewModel::setm3eEnabled, +// enabled = state.isPremium + ) + + 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 + ) + + StyledList(title = stringResource(R.string.popup_animations)) { 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, + label = stringResource(R.string.show_bottom_sheet_popup), + description = stringResource(R.string.show_bottom_sheet_popup_description), + checked = state.showBottomSheetPopup, + onCheckedChange = viewModel::setShowBottomSheetPopup, + ) + + StyledToggle( + label = stringResource(R.string.show_island_popup), + description = stringResource(R.string.show_island_popup_description), + checked = state.showIslandPopup, + onCheckedChange = viewModel::setShowIslandPopup, + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + StyledList (title = stringResource(R.string.conversational_awareness)) { + StyledToggle( + label = stringResource(R.string.conversational_awareness_pause_music), + description = stringResource(R.string.conversational_awareness_pause_music_description), + checked = state.conversationalAwarenessPauseMusicEnabled, + onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled, 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) + StyledToggle( + label = stringResource(R.string.relative_conversational_awareness_volume), + description = stringResource(R.string.relative_conversational_awareness_volume_description), + checked = state.relativeConversationalAwarenessVolumeEnabled, + onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled, + enabled = state.isPremium, ) + } - Spacer(modifier = Modifier.height(2.dp)) + Spacer(modifier = Modifier.height(16.dp)) - Column( - modifier = Modifier - .fillMaxWidth() - .background( - backgroundColor, RoundedCornerShape(28.dp) - ) - .padding(vertical = 4.dp) - ) { - 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 + val conversationalAwarenessVolume = state.conversationalAwarenessVolume + LaunchedEffect(conversationalAwarenessVolume) { + viewModel.setConversationalAwarenessVolume(conversationalAwarenessVolume) + } + + 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 ) - - 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 - ) - } - - 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)) - - Column( - modifier = Modifier - .fillMaxWidth() - .background( - backgroundColor, RoundedCornerShape(28.dp) - ) - .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 - ) - - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier.padding(horizontal = 12.dp) - ) - - 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, - enabled = state.isPremium, - ) - } - - Spacer(modifier = Modifier.height(16.dp)) - - val conversationalAwarenessVolume = state.conversationalAwarenessVolume - LaunchedEffect(conversationalAwarenessVolume) { - viewModel.setConversationalAwarenessVolume(conversationalAwarenessVolume) - } - - 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 - ) - }, - independent = true, - enabled = state.isPremium - ) + }, + independent = true, + enabled = state.isPremium + ) // if (!BuildConfig.PLAY_BUILD) { // Spacer(modifier = Modifier.height(16.dp)) // -// NavigationButton( +// StyledListItem( // to = "", -// title = stringResource(R.string.camera_control), +// titleRes = 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) +// descriptionRes = 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 - ) - } - - 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)) - - Column( - modifier = Modifier - .fillMaxWidth() - .background( - backgroundColor, RoundedCornerShape(28.dp) - ) - .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 - ) - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier.padding(horizontal = 12.dp) - ) - - 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 - ) - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier.padding(horizontal = 12.dp) - ) - - 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 - ) - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier.padding(horizontal = 12.dp) - ) - - 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 - ) - } - - 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 - ) - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier.padding(horizontal = 12.dp) - ) - - 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 - ) - } - - 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) - ) - - Spacer(modifier = Modifier.height(2.dp)) - + Spacer(modifier = Modifier.height(16.dp)) + if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) { 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( - 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), - ), - modifier = Modifier - ) - } - } - - 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 - ) - })", - 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, + 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 ) } - 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) + StyledList(title = stringResource(R.string.takeover_airpods_state)) { + StyledToggle( + label = stringResource(R.string.takeover_disconnected), + description = stringResource(R.string.takeover_disconnected_desc), + checked = state.takeoverWhenDisconnected, + onCheckedChange = viewModel::setTakeoverWhenDisconnected, + enabled = state.isPremium + ) + StyledToggle( + label = stringResource(R.string.takeover_idle), + description = stringResource(R.string.takeover_idle_desc), + checked = state.takeoverWhenIdle, + onCheckedChange = viewModel::setTakeoverWhenIdle, + enabled = state.isPremium + ) + StyledToggle( + label = stringResource(R.string.takeover_music), + description = stringResource(R.string.takeover_music_desc), + checked = state.takeoverWhenMusic, + onCheckedChange = viewModel::setTakeoverWhenMusic, + enabled = state.isPremium + ) + + StyledToggle( + label = stringResource(R.string.takeover_call), + description = stringResource(R.string.takeover_call_desc), + checked = state.takeoverWhenCall, + onCheckedChange = viewModel::setTakeoverWhenCall, + enabled = state.isPremium ) } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(16.dp)) + StyledList(title = stringResource(R.string.takeover_phone_state)) { + StyledToggle( + label = stringResource(R.string.takeover_ringing_call), + description = stringResource(R.string.takeover_ringing_call_desc), + checked = state.takeoverWhenRingingCall, + onCheckedChange = viewModel::setTakeoverWhenRingingCall, + enabled = state.isPremium + ) + StyledToggle( + label = stringResource(R.string.takeover_media_start), + description = stringResource(R.string.takeover_media_start_desc), + checked = state.takeoverWhenMediaStart, + onCheckedChange = viewModel::setTakeoverWhenMediaStart, + enabled = state.isPremium + ) + } + + StyledToggle( + title = stringResource(R.string.advanced_options), // shouldn't be here, but okay + 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, + enabled = state.isPremium + ) + Spacer(modifier = Modifier.height(16.dp)) + } else { Box( modifier = Modifier - .background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)) - .padding(start = 16.dp, bottom = 2.dp, top = 24.dp, end = 4.dp) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 16.dp) + .padding(top = 16.dp, bottom = 2.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)) - ) + text = stringResource(R.string.customizations_unavailable), + style = MaterialTypography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier ) } + } - Spacer(modifier = Modifier.height(4.dp)) - Column( - modifier = Modifier - .fillMaxWidth() - .background( - backgroundColor, RoundedCornerShape(28.dp) + 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 ) - .clip(RoundedCornerShape(28.dp)) - ) { - NavigationButton( - to = "", - name = stringResource(R.string.email), - navController = navController, - onClick = { contactBottomSheet.value = true }, - independent = false - ) + })", + 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) + } + ) + } - 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 - ) - - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier.padding(horizontal = 12.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) - }, - independent = false + if (!BuildConfig.PLAY_BUILD) { + Spacer(modifier = Modifier.height(16.dp)) + StyledList { + StyledListItem( + name = stringResource(R.string.troubleshooting), + onClick = navigateToTroubleshooting, ) } + } - Spacer(modifier = Modifier.height(20.dp)) - DeviceInfoCard() - Spacer(modifier = Modifier.height(16.dp)) - AppInfoCard() + Spacer(modifier = Modifier.height(8.dp)) - Spacer(modifier = Modifier.height(16.dp)) - - NavigationButton( - to = "open_source_licenses", - name = stringResource(R.string.open_source_licenses), - navController = navController, - independent = true + StyledList(title = stringResource(R.string.contact)) { + StyledListItem( + name = stringResource(R.string.email), + onClick = { contactBottomSheet.value = true }, ) - Spacer(modifier = Modifier.height(bottomPadding)) + StyledListItem( + name = stringResource(R.string.discord), + onClick = { + val intent = + Intent(Intent.ACTION_VIEW, "https://discord.gg/Ts4wupXcmc".toUri()) + context.startActivity(intent) + }, + ) - if (state.showCameraDialog) { - AlertDialog(onDismissRequest = { viewModel.setShowCameraDialog(false) }, title = { + StyledListItem( + name = stringResource(R.string.github_issues), + 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) + }, + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + DeviceInfoCard() + Spacer(modifier = Modifier.height(16.dp)) + AppInfoCard(navigateToReleaseNotesScreen) + + Spacer(modifier = Modifier.height(16.dp)) + + StyledListItem( + name = stringResource(R.string.open_source_licenses), + onClick = navigateToOpenSourceLicenses, + ) + + 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.set_custom_camera_package), + 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 ) - }, 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 - ) - } - }) - } + } + }, 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 } + 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 ) - 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() + ) + 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}" + ) } - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - StyledInputField( - inputState = subjectState, - focusRequester = subjectFocusRequester, - placeholder = stringResource(R.string.subject), + context.startActivity(intent) + subjectState.clearText() + descriptionState.clearText() + } ) + } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(8.dp)) - StyledInputField( - inputState = descriptionState, - focusRequester = descriptionFocusRequester, - placeholder = stringResource(R.string.describe_your_issue), - singleLine = false - ) - } + StyledInputField( + inputState = subjectState, + focusRequester = subjectFocusRequester, + placeholder = stringResource(R.string.subject), + forceApple = true + ) + + Spacer(modifier = Modifier.height(12.dp)) + + StyledInputField( + inputState = descriptionState, + focusRequester = descriptionFocusRequester, + placeholder = stringResource(R.string.describe_your_issue), + singleLine = false, + forceApple = true + ) } } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/CallControlScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/CallControlScreen.kt new file mode 100644 index 00000000..101e4bba --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/CallControlScreen.kt @@ -0,0 +1,97 @@ +package me.kavishdevar.librepods.presentation.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.bluetooth.AACPManager +import me.kavishdevar.librepods.presentation.components.StyledList +import me.kavishdevar.librepods.presentation.components.StyledListItem +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CallControlScreen(viewModel: AirPodsViewModel, action: String, onCallControlValueChanged: (Boolean) -> Unit) { + val state by viewModel.uiState.collectAsState() + + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + + val scrollState = rememberScrollState() + + 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 + } + + val pressOnceText = stringResource(R.string.press_once) + val pressTwiceText = stringResource(R.string.press_twice) + val muteUnmuteText = stringResource(R.string.mute_unmute) + + var singlePressAction by remember { mutableStateOf(if ((action == muteUnmuteText) == !flipped) pressOnceText else pressTwiceText) } + + val pressOnceIsAction by remember { derivedStateOf { singlePressAction == pressOnceText } } + val flippedValue = action != muteUnmuteText + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + .verticalScroll(scrollState) + .padding(top = 8.dp) + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(topPadding)) + + StyledList { + StyledListItem( + name = pressOnceText, + selected = pressOnceIsAction, + onClick = { + singlePressAction = pressOnceText + onCallControlValueChanged(flippedValue) + } + ) + + StyledListItem( + name = pressTwiceText, + selected = !pressOnceIsAction, + onClick = { + singlePressAction = pressTwiceText + onCallControlValueChanged(!flippedValue) + } + ) + } + + Spacer(modifier = Modifier.height(bottomPadding)) + } +} 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 433e7b32..7582fc3a 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 @@ -18,94 +18,64 @@ package me.kavishdevar.librepods.presentation.screens -import android.accessibilityservice.AccessibilityServiceInfo -import android.content.ComponentName -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.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -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.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.viewmodel.AirPodsViewModel - -@Composable -fun CameraControlScreen(viewModel: AirPodsViewModel) { - val context = LocalContext.current - val currentCameraAction by viewModel.cameraAction.collectAsState() - - fun isAppListenerServiceEnabled(context: Context): Boolean { - val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager - val enabledServices = - am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK) - val serviceComponent = ComponentName(context, AppListenerService::class.java) - return enabledServices.any { - it.resolveInfo.serviceInfo.packageName == serviceComponent.packageName && - it.resolveInfo.serviceInfo.name == serviceComponent.className - } - } - - fun handleSelection(action: StemPressType?) { - if (action != null && !isAppListenerServiceEnabled(context)) { - context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) - } else { - viewModel.setCameraAction(action) - } - } - - val cameraOptions = remember(currentCameraAction) { - 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) } - ) - ) - } - - 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) - ) { - Spacer(modifier = Modifier.height(spacerHeight)) - StyledSelectList(items = cameraOptions) - } - } -} +//@Composable +//fun CameraControlScreen(viewModel: AirPodsViewModel) { +// val context = LocalContext.current +// val currentCameraAction by viewModel.cameraAction.collectAsState() +// +// fun isAppListenerServiceEnabled(context: Context): Boolean { +// val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager +// val enabledServices = +// am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK) +// val serviceComponent = ComponentName(context, AppListenerService::class.java) +// return enabledServices.any { +// it.resolveInfo.serviceInfo.packageName == serviceComponent.packageName && +// it.resolveInfo.serviceInfo.name == serviceComponent.className +// } +// } +// +// fun handleSelection(action: StemPressType?) { +// if (action != null && !isAppListenerServiceEnabled(context)) { +// context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) +// } else { +// viewModel.setCameraAction(action) +// } +// } +// +// val cameraOptions = remember(currentCameraAction) { +// 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) } +// ) +// ) +// } +// +// val backdrop = rememberLayerBackdrop() +// +// StyledScaffold( +// titleRes = stringResource(R.string.camera_control) +// ) { spacerHeight -> +// Column( +// modifier = Modifier +// .fillMaxSize() +// .layerBackdrop(backdrop) +// .padding(horizontal = 16.dp), +// verticalArrangement = Arrangement.spacedBy(16.dp) +// ) { +// Spacer(modifier = Modifier.height(spacerHeight)) +// StyledSelectList(items = cameraOptions) +// } +// } +//} 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 deleted file mode 100644 index 75c4bc2d..00000000 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/DebugScreen.kt +++ /dev/null @@ -1,523 +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(ExperimentalHazeMaterialsApi::class, ExperimentalEncodingApi::class) - -package me.kavishdevar.librepods.presentation.screens - -import android.annotation.SuppressLint -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -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.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -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.navigationBarsPadding -import androidx.compose.foundation.layout.padding -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.material3.ExperimentalMaterial3Api -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -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.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.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.services.ServiceManager -import kotlin.io.encoding.ExperimentalEncodingApi - -data class PacketInfo( - val type: String, - val description: String, - val rawData: String, - val parsedData: Map = emptyMap(), - val isUnknown: Boolean = false -) - -fun parsePacket(message: String): PacketInfo { - val rawData = if (message.startsWith("Sent")) message.substring(5) else message.substring(9) - val bytes = rawData.split(" ").mapNotNull { - it.takeIf { it.isNotEmpty() }?.toIntOrNull(16)?.toByte() - }.toByteArray() - - val airPodsService = ServiceManager.getService() - if (airPodsService != null) { - return when { - message.startsWith("Sent") -> parseOutgoingPacket(bytes, rawData) - airPodsService.batteryNotification.isBatteryData(bytes) -> { - val batteryInfo = mutableMapOf() - airPodsService.batteryNotification.setBattery(bytes) - val batteries = airPodsService.batteryNotification.getBattery() - val batteryInfoString = batteries.joinToString(", ") { battery -> - "${battery.getComponentName() ?: "Unknown"}: ${battery.level}% ${if (battery.status == BatteryStatus.CHARGING) "(Charging)" else ""}" - } - batteries.forEach { battery -> - if (battery.status != BatteryStatus.DISCONNECTED) { - batteryInfo[battery.getComponentName() ?: "Unknown"] = - "${battery.level}% ${if (battery.status == BatteryStatus.CHARGING) "(Charging)" else ""}" - } - } - - PacketInfo( - "Battery", - batteryInfoString, - rawData, - batteryInfo - ) - } - airPodsService.ancNotification.isANCData(bytes) -> { - airPodsService.ancNotification.setStatus(bytes) - val mode = when (airPodsService.ancNotification.status) { - 1 -> "Off" - 2 -> "Noise Cancellation" - 3 -> "Transparency" - 4 -> "Adaptive" - else -> "Unknown" - } - - PacketInfo( - "Noise Control", - "Mode: $mode", - rawData, - mapOf("Mode" to mode) - ) - } - airPodsService.earDetectionNotification.isEarDetectionData(bytes) -> { - airPodsService.earDetectionNotification.setStatus(bytes) - val status = airPodsService.earDetectionNotification.status - val primaryStatus = if (status[0] == 0.toByte()) "In ear" else "Out of ear" - val secondaryStatus = if (status[1] == 0.toByte()) "In ear" else "Out of ear" - - PacketInfo( - "Ear Detection", - "Primary: $primaryStatus, Secondary: $secondaryStatus", - rawData, - mapOf("Primary" to primaryStatus, "Secondary" to secondaryStatus) - ) - } - airPodsService.conversationAwarenessNotification.isConversationalAwarenessData(bytes) -> { - airPodsService.conversationAwarenessNotification.setData(bytes) - val statusMap = mapOf( - 1.toByte() to "Started speaking", - 2.toByte() to "Speaking", - 8.toByte() to "Stopped speaking", - 9.toByte() to "Not speaking" - ) - val status = statusMap[airPodsService.conversationAwarenessNotification.status] ?: - "Unknown (${airPodsService.conversationAwarenessNotification.status})" - - PacketInfo( - "Conversation Awareness", - "Status: $status", - rawData, - mapOf("Status" to status) - ) - } - isHeadTrackingData(bytes) -> { - val horizontal = if (bytes.size >= 53) - "${bytes[51].toInt() and 0xFF or (bytes[52].toInt() shl 8)}" else "Unknown" - val vertical = if (bytes.size >= 55) - "${bytes[53].toInt() and 0xFF or (bytes[54].toInt() shl 8)}" else "Unknown" - - PacketInfo( - "Head Tracking", - "Position data", - rawData, - mapOf("Horizontal" to horizontal, "Vertical" to vertical) - ) - } - else -> PacketInfo("Unknown", "Unknown packet format", rawData, emptyMap(), true) - } - } else { - return if (message.startsWith("Sent")) { - parseOutgoingPacket(bytes, rawData) - } else { - PacketInfo("Unknown", "Unknown packet format", rawData, emptyMap(), true) - } - } -} - -fun parseOutgoingPacket(bytes: ByteArray, rawData: String): PacketInfo { - if (bytes.size < 7) { - return PacketInfo("Unknown", "Unknown outgoing packet", rawData, emptyMap(), true) - } - - return when { - bytes.size >= 16 && - bytes[0] == 0x00.toByte() && - bytes[1] == 0x00.toByte() && - bytes[2] == 0x04.toByte() && - bytes[3] == 0x00.toByte() -> { - PacketInfo("Handshake", "Initial handshake with AirPods", rawData) - } - - bytes.size >= 11 && - bytes[0] == 0x04.toByte() && - bytes[1] == 0x00.toByte() && - bytes[2] == 0x04.toByte() && - bytes[3] == 0x00.toByte() && - bytes[4] == 0x09.toByte() && - bytes[5] == 0x00.toByte() && - bytes[6] == 0x0d.toByte() -> { - val mode = when (bytes[7].toInt()) { - 1 -> "Off" - 2 -> "Noise Cancellation" - 3 -> "Transparency" - 4 -> "Adaptive" - else -> "Unknown" - } - PacketInfo("Noise Control", "Set mode to $mode", rawData, mapOf("Mode" to mode)) - } - - bytes.size >= 11 && - bytes[0] == 0x04.toByte() && - bytes[1] == 0x00.toByte() && - bytes[2] == 0x04.toByte() && - bytes[3] == 0x00.toByte() && - bytes[4] == 0x09.toByte() && - bytes[5] == 0x00.toByte() && - bytes[6] == 0x28.toByte() -> { - val mode = if (bytes[7].toInt() == 1) "On" else "Off" - PacketInfo("Conversation Awareness", "Set mode to $mode", rawData, mapOf("Mode" to mode)) - } - - bytes.size > 10 && - bytes[0] == 0x04.toByte() && - bytes[1] == 0x00.toByte() && - bytes[2] == 0x04.toByte() && - bytes[3] == 0x00.toByte() && - bytes[4] == 0x17.toByte() -> { - val action = if (bytes.joinToString(" ") { "%02X".format(it) }.contains("A1 02")) "Start" else "Stop" - PacketInfo("Head Tracking", "$action head tracking", rawData) - } - - bytes.size >= 11 && - bytes[0] == 0x04.toByte() && - bytes[1] == 0x00.toByte() && - bytes[2] == 0x04.toByte() && - bytes[3] == 0x00.toByte() && - bytes[4] == 0x09.toByte() && - bytes[5] == 0x00.toByte() && - bytes[6] == 0x1A.toByte() -> { - PacketInfo("Long Press Config", "Change long press modes", rawData) - } - - bytes.size >= 9 && - bytes[0] == 0x04.toByte() && - bytes[1] == 0x00.toByte() && - bytes[2] == 0x04.toByte() && - bytes[3] == 0x00.toByte() && - bytes[4] == 0x4d.toByte() -> { - PacketInfo("Feature Request", "Set specific features", rawData) - } - - bytes.size >= 9 && - bytes[0] == 0x04.toByte() && - bytes[1] == 0x00.toByte() && - bytes[2] == 0x04.toByte() && - bytes[3] == 0x00.toByte() && - bytes[4] == 0x0f.toByte() -> { - PacketInfo("Notifications", "Request notifications", rawData) - } - - else -> PacketInfo("Unknown", "Unknown outgoing packet", rawData, emptyMap(), true) - } -} - -@RequiresApi(Build.VERSION_CODES.Q) -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, ExperimentalFoundationApi::class) -@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter", "UnspecifiedRegisterReceiverFlag") -@Composable -fun DebugScreen(navController: NavController) { - val context = LocalContext.current - val listState = rememberLazyListState() - val focusManager = LocalFocusManager.current - val coroutineScope = rememberCoroutineScope() - - val airPodsService = remember { ServiceManager.getService() } - val packetLogs = airPodsService?.packetLogsFlow?.collectAsState(emptySet())?.value ?: emptySet() - - val refreshTrigger = remember { mutableIntStateOf(0) } - LaunchedEffect(refreshTrigger.intValue) { - while(true) { - delay(1000) - refreshTrigger.intValue += 1 - } - } - - val expandedItems = remember { mutableStateOf(setOf()) } - - fun copyToClipboard(text: String) { - val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - val clip = ClipData.newPlainText("Packet Data", text) - clipboard.setPrimaryClip(clip) - Toast.makeText(context, "Packet copied to clipboard", Toast.LENGTH_SHORT).show() - } - - LaunchedEffect(packetLogs.size, refreshTrigger.intValue) { - if (packetLogs.isNotEmpty()) { - listState.animateScrollToItem(packetLogs.size - 1) - } - } - - val isDarkTheme = isSystemInDarkTheme() - val backdrop = rememberLayerBackdrop() - StyledScaffold( - title = "Debug", - actionButtons = listOf( - {scaffoldBackdrop -> - StyledIconButton( - onClick = { - airPodsService?.clearLogs() - expandedItems.value = emptySet() - }, - icon = "􀈑", - backdrop = scaffoldBackdrop - ) - } - ), - ) { topPadding, hazeState, bottomPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .hazeSource(hazeState) - .navigationBarsPadding() - .layerBackdrop(backdrop) - .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( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 2.dp) - .combinedClickable( - onClick = { - expandedItems.value = if (isExpanded) { - expandedItems.value - index - } else { - expandedItems.value + index - } - }, - onLongClick = { - 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) - ), - ) - Spacer(modifier = Modifier.width(4.dp)) - Column { - Text( - text = if (packetInfo.isUnknown) { - val shortenedData = packetInfo.rawData.take(60) + - (if (packetInfo.rawData.length > 60) "..." else "") - shortenedData - } else { - "${packetInfo.type}: ${packetInfo.description}" - }, - style = TextStyle( - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - fontFamily = FontFamily(Font(R.font.hack)) - ) - ) - if (isExpanded) { - Spacer(modifier = Modifier.height(4.dp)) - - if (packetInfo.parsedData.isNotEmpty()) { - packetInfo.parsedData.forEach { (key, value) -> - Row { - Text( - text = "$key: ", - style = TextStyle( - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - fontFamily = FontFamily(Font(R.font.hack)) - ), - color = Color.Gray - ) - Text( - text = value, - style = TextStyle( - fontSize = 12.sp, - fontFamily = FontFamily(Font(R.font.hack)) - ), - color = Color.Gray - ) - } - } - Spacer(modifier = Modifier.height(4.dp)) - } - - Text( - text = "Raw: ${packetInfo.rawData}", - style = TextStyle( - fontSize = 12.sp, - fontFamily = FontFamily(Font(R.font.hack)) - ), - color = Color.Gray - ) - } - } - } - } - } - } - ) - 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 - ) { - val packet = remember { mutableStateOf(TextFieldValue("")) } - TextField( - value = packet.value, - onValueChange = { packet.value = it }, - label = { Text("Packet") }, - 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") - } - }, - colors = TextFieldDefaults.colors( - focusedContainerColor = if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7), - unfocusedContainerColor = if (isSystemInDarkTheme()) Color(0xFF1C1B20) else Color(0xFFF2F2F7), - 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), - ), - shape = RoundedCornerShape(12.dp) - ) - } - } - } -} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/EqualizerScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/EqualizerScreen.kt index d6ecd76f..408e6f57 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/EqualizerScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/EqualizerScreen.kt @@ -31,22 +31,29 @@ 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.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues 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.navigationBars import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.visible import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -69,6 +76,8 @@ import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -81,578 +90,673 @@ import com.kyant.backdrop.highlight.Highlight import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.debounce import me.kavishdevar.librepods.R -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.components.StyledList +import me.kavishdevar.librepods.presentation.components.StyledListItem +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.viewmodel.AirPodsUiState import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel +import me.kavishdevar.librepods.presentation.viewmodel.demoState import kotlin.math.abs import kotlin.math.roundToInt import kotlin.time.Duration.Companion.milliseconds -@OptIn(FlowPreview::class) @Composable -fun EqualizerScreen(viewModel: AirPodsViewModel) { +fun EqualizerRoute(viewModel: AirPodsViewModel) { val state by viewModel.uiState.collectAsState() - val customEq = state.customEq - val enabled = customEq.isEnabled() + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - val recommendedString = stringResource(R.string.recommended) - val customString = stringResource(R.string.custom) - - val eqStateOptions = remember(state.customEq) { - listOf( - SelectItem( - name = recommendedString, - selected = !enabled, - onClick = { viewModel.setCustomEqEnabled(false) } - ), - SelectItem( - name = customString, - selected = enabled, - onClick = { viewModel.setCustomEqEnabled(true) } - ), + Box ( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + EqualizerScreen( + state = state, + topPadding = topPadding, + bottomPadding = bottomPadding, + setCustomEqEnabled = viewModel::setCustomEqEnabled, + setCustomEq = viewModel::setCustomEq ) } +} - StyledScaffold( - title = stringResource(R.string.equalizer) - ) { spacerHeight -> - val scrollState = rememberScrollState() - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp) - .verticalScroll(scrollState), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { +@OptIn(FlowPreview::class) +@Composable +fun EqualizerScreen( + state: AirPodsUiState, + topPadding: Dp = 16.dp, + bottomPadding: Dp = 16.dp, + setCustomEqEnabled: (Boolean) -> Unit, + setCustomEq: (Int, Int, Int) -> Unit +) { + val customEq = state.customEq - val height = 200.dp - val maxOffset = with(LocalDensity.current) { height.toPx() } / 2 + val scrollState = rememberScrollState() - val offsets = remember(state.customEq) { - listOf( - mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.low.toFloat() / 100)), - mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.mid.toFloat() / 100)), - mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.high.toFloat() / 100)) + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + val height = 200.dp + val maxOffset = with(LocalDensity.current) { height.toPx() } / 2 + + val offsets = remember(state.customEq) { + listOf( + mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.low.toFloat() / 100)), + mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.mid.toFloat() / 100)), + mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.high.toFloat() / 100)) + ) + } + + LaunchedEffect(offsets) { + snapshotFlow { + Triple( + offsets[0].floatValue, + offsets[1].floatValue, + offsets[2].floatValue ) } + .debounce(100.milliseconds) // cool, should've been using this since the very beginning + .collect { (lowF, midF, highF) -> + val low = + 100 - ((lowF / (2 * maxOffset) + 0.5f) * 100).roundToInt() + val mid = + 100 - ((midF / (2 * maxOffset) + 0.5f) * 100).roundToInt() + val high = + 100 - ((highF / (2 * maxOffset) + 0.5f) * 100).roundToInt() - Spacer(modifier = Modifier.height(spacerHeight)) - StyledSelectList(items = eqStateOptions) - Spacer(modifier = Modifier.height(12.dp)) - val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) + setCustomEq(low, mid, high) + } + } + + Spacer(modifier = Modifier.height(topPadding)) + + val enabled = customEq.isEnabled() + + StyledList { + StyledListItem( + name = stringResource(R.string.recommended), + selected = !enabled, + onClick = { setCustomEqEnabled(false) } + ) + + StyledListItem( + name = stringResource(R.string.custom), + selected = enabled, + onClick = { setCustomEqEnabled(true) } + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Crossfade ( + customEq.isEnabled() + ) { visible -> + Column( + modifier = Modifier + .fillMaxWidth() + .visible(visible), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + + EqualizerCard( + lowOffset = offsets[0], + midOffset = offsets[1], + highOffset = offsets[2] + ) + + val resetButtonEnabled = remember { derivedStateOf { !offsets.all { it.floatValue == 0f } } } + + StyledButton( + onClick = { + offsets[0].floatValue = 0f + offsets[1].floatValue = 0f + offsets[2].floatValue = 0f + }, + backdrop = rememberLayerBackdrop(), + modifier = Modifier.fillMaxWidth(), + isInteractive = false, + enabled = resetButtonEnabled.value + ) { + Text( + text = stringResource(R.string.reset), + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + + Spacer(modifier = Modifier.height(bottomPadding)) + } +} + + +@Composable +fun EqualizerCard( + lowOffset: MutableState, + midOffset: MutableState, + highOffset: MutableState +) { + val height = 200.dp + val maxOffset = with(LocalDensity.current) { height.toPx() } / 2 + + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(28.dp)) + ) { + val dashColor = if (isSystemInDarkTheme()) Color(0x80AAAAAA) else Color(0x809D9D9D) + + val backdrop = rememberLayerBackdrop() + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface, RoundedCornerShape(28.dp)) + ) { + Spacer(modifier = Modifier.height(42.dp)) + // Row( + // modifier = Modifier + // .fillMaxWidth() + // .padding(18.dp), + // verticalAlignment = Alignment.CenterVertically, + // horizontalArrangement = Arrangement.spacedBy(12.dp) + // ) { + // Box( + // modifier = Modifier + // .size(64.dp) + // .background(if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray, RoundedCornerShape(12.dp)) + // ) + // Column( + // modifier = Modifier + // .weight(1f), + // verticalArrangement = Arrangement.Center + // ) { + // Text( + // text = "Written into Changes", + // style = TextStyle( + // fontSize = 16.sp, + // fontFamily = FontFamily(Font(R.font.sf_pro)), + // fontWeight = FontWeight.Bold, + // color = if (isSystemInDarkTheme()) Color.White else Color.Black + // ) + // ) + // Spacer(modifier = Modifier.height(4.dp)) + // Text( + // text = "Avalon Emerson", + // style = TextStyle( + // fontSize = 14.sp, + // fontFamily = FontFamily(Font(R.font.sf_pro)), + // fontWeight = FontWeight.Normal, + // color = if (isSystemInDarkTheme()) Color.White else Color.Black + // ) + // ) + // } + // val paused = remember { mutableStateOf(false) } + // Box( + // modifier = Modifier + // .size(48.dp) + // .background(Color(0x600091FF), CircleShape) + // .clickable( + // interactionSource = remember { MutableInteractionSource() }, + // indication = null, + // ) { + // paused.value = !paused.value + // }, + // contentAlignment = Alignment.Center + // ) { + // Crossfade( + // targetState = paused.value, + // label = "media_icon" + // ) { p -> + // Text( + // text = if (p) "􀊄" else "􀊆", + // style = TextStyle( + // fontSize = 24.sp, + // fontFamily = FontFamily(Font(R.font.sf_pro)), + // fontWeight = FontWeight.Normal, + // color = Color(0xFF0091FF), + // textAlign = TextAlign.Center + // ) + // ) + // } + // } + // } + // + // HorizontalDivider( + // thickness = 1.dp, + // color = Color(0x40888888), + // modifier = Modifier + // .padding(horizontal = 20.dp) + // .padding(bottom = 16.dp) + // ) + + Box( + modifier = Modifier.fillMaxWidth() + ) { + fun colorFromY(y: Float): Color { + val f = ((y + maxOffset) / (2f * maxOffset)).coerceIn(0f, 1f) + val stops = listOf( + 0.0f to Color(0xFFFFA300), + 0.25f to Color(0xFFFCE600), + 0.5f to Color(0xFF00FAAF), + 0.75f to Color(0xFF00FAFF), + 1.0f to Color(0xFF00B5FF) + ) + val (start, end) = stops.zipWithNext() + .first { f <= it.second.first } + val c = (f - start.first) / (end.first - start.first) + return lerp(start.second, end.second, c) + } + + fun pathBrush( + startY: Float, + endY: Float, + ): Brush { + val stops = (0..20).map { i -> + val t = i / 20f + val y = lerp(startY, endY, t) + t to colorFromY(y) + } + + return Brush.linearGradient( + colorStops = stops.toTypedArray() + ) + } - Crossfade ( - customEq.isEnabled() - ) { visible -> Column( modifier = Modifier .fillMaxWidth() - .visible(visible), - verticalArrangement = Arrangement.spacedBy(16.dp) + .layerBackdrop(backdrop) ) { - Column( + Box( modifier = Modifier .fillMaxWidth() - .background(backgroundColor, RoundedCornerShape(28.dp)) + .height(height) + .padding(horizontal = 20.dp) ) { - val dashColor = - if (isSystemInDarkTheme()) Color(0x80AAAAAA) else Color(0x809D9D9D) - // LaunchedEffect(offsets[0].floatValue, offsets[1].floatValue, offsets[2].floatValue) { - // val low = ((offsets[0].floatValue / (2 * maxOffset) + 0.5f) * 100).roundToInt() - // val mid = ((offsets[1].floatValue / (2 * maxOffset) + 0.5f) * 100).roundToInt() - // val high = ((offsets[2].floatValue / (2 * maxOffset) + 0.5f) * 100).roundToInt() - // Log.d("EqualizerScreen", "$low, $mid, $high") - // viewModel.setCustomEq( - // low = low, - // mid = mid, - // high = high - // ) - // } - - LaunchedEffect(offsets) { - snapshotFlow { - Triple( - offsets[0].floatValue, - offsets[1].floatValue, - offsets[2].floatValue - ) - } - .debounce(100.milliseconds) // cool, should've been using this since the very beginning - .collect { (lowF, midF, highF) -> - val low = - 100 - ((lowF / (2 * maxOffset) + 0.5f) * 100).roundToInt() - val mid = - 100 - ((midF / (2 * maxOffset) + 0.5f) * 100).roundToInt() - val high = - 100 - ((highF / (2 * maxOffset) + 0.5f) * 100).roundToInt() - - viewModel.setCustomEq(low, mid, high) + Row( + modifier = Modifier + .fillMaxSize() + ) { + val dashCount = (height / 10.dp).toInt() + repeat(3) { + Box( + modifier = Modifier + .fillMaxSize() + .weight(1f), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .fillMaxHeight(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + for (i in 1..(dashCount)) { + val t = i.toFloat() / dashCount + val centerDistance = abs(0.5f - t) + val alpha = 1f - (centerDistance * 2f) + Box( + modifier = Modifier + .height(9.dp) + .width(0.75.dp) + .background( + dashColor.copy(alpha), + RoundedCornerShape(28.dp) + ) + ) + } + } } + } } - val backdrop = rememberLayerBackdrop() - Column( + val backgroundColor = MaterialTheme.colorScheme.surface + + Canvas( modifier = Modifier - .fillMaxWidth() - .background(backgroundColor, RoundedCornerShape(28.dp)) + .fillMaxSize() ) { - Spacer(modifier = Modifier.height(42.dp)) - // Row( - // modifier = Modifier - // .fillMaxWidth() - // .padding(18.dp), - // verticalAlignment = Alignment.CenterVertically, - // horizontalArrangement = Arrangement.spacedBy(12.dp) - // ) { - // Box( - // modifier = Modifier - // .size(64.dp) - // .background(if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray, RoundedCornerShape(12.dp)) - // ) - // Column( - // modifier = Modifier - // .weight(1f), - // verticalArrangement = Arrangement.Center - // ) { - // Text( - // text = "Written into Changes", - // style = TextStyle( - // fontSize = 16.sp, - // fontFamily = FontFamily(Font(R.font.sf_pro)), - // fontWeight = FontWeight.Bold, - // color = if (isSystemInDarkTheme()) Color.White else Color.Black - // ) - // ) - // Spacer(modifier = Modifier.height(4.dp)) - // Text( - // text = "Avalon Emerson", - // style = TextStyle( - // fontSize = 14.sp, - // fontFamily = FontFamily(Font(R.font.sf_pro)), - // fontWeight = FontWeight.Normal, - // color = if (isSystemInDarkTheme()) Color.White else Color.Black - // ) - // ) - // } - // val paused = remember { mutableStateOf(false) } - // Box( - // modifier = Modifier - // .size(48.dp) - // .background(Color(0x600091FF), CircleShape) - // .clickable( - // interactionSource = remember { MutableInteractionSource() }, - // indication = null, - // ) { - // paused.value = !paused.value - // }, - // contentAlignment = Alignment.Center - // ) { - // Crossfade( - // targetState = paused.value, - // label = "media_icon" - // ) { p -> - // Text( - // text = if (p) "􀊄" else "􀊆", - // style = TextStyle( - // fontSize = 24.sp, - // fontFamily = FontFamily(Font(R.font.sf_pro)), - // fontWeight = FontWeight.Normal, - // color = Color(0xFF0091FF), - // textAlign = TextAlign.Center - // ) - // ) - // } - // } - // } - // - // HorizontalDivider( - // thickness = 1.dp, - // color = Color(0x40888888), - // modifier = Modifier - // .padding(horizontal = 20.dp) - // .padding(bottom = 16.dp) - // ) + val canvasWidth = size.width - Box( + drawLine( + color = backgroundColor, + start = Offset( + x = 0f, + y = lowOffset.value + maxOffset + ), + end = Offset( + x = 1 / 6f * canvasWidth, + y = lowOffset.value + maxOffset + ), + strokeWidth = 10f + ) + drawLine( + color = colorFromY(lowOffset.value), + start = Offset( + x = 0f, + y = lowOffset.value + maxOffset + ), + end = Offset( + x = 1 / 6f * canvasWidth, + y = lowOffset.value + maxOffset + ), + strokeWidth = 8f + ) + + val lowToMidPath = Path() + lowToMidPath.moveTo( + x = 1 / 6f * canvasWidth, + y = lowOffset.value + maxOffset + ) + lowToMidPath.cubicTo( + x1 = canvasWidth * 1 / 6f + 108.dp.value, + y1 = lowOffset.value + maxOffset, + x2 = canvasWidth * 0.5f - 108.dp.value, + y2 = midOffset.value + maxOffset, + x3 = canvasWidth * 0.5f, + y3 = midOffset.value + maxOffset + ) + drawPath( + color = backgroundColor, + path = lowToMidPath, + style = Stroke(width = 10f) + ) + drawPath( + brush = pathBrush( + lowOffset.value, + midOffset.value + ), + path = lowToMidPath, + style = Stroke(width = 8f) + ) + + val midToHighPath = Path() + midToHighPath.moveTo( + x = 0.5f * canvasWidth, + y = midOffset.value + maxOffset + ) + midToHighPath.cubicTo( + x1 = canvasWidth * 0.5f + 108.dp.value, + y1 = midOffset.value + maxOffset, + x2 = canvasWidth * 5 / 6f - 108.dp.value, + y2 = highOffset.value + maxOffset, + x3 = canvasWidth * 5 / 6f, + y3 = highOffset.value + maxOffset + ) + drawPath( + color = backgroundColor, + path = midToHighPath, + style = Stroke(width = 10f) + ) + drawPath( + brush = pathBrush( + midOffset.value, + highOffset.value + ), + path = midToHighPath, + style = Stroke(width = 8f) + ) + drawLine( + color = backgroundColor, + start = Offset( + x = 5 / 6f * canvasWidth, + y = highOffset.value + maxOffset + ), + end = Offset( + x = 1f * canvasWidth, + y = highOffset.value + maxOffset + ), + strokeWidth = 10f + ) + drawLine( + color = colorFromY(highOffset.value), + start = Offset( + x = 5 / 6f * canvasWidth, + y = highOffset.value + maxOffset + ), + end = Offset( + x = 1f * canvasWidth, + y = highOffset.value + maxOffset + ), + strokeWidth = 8f + ) + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp, horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier.weight(1f) + ) { + Text( + text = "Low".uppercase(), + style = TextStyle( + fontSize = 14.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontWeight = FontWeight.Bold, + color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy( + 0.2f + ), + textAlign = TextAlign.Center + ), modifier = Modifier.fillMaxWidth() + ) + } + Box( + modifier = Modifier.weight(1f) + ) { + Text( + text = "Mid".uppercase(), + style = TextStyle( + fontSize = 14.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontWeight = FontWeight.Bold, + color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy( + 0.2f + ), + textAlign = TextAlign.Center + ), + modifier = Modifier.fillMaxWidth() + ) + } + Box( + modifier = Modifier.weight(1f) + ) { + Text( + text = "High".uppercase(), + style = TextStyle( + fontSize = 14.sp, + fontFamily = FontFamily(Font(R.font.sf_pro)), + fontWeight = FontWeight.Bold, + color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy( + 0.2f + ), + textAlign = TextAlign.Center + ), + modifier = Modifier.fillMaxWidth() + ) + } + } + Spacer(modifier = Modifier.height(24.dp)) + } + Row( + modifier = Modifier + .fillMaxWidth() + .height(height) + .padding(horizontal = 20.dp), + + verticalAlignment = Alignment.CenterVertically + ) { + for (i in 0..2) { + Row( + modifier = Modifier + .weight(1f), + horizontalArrangement = Arrangement.Center + ) { + val pressed = remember { mutableStateOf(false) } + Box( + modifier = Modifier + .offset { + IntOffset( + x = 0, + y = when (i) { 0 -> lowOffset.value; 1 -> midOffset.value; 2-> highOffset.value else -> 0f}.roundToInt() + ) + }, + contentAlignment = Alignment.Center ) { - fun colorFromY(y: Float): Color { - val f = ((y + maxOffset) / (2f * maxOffset)).coerceIn(0f, 1f) - val stops = listOf( - 0.0f to Color(0xFFFFA300), - 0.25f to Color(0xFFFCE600), - 0.5f to Color(0xFF00FAAF), - 0.75f to Color(0xFF00FAFF), - 1.0f to Color(0xFF00B5FF) - ) - val (start, end) = stops.zipWithNext() - .first { f <= it.second.first } - val c = (f - start.first) / (end.first - start.first) - return lerp(start.second, end.second, c) - } - - fun pathBrush( - startY: Float, - endY: Float, - ): Brush { - val stops = (0..20).map { i -> - val t = i / 20f - val y = lerp(startY, endY, t) - t to colorFromY(y) - } - - return Brush.linearGradient( - colorStops = stops.toTypedArray() - ) - } - - Column( - modifier = Modifier.fillMaxWidth().layerBackdrop(backdrop) + Crossfade( + pressed.value ) { Box( modifier = Modifier - .fillMaxWidth() - .height(height) - .padding(horizontal = 20.dp) - ) { - Row( - modifier = Modifier - .fillMaxSize() - ) { - val dashCount = (height / 10.dp).toInt() - repeat(3) { - Box( - modifier = Modifier - .fillMaxSize() - .weight(1f), - contentAlignment = Alignment.Center - ) { - Column( - modifier = Modifier - .fillMaxHeight(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - for (i in 1..(dashCount)) { - val t = i.toFloat() / dashCount - val centerDistance = abs(0.5f - t) - val alpha = 1f - (centerDistance * 2f) - Box( - modifier = Modifier - .height(9.dp) - .width(0.75.dp) - .background( - dashColor.copy(alpha), - RoundedCornerShape(28.dp) - ) + .size(96.dp) + .then( + if (it) { + Modifier.drawBackdrop( + backdrop = backdrop, + shape = { CircleShape }, + highlight = { + Highlight.Ambient + }, + onDrawSurface = { + drawCircle( + color = Color.White.copy( + 0.2f + ), + radius = size.height + ) + drawCircle( + color = colorFromY( + when (i) { + 0 -> lowOffset.value; 1 -> midOffset.value; 2 -> highOffset.value + else -> 0f + } + ), + style = Stroke(2.dp.value), + radius = size.height / 2 + ) + }, + effects = { + lens( + refractionHeight = 32f.dp.value, + refractionAmount = size.height ) } + ) + } else Modifier + ) + ) + } + Box( + modifier = Modifier + .size(18.dp) + .background( + colorFromY( + when (i) { + 0 -> lowOffset.value; 1 -> midOffset.value; 2 -> highOffset.value + else -> 0f + } + ), + CircleShape + ) + .border( + 2.5.dp, + MaterialTheme.colorScheme.surfaceContainer, + CircleShape + ) + .draggable( + orientation = Orientation.Vertical, + state = rememberDraggableState { delta -> + when (i) { + 0 -> { + lowOffset.value = + (lowOffset.value + delta).coerceIn( + -maxOffset, + maxOffset + ) + } + + 1 -> { + midOffset.value = + (midOffset.value + delta).coerceIn( + -maxOffset, + maxOffset + ) + } + + 2 -> { + highOffset.value = + (highOffset.value + delta).coerceIn( + -maxOffset, + maxOffset + ) } } + }, + onDragStarted = { + pressed.value = true + }, + onDragStopped = { + pressed.value = false } - } - - Canvas( - modifier = Modifier - .fillMaxSize() - ) { - val canvasWidth = size.width - - drawLine( - color = backgroundColor, - start = Offset( - x = 0f, - y = offsets[0].floatValue + maxOffset - ), - end = Offset( - x = 1 / 6f * canvasWidth, - y = offsets[0].floatValue + maxOffset - ), - strokeWidth = 10f - ) - drawLine( - color = colorFromY(offsets[0].floatValue), - start = Offset( - x = 0f, - y = offsets[0].floatValue + maxOffset - ), - end = Offset( - x = 1 / 6f * canvasWidth, - y = offsets[0].floatValue + maxOffset - ), - strokeWidth = 8f - ) - - val lowToMidPath = Path() - lowToMidPath.moveTo( - x = 1 / 6f * canvasWidth, - y = offsets[0].floatValue + maxOffset - ) - lowToMidPath.cubicTo( - x1 = canvasWidth * 1 / 6f + 108.dp.value, - y1 = offsets[0].floatValue + maxOffset, - x2 = canvasWidth * 0.5f - 108.dp.value, - y2 = offsets[1].floatValue + maxOffset, - x3 = canvasWidth * 0.5f, - y3 = offsets[1].floatValue + maxOffset - ) - drawPath( - color = backgroundColor, - path = lowToMidPath, - style = Stroke(width = 10f) - ) - drawPath( - brush = pathBrush( - offsets[0].floatValue, - offsets[1].floatValue - ), - path = lowToMidPath, - style = Stroke(width = 8f) - ) - - val midToHighPath = Path() - midToHighPath.moveTo( - x = 0.5f * canvasWidth, - y = offsets[1].floatValue + maxOffset - ) - midToHighPath.cubicTo( - x1 = canvasWidth * 0.5f + 108.dp.value, - y1 = offsets[1].floatValue + maxOffset, - x2 = canvasWidth * 5 / 6f - 108.dp.value, - y2 = offsets[2].floatValue + maxOffset, - x3 = canvasWidth * 5 / 6f, - y3 = offsets[2].floatValue + maxOffset - ) - drawPath( - color = backgroundColor, - path = midToHighPath, - style = Stroke(width = 10f) - ) - drawPath( - brush = pathBrush( - offsets[1].floatValue, - offsets[2].floatValue - ), - path = midToHighPath, - style = Stroke(width = 8f) - ) - drawLine( - color = backgroundColor, - start = Offset( - x = 5 / 6f * canvasWidth, - y = offsets[2].floatValue + maxOffset - ), - end = Offset( - x = 1f * canvasWidth, - y = offsets[2].floatValue + maxOffset - ), - strokeWidth = 10f - ) - drawLine( - color = colorFromY(offsets[2].floatValue), - start = Offset( - x = 5 / 6f * canvasWidth, - y = offsets[2].floatValue + maxOffset - ), - end = Offset( - x = 1f * canvasWidth, - y = offsets[2].floatValue + maxOffset - ), - strokeWidth = 8f - ) - } - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp, horizontal = 20.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier.weight(1f) - ) { - Text( - text = "Low".uppercase(), - style = TextStyle( - fontSize = 14.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Bold, - color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy( - 0.2f - ), - textAlign = TextAlign.Center - ), - modifier = Modifier.fillMaxWidth() - ) - } - Box( - modifier = Modifier.weight(1f) - ) { - Text( - text = "Mid".uppercase(), - style = TextStyle( - fontSize = 14.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Bold, - color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy( - 0.2f - ), - textAlign = TextAlign.Center - ), - modifier = Modifier.fillMaxWidth() - ) - } - Box( - modifier = Modifier.weight(1f) - ) { - Text( - text = "High".uppercase(), - style = TextStyle( - fontSize = 14.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Bold, - color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy( - 0.2f - ), - textAlign = TextAlign.Center - ), - modifier = Modifier.fillMaxWidth() - ) - } - } - Spacer(modifier = Modifier.height(24.dp)) - } - Row( - modifier = Modifier - .fillMaxWidth() - .height(height) - .padding(horizontal = 20.dp), - - verticalAlignment = Alignment.CenterVertically - ) { - for (i in 0..2) { - Row( - modifier = Modifier - .weight(1f), - horizontalArrangement = Arrangement.Center - ) { - val pressed = remember { mutableStateOf(false) } - Box( - modifier = Modifier - .offset { - IntOffset( - x = 0, - y = offsets[i].floatValue.roundToInt() - ) - }, - contentAlignment = Alignment.Center - ) { - Crossfade( - pressed.value - ) { - Box( - modifier = Modifier - .size(96.dp) - .then( - if (it) { - Modifier.drawBackdrop( - backdrop = backdrop, - shape = { CircleShape }, - highlight = { - Highlight.Ambient - }, - onDrawSurface = { - drawCircle( - color = Color.White.copy( - 0.2f - ), - radius = size.height - ) - drawCircle( - color = colorFromY( - offsets[i].floatValue - ), - style = Stroke(2.dp.value), - radius = size.height / 2 - ) - }, - effects = { - lens( - refractionHeight = 32f.dp.value, - refractionAmount = size.height - ) - } - ) - } else Modifier - ) - ) - } - Box( - modifier = Modifier - .size(18.dp) - .background( - colorFromY(offsets[i].floatValue), - CircleShape - ) - .border( - 2.5.dp, - backgroundColor, - CircleShape - ) - .draggable( - orientation = Orientation.Vertical, - state = rememberDraggableState { delta -> - offsets[i].floatValue = - (offsets[i].floatValue + delta).coerceIn( - -maxOffset, - maxOffset - ) - }, - onDragStarted = { - pressed.value = true - }, - onDragStopped = { - pressed.value = false - } - ) - ) - } - } - } - } + ) + ) } } } - - val resetButtonEnabled = remember { derivedStateOf { !offsets.all { it.floatValue == 0f } } } - - StyledButton( - onClick = { - offsets[0].floatValue = 0f - offsets[1].floatValue = 0f - offsets[2].floatValue = 0f - }, - backdrop = rememberLayerBackdrop(), - modifier = Modifier.fillMaxWidth(), - isInteractive = false, - surfaceColor = backgroundColor, - enabled = resetButtonEnabled.value - ) { - Text( - text = stringResource(R.string.reset), - style = TextStyle( - fontSize = 14.sp, - fontFamily = FontFamily(Font(R.font.sf_pro)), - fontWeight = FontWeight.Normal, - color = if (!offsets.all { it.floatValue == 0f }) Color(0xFF0093FF) else Color.Gray - ) - ) - } } } } } } + +@Preview(name = "Apple") +@Composable +fun EqualizerScreenPreviewApple() { + LibrePodsTheme( + m3eEnabled = false + ) { + Box ( + modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer) + ) { + EqualizerScreen( + state = demoState, + setCustomEqEnabled = { }, + setCustomEq = {_, _, _ -> } + ) + } + } +} + +@Preview(name = "Material") +@Composable +fun EqualizerScreenPreviewMaterial() { + LibrePodsTheme( + m3eEnabled = true + ) { + val state = remember { mutableStateOf(demoState) } + Box ( + modifier = Modifier + .wrapContentHeight() + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + EqualizerScreen( + state = state.value, + setCustomEqEnabled = { state.value = state.value.copy(customEq = state.value.customEq.copy(state = if (it) 2 else 1)) }, + setCustomEq = {low, mid, high -> state.value = state.value.copy(customEq = state.value.customEq.copy(low = low, mid = mid, high = high))} + ) + } + } +} 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 ce21253c..79a14af1 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 @@ -24,10 +24,6 @@ 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.tween @@ -36,21 +32,26 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.Canvas +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.Spacer -import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -66,52 +67,38 @@ 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.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 -import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.nativeCanvas -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.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem 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.sin -import kotlin.random.Random @ExperimentalHazeMaterialsApi @OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) @Composable -fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController) { +fun HeadTrackingScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) { val state by viewModel.uiState.collectAsState() DisposableEffect(Unit) { viewModel.startHeadTracking() @@ -123,511 +110,169 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController 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() + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - var lastClickTime by remember { mutableLongStateOf(0L) } - var shouldExplode by remember { mutableStateOf(false) } + var gestureText by remember { mutableStateOf("") } + val coroutineScope = rememberCoroutineScope() - val scrollState = rememberScrollState() + var lastClickTime by remember { mutableLongStateOf(0L) } + var shouldExplode by remember { mutableStateOf(false) } - Column( + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + .verticalScroll(scrollState), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(topPadding)) + + Column ( modifier = Modifier .fillMaxWidth() - .verticalScroll(scrollState), - horizontalAlignment = Alignment.CenterHorizontally + .layerBackdrop(backdrop) + .padding(top = 8.dp) + .padding(horizontal = 16.dp) ) { - Column ( - modifier = Modifier - .fillMaxWidth() - .hazeSource(state = hazeState) - .layerBackdrop(backdrop) - .padding(top = 8.dp) - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) - if (!state.isPremium) { - StyledButton( - onClick = { - navController.navigate("purchase_screen") - }, - backdrop = rememberLayerBackdrop(), - modifier = Modifier.fillMaxWidth(), - maxScale = 0.05f, - surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) - ) { - Text( - stringResource(R.string.unlock_advanced_features), - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = Color.White - ), - ) - } - Spacer(modifier = Modifier.height(8.dp)) + if (!state.isPremium) { + StyledButton( + onClick = navigateToPurchase, + backdrop = rememberLayerBackdrop(), + modifier = Modifier.fillMaxWidth(), + maxScale = 0.05f, + surfaceColor = MaterialTheme.colorScheme.primary + ) { + Text( + stringResource(R.string.unlock_advanced_features), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimary + ) } - - StyledToggle( - label = "Head Gestures", - checked = state.headGesturesEnabled, - onCheckedChange = { viewModel.setHeadGesturesEnabled(it) }, - enabled = state.isPremium || state.headGesturesEnabled, - description = stringResource(R.string.head_gestures_details) - ) - 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) - ) - 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) - ) - 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 - ), - ) - } - 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 = "" - } - } - 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() - ) + + StyledToggle( + label = "Head Gestures", + checked = state.headGesturesEnabled, + onCheckedChange = { viewModel.setHeadGesturesEnabled(it) }, + enabled = state.isPremium || state.headGesturesEnabled, + description = stringResource(R.string.head_gestures_details), + header = true + ) + + Spacer(modifier = Modifier.height(16.dp)) + + 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) + ) + Plot() + + Spacer(modifier = Modifier.height(16.dp)) + + LaunchedEffect(gestureText) { + if (gestureText.isNotEmpty()) { + lastClickTime = System.currentTimeMillis() + delay(3000) + if (System.currentTimeMillis() - lastClickTime >= 3000) { + shouldExplode = true } } } } - } -} -private data class Particle( - val initialPosition: Offset, - val velocity: Offset, - var alpha: Float = 1f -) - -@Composable -private fun ParticleText( - text: String, - style: TextStyle, - onAnimationComplete: () -> Unit, -) { - val particles = remember { mutableStateListOf() } - val textMeasurer = rememberTextMeasurer() - var isAnimating by remember { mutableStateOf(true) } - var textVisible by remember { mutableStateOf(true) } - - Canvas(modifier = Modifier.fillMaxWidth()) { - val textLayoutResult = textMeasurer.measure(text, style) - val textBounds = textLayoutResult.size - val centerX = (size.width - textBounds.width) / 2 - val centerY = size.height / 2 - - if (textVisible && particles.isEmpty()) { - drawText( - textMeasurer = textMeasurer, - text = text, - style = style, - topLeft = Offset(centerX, centerY - textBounds.height / 2) + 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() + .padding(horizontal = 16.dp), + maxScale = 0.05f + ) { + Text( + "Test Head Gestures", + style = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + fontFamily = FontFamily(Font(R.font.sf_pro)), + color = MaterialTheme.colorScheme.onSecondaryContainer + ), ) } - - if (particles.isEmpty()) { - val random = Random(System.currentTimeMillis()) - for (@Suppress("Unused")i in 0..100) { - val x = centerX + random.nextFloat() * textBounds.width - val y = centerY - textBounds.height / 2 + random.nextFloat() * textBounds.height - val vx = (random.nextFloat() - 0.5f) * 20 - val vy = (random.nextFloat() - 0.5f) * 20 - particles.add(Particle(Offset(x, y), Offset(vx, vy))) - } - } - - particles.forEach { particle -> - drawCircle( - color = style.color.copy(alpha = particle.alpha), - radius = 0.5.dp.toPx(), - center = particle.initialPosition - ) - } - } - - LaunchedEffect(text) { - while (isAnimating) { - delay(16) - particles.forEachIndexed { index, particle -> - particles[index] = particle.copy( - initialPosition = particle.initialPosition + particle.velocity, - alpha = (particle.alpha - 0.02f).coerceAtLeast(0f) - ) - } - - if (particles.all { it.alpha <= 0f }) { - isAnimating = false - onAnimationComplete() - } - } - } -} - -@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 - - Card( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(2f), - colors = CardDefaults.cardColors( - containerColor = backgroundColor - ), - shape = RoundedCornerShape(28.dp) - ) { Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + contentAlignment = Alignment.Center, + modifier = Modifier.padding(top = 12.dp, bottom = 24.dp) ) { - Canvas( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - ) { - val width = size.width - val height = size.height - 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 cosY = cos(yaw).toFloat() - val sinY = sin(yaw).toFloat() - val cosP = cos(pitch).toFloat() - val sinP = sin(pitch).toFloat() - - fun rotate3D(point: Triple): Triple { - val (x, y, z) = point - val x1 = x * cosY - z * sinY - val z1 = x * sinY + z * cosY - - val y2 = y * cosP - z1 * sinP - val z2 = y * sinP + z1 * cosP - - return Triple(x1, y2, z2) + AnimatedContent( + targetState = gestureText, + transitionSpec = { + (fadeIn( + animationSpec = tween(300) + ) + slideInVertically( + initialOffsetY = { 40 }, + animationSpec = tween(300) + )).togetherWith(fadeOut(animationSpec = tween(150))) } - - fun project(point: Triple): Pair { - val (x, y, z) = point - val scale = 1f + (z / width) - return Pair(center.x + x * scale, center.y + y * scale) - } - - val earWidth = height * 0.08f - val earHeight = height * 0.2f - val earOffsetX = height * 0.4f - val earOffsetY = 0f - val earZ = 0f - - for (xSign in listOf(-1f, 1f)) { - val rotated = rotate3D(Triple(earOffsetX * xSign, earOffsetY, earZ)) - val (earX, earY) = project(rotated) - drawRoundRect( - color = strokeColor, - topLeft = Offset(earX - earWidth/2, earY - earHeight/2), - size = Size(earWidth, earHeight), - cornerRadius = CornerRadius(earWidth/2), - style = Stroke(width = 4.dp.toPx()) + ) { text -> + if (shouldExplode) { + LaunchedEffect(Unit) { + CoroutineScope(coroutineScope.coroutineContext).launch { + delay(750) + gestureText = "" + } + } + Text( + text = text, + style = TextStyle( + fontSize = 20.sp, + fontWeight = FontWeight.Medium, + fontFamily = FontFamily(Font(R.font.sf_pro)), + textAlign = TextAlign.Center + ), + color = MaterialTheme.colorScheme.onBackground ) - } - - val spherePath = Path() - val firstPoint = project(rotate3D(Triple(faceRadius, 0f, 0f))) - spherePath.moveTo(firstPoint.first, firstPoint.second) - - for (i in 1..32) { - val angle = (i * 2 * Math.PI / 32).toFloat() - val point = project(rotate3D(Triple( - cos(angle) * faceRadius, - sin(angle) * faceRadius, - 0f - ))) - spherePath.lineTo(point.first, point.second) - } - 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 - } - 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 - } - 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 - } - drawPath(spherePath.asAndroidPath(), shadowPaint) - } - - drawPath( - path = spherePath, - color = strokeColor, - style = Stroke(width = 4.dp.toPx()) - ) - - val smileRadius = faceRadius * 0.5f - val smileStartAngle = -340f - val smileSweepAngle = 140f - val smileOffsetY = faceRadius * 0.1f - - val smilePath = Path() - for (i in 0..32) { - val angle = Math.toRadians(smileStartAngle + (smileSweepAngle * i / 32.0)) - val x = cos(angle.toFloat()) * smileRadius - val y = sin(angle.toFloat()) * smileRadius + smileOffsetY - - val rotated = rotate3D(Triple(x, y, 0f)) - val projected = project(rotated) - - if (i == 0) { - smilePath.moveTo(projected.first, projected.second) - } else { - smilePath.lineTo(projected.first, projected.second) - } - } - - drawPath( - path = smilePath, - color = strokeColor, - style = Stroke( - width = 4.dp.toPx(), - cap = StrokeCap.Round - ) - ) - - val eyeOffsetX = height * 0.15f - val eyeOffsetY = height * 0.1f - val eyeLength = height * 0.08f - - for (xSign in listOf(-1f, 1f)) { - val rotated = rotate3D(Triple(eyeOffsetX * xSign, -eyeOffsetY, 0f)) - val (eyeX, eyeY) = project(rotated) - drawLine( - color = strokeColor, - start = Offset(eyeX, eyeY - eyeLength/2), - end = Offset(eyeX, eyeY + eyeLength/2), - strokeWidth = 4.dp.toPx(), - cap = StrokeCap.Round - ) - } - - 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 - ) - } - - val pitch = orientation.pitch.toInt() - val yaw = orientation.yaw.toInt() - val text = "Pitch: ${pitch}° Yaw: ${yaw}°" - - drawText( - text, - width - 8.dp.toPx(), - height - 8.dp.toPx(), - paint + } 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() ) } } } + Spacer(modifier = Modifier.height(bottomPadding)) } } @Composable -private fun AccelerationPlot() { +private fun Plot() { val acceleration by HeadTracking.acceleration.collectAsState() val maxPoints = 100 val points = remember { mutableStateListOf>() } @@ -649,11 +294,12 @@ private fun AccelerationPlot() { modifier = Modifier .fillMaxWidth() .height(300.dp), - colors = CardDefaults.cardColors( - containerColor = if (darkTheme) Color(0xFF1C1C1E) else Color.White - ), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), shape = RoundedCornerShape(28.dp) ) { + val horizontalColor = MaterialTheme.colorScheme.primary + val verticalColor = MaterialTheme.colorScheme.onPrimary + Box( modifier = Modifier .fillMaxSize() @@ -704,14 +350,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() @@ -734,7 +380,7 @@ 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 @@ -744,7 +390,7 @@ private fun AccelerationPlot() { 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 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 108e19c0..e4b78c4b 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 @@ -20,16 +20,21 @@ package me.kavishdevar.librepods.presentation.screens import android.annotation.SuppressLint import android.util.Log -import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -41,10 +46,6 @@ 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 @@ -52,9 +53,10 @@ 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.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import kotlin.io.encoding.ExperimentalEncodingApi @@ -65,11 +67,8 @@ private const val TAG = "HearingAidAdjustments" @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 +143,83 @@ 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) - ) { - Spacer(modifier = Modifier.height(spacerHeight)) + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - StyledSlider( - label = stringResource(R.string.amplification), - valueRange = -1f..1f, - value = amplificationSliderValue.floatValue, - onValueChange = { - amplificationSliderValue.floatValue = it - }, - startIcon = "􀊥", - endIcon = "􀊩", - independent = true, - ) + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + .verticalScroll(verticalScrollState) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.height(topPadding)) - 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.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, - ) + 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.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.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.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, - ) + 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, + ) - StyledToggle( - label = stringResource(R.string.conversation_boost), - checked = conversationBoostEnabled.value, - onCheckedChange = { conversationBoostEnabled.value = it }, - independent = true, - description = stringResource(R.string.conversation_boost_description) - ) - } + 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 }, + description = stringResource(R.string.conversation_boost_description) + ) + + Spacer(modifier = Modifier.height(bottomPadding)) } } 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 3cb72ce4..4a040a16 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 @@ -25,16 +25,17 @@ 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.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll 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.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -43,7 +44,6 @@ 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 @@ -52,11 +52,8 @@ 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 @@ -66,9 +63,11 @@ 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.StyledList +import me.kavishdevar.librepods.presentation.components.StyledListItem import me.kavishdevar.librepods.presentation.components.StyledToggle +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import kotlin.io.encoding.ExperimentalEncodingApi @@ -78,14 +77,11 @@ private const val TAG = "AccessibilitySettings" @ExperimentalHazeMaterialsApi @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @Composable -fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController) { - val isDarkTheme = isSystemInDarkTheme() - val textColor = if (isDarkTheme) Color.White else Color.Black +fun HearingAidScreen(viewModel: AirPodsViewModel, onNavigateHearingAidAdjustments: () -> Unit, onNavigateHearingTest: () -> Unit) { val verticalScrollState = rememberScrollState() - val snackbarHostState = remember { SnackbarHostState() } + val backdrop = rememberLayerBackdrop() val showDialog = remember { mutableStateOf(false) } - val backdrop = rememberLayerBackdrop() val initialLoad = remember { mutableStateOf(true) } val state by viewModel.uiState.collectAsState() @@ -96,38 +92,36 @@ 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 - StyledScaffold( - 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) - ) { - hazeStateS.value = hazeState - Spacer(modifier = Modifier.height(topPadding)) + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + + Column( + modifier = Modifier + .layerBackdrop(backdrop) + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + .verticalScroll(verticalScrollState) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + 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 - } - initialLoad.value = 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 } + initialLoad.value = false + } // fun onAdjustPhoneChange(value: Boolean) { // // TODO @@ -137,105 +131,75 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController) // // 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) + StyledList (title = stringResource(R.string.hearing_aid)) { + StyledToggle( + label = stringResource(R.string.hearing_aid), + checked = hearingAidEnabled.value, + onCheckedChange = { hearingAidEnabled.value = it }, ) - - val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) - - Column( - modifier = Modifier - .fillMaxWidth() - .background(backgroundColor, RoundedCornerShape(28.dp)) - .clip( - RoundedCornerShape(28.dp) - ) - ) { - 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) + StyledListItem( + name = stringResource(R.string.adjustments), + onClick = onNavigateHearingAidAdjustments, ) - 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)) } + + 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)) + + StyledListItem( + name = stringResource(R.string.update_hearing_test), + onClick = onNavigateHearingTest, + ) + + // not implemented yet + + // StyledToggle( + // titleRes = stringResource(R.string.media_assist), + // label = stringResource(R.string.media_assist), + // checkedState = mediaAssistEnabled, + // independent = true, + // descriptionRes = 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", @@ -274,7 +238,6 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController) hearingAidEnabled.value = false showDialog.value = false }, -// hazeState = hazeStateS.value, backdrop = backdrop ) } 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 cf484b86..9c83f507 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,103 +18,101 @@ package me.kavishdevar.librepods.presentation.screens -import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +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.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.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel @Composable -fun HearingProtectionScreen(viewModel: AirPodsViewModel, navController: NavController) { +fun HearingProtectionScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) { val backdrop = rememberLayerBackdrop() val state by viewModel.uiState.collectAsState() - StyledScaffold( - title = stringResource(R.string.hearing_protection), - ) { spacerHeight -> - Column( - modifier = Modifier - .fillMaxSize() - .layerBackdrop(backdrop) - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(spacerHeight)) - if (!state.isPremium) { - StyledButton( - onClick = { - navController.navigate("purchase_screen") - }, - backdrop = rememberLayerBackdrop(), - modifier = Modifier.fillMaxWidth(), - maxScale = 0.05f, - surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) - ) { - Text( - stringResource(R.string.unlock_advanced_features), - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = Color.White - ), - ) - } - } - 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()) - ) - }, - enabled = state.isPremium - ) - Spacer(modifier = Modifier.height(12.dp)) + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + .layerBackdrop(backdrop) + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(topPadding)) + if (!state.isPremium) { + StyledButton( + onClick = navigateToPurchase, + backdrop = rememberLayerBackdrop(), + modifier = Modifier.fillMaxWidth(), + maxScale = 0.05f, + surfaceColor = MaterialTheme.colorScheme.primary + ) { + Text( + stringResource(R.string.unlock_advanced_features), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimary + ) } + Spacer(modifier = Modifier.height(16.dp)) + } + + if (state.vendorIdHook) { 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, + 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.setControlCommandBoolean( - AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, it + viewModel.setATTCharacteristicValue( + ATTHandles.LOUD_SOUND_REDUCTION, + byteArrayOf(if (it) 1.toByte() else 0.toByte()) ) }, 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 + ) + }, + enabled = state.isPremium + ) + Spacer(modifier = Modifier.height(bottomPadding)) } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/LoadingScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/LoadingScreen.kt new file mode 100644 index 00000000..2702da10 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/LoadingScreen.kt @@ -0,0 +1,35 @@ +package me.kavishdevar.librepods.presentation.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme + +@Composable +fun LoadingScreen() { + Box( + modifier = Modifier + .background(MaterialTheme.colorScheme.surfaceContainer), + contentAlignment = Alignment.Center + ) { + CircularWavyProgressIndicator( + modifier = Modifier + .size(120.dp) + ) + } +} + +@Preview +@Composable +fun LoadingScreenPreview() { + LibrePodsTheme { + LoadingScreen() + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/MicrophoneSettingsScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/MicrophoneSettingsScreen.kt new file mode 100644 index 00000000..91bf9750 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/MicrophoneSettingsScreen.kt @@ -0,0 +1,101 @@ +package me.kavishdevar.librepods.presentation.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.bluetooth.AACPManager +import me.kavishdevar.librepods.presentation.components.StyledList +import me.kavishdevar.librepods.presentation.components.StyledListItem +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel + +@Composable +fun MicrophoneSettingsRoute( + viewModel: AirPodsViewModel +) { + val state by viewModel.uiState.collectAsState() + + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + + val id = AACPManager.Companion.ControlCommandIdentifiers.MIC_MODE + + Box ( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + MicrophoneSettingsScreen( + selectedMode = state.controlStates[id]?.getOrNull(0)?.toInt() ?: 0, + topPadding = topPadding, + bottomPadding = bottomPadding, + onMicrophoneSettingsChanged = { + viewModel.setControlCommandInt(id, it) + } + ) + } +} + +@Composable +fun MicrophoneSettingsScreen( + selectedMode: Int, + topPadding: Dp = 16.dp, + bottomPadding: Dp = 16.dp, + onMicrophoneSettingsChanged: (Int) -> Unit +) { + val scrollState = rememberScrollState() + + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + .verticalScroll(scrollState) + .padding(top = 8.dp) + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(topPadding)) + + StyledList { + StyledListItem( + name = stringResource(R.string.microphone_automatic), + selected = selectedMode == 0, + onClick = { onMicrophoneSettingsChanged(0) } + ) + + StyledListItem( + name = stringResource(R.string.microphone_always_right), + selected = selectedMode == 1, + onClick = { onMicrophoneSettingsChanged(1) } + ) + + StyledListItem( + name = stringResource(R.string.microphone_always_left), + selected = selectedMode == 2, + onClick = { onMicrophoneSettingsChanged(2) } + ) + } + + Spacer(modifier = Modifier.height(bottomPadding)) + } +} 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 8f57cdb5..d8663111 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 @@ -19,21 +19,24 @@ package me.kavishdevar.librepods.presentation.screens import android.annotation.SuppressLint -import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier 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 @@ -41,7 +44,8 @@ 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 me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import kotlin.io.encoding.ExperimentalEncodingApi private var debounceJob: Job? = null @@ -50,33 +54,34 @@ private var debounceJob: Job? = null @ExperimentalHazeMaterialsApi @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @Composable -fun OpenSourceLicensesScreen(navController: NavController) { - val isDarkTheme = isSystemInDarkTheme() +fun OpenSourceLicensesScreen() { val backdrop = rememberLayerBackdrop() - 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() - ) + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + + Column( + modifier = Modifier + .fillMaxSize() + .layerBackdrop(backdrop) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.height(topPadding)) + 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() + ) + Spacer(modifier = Modifier.height(bottomPadding)) } } 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 ef8a073e..247eacb5 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 @@ -21,38 +21,42 @@ package me.kavishdevar.librepods.presentation.screens import android.util.Log -import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll 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.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.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.ListItemOrientation 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.components.StyledList +import me.kavishdevar.librepods.presentation.components.StyledListItem +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import kotlin.experimental.and import kotlin.io.encoding.ExperimentalEncodingApi @@ -60,10 +64,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi @ExperimentalHazeMaterialsApi @OptIn(ExperimentalMaterial3Api::class) @Composable -fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavController) { - val isDarkTheme = isSystemInDarkTheme() - val textColor = if (isDarkTheme) Color.White else Color.Black - +fun LongPress(viewModel: AirPodsViewModel, name: String, navigateToPurchase: () -> Unit) { val state by viewModel.uiState.collectAsState() val modesByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0) ?: 0 @@ -75,137 +76,158 @@ 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) - ) { - 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 - ) + + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp + + val scrollState = rememberScrollState() + + Column ( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + .verticalScroll(scrollState) + .padding(top = 8.dp) + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(topPadding)) + + StyledList { + StyledListItem( + name = stringResource(R.string.noise_control), + selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES, + onClick = { + viewModel.setLongPressAction( + name, + StemAction.CYCLE_NOISE_CONTROL_MODES + ) + } ) - StyledSelectList(items = actionItems) - if (!state.isPremium) { - Spacer(modifier = Modifier.height(24.dp)) - StyledButton( - onClick = { - navController.navigate("purchase_screen") - }, - backdrop = rememberLayerBackdrop(), - modifier = Modifier.fillMaxWidth(), - maxScale = 0.05f, - surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) - ) { - Text( - stringResource(R.string.unlock_advanced_features), - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = Color.White - ), + StyledListItem( + name = stringResource(R.string.digital_assistant), + selected = longPressAction == StemAction.DIGITAL_ASSISTANT, + onClick = { + viewModel.setLongPressAction( + name, + StemAction.DIGITAL_ASSISTANT + ) + }, + enabled = state.isPremium + ) + } + + if (!state.isPremium) { + Spacer(modifier = Modifier.height(24.dp)) + StyledButton( + onClick = navigateToPurchase, + backdrop = rememberLayerBackdrop(), + modifier = Modifier.fillMaxWidth(), + maxScale = 0.05f, + surfaceColor = MaterialTheme.colorScheme.primary + ) { + Text( + stringResource(R.string.unlock_advanced_features), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimary + ) + } + Spacer(modifier = Modifier.height(16.dp)) + } + + if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) { + Spacer(modifier = Modifier.height(32.dp)) + + val currentByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0)?.toInt() ?: 0 + + StyledList( + title = stringResource(R.string.noise_control), + description = stringResource(R.string.press_and_hold_noise_control_description) + ) { + if (state.offListeningMode) { + StyledListItem( + name = stringResource(R.string.off), + description = stringResource(R.string.listening_mode_off_description), + selected = (currentByte and 0x01) != 0, + onClick = { + viewModel.toggleListeningMode(0x01) + }, + orientation = ListItemOrientation.Vertical, + leadingContent = { + Icon( + painter = painterResource(R.drawable.noise_cancellation), + contentDescription = "Icon", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .height(42.dp) + .wrapContentWidth() + ) + } ) } - Spacer(modifier = Modifier.height(8.dp)) - } - 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) + StyledListItem( + name = stringResource(R.string.transparency), + description = stringResource(R.string.listening_mode_transparency_description), + selected = (currentByte and 0x04) != 0, + onClick = { + viewModel.toggleListeningMode(0x04) + }, + orientation = ListItemOrientation.Vertical, + leadingContent = { + Icon( + painter = painterResource(R.drawable.transparency), + contentDescription = "Icon", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .height(42.dp) + .wrapContentWidth() + ) + } ) - Spacer(modifier = Modifier.height(8.dp)) - - 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) - } + StyledListItem( + name = stringResource(R.string.adaptive), + description = stringResource(R.string.listening_mode_adaptive_description), + selected = (currentByte and 0x08) != 0, + onClick = { + viewModel.toggleListeningMode(0x08) + }, + orientation = ListItemOrientation.Vertical, + leadingContent = { + Icon( + painter = painterResource(R.drawable.adaptive), + contentDescription = "Icon", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .height(42.dp) + .wrapContentWidth() ) - ) - } - 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) + } + ) + + StyledListItem( + name = stringResource(R.string.noise_cancellation), + description = stringResource(R.string.listening_mode_noise_cancellation_description), + selected = (currentByte and 0x02) != 0, + onClick = { + viewModel.toggleListeningMode(0x02) + }, + orientation = ListItemOrientation.Vertical, + leadingContent = { + Icon( + painter = painterResource(R.drawable.noise_cancellation), + contentDescription = "Icon", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier + .height(42.dp) + .wrapContentWidth() + ) + } ) } } + Spacer(modifier = Modifier.height(bottomPadding)) } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PurchaseScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PurchaseScreen.kt index ae5cefe9..c5d1642c 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PurchaseScreen.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/PurchaseScreen.kt @@ -19,510 +19,199 @@ 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.Spacer -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.HorizontalDivider +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 import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Modifier -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.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp 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.R +import me.kavishdevar.librepods.presentation.components.ListItemOrientation +import me.kavishdevar.librepods.presentation.components.MaterialButtonStyle import me.kavishdevar.librepods.presentation.components.StyledButton -import me.kavishdevar.librepods.presentation.components.StyledScaffold +import me.kavishdevar.librepods.presentation.components.StyledList +import me.kavishdevar.librepods.presentation.components.StyledListItem +import me.kavishdevar.librepods.presentation.navigation.Screen +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel import me.kavishdevar.librepods.utils.XposedState @Composable fun PurchaseScreen( viewModel: PurchaseViewModel = viewModel(), - navController: NavController + backStack: SnapshotStateList ) { val context = LocalContext.current val scrollState = rememberScrollState() + val state by viewModel.uiState.collectAsState() val backdrop = rememberLayerBackdrop() - StyledScaffold( - title = stringResource(R.string.unlock_advanced_features) - ) { topPadding, hazeState, bottomPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .layerBackdrop(backdrop) - .hazeSource(state = hazeState) - .verticalScroll(scrollState) - .padding(horizontal = 16.dp) - ) { - Spacer(modifier = Modifier.height(topPadding)) + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - val isDarkTheme = isSystemInDarkTheme() - val backgroundColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7) - val cardBackgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) - val textColor = if (isDarkTheme) Color.White else Color.Black - LaunchedEffect(state.isPremium) { - if (state.isPremium) { - navController.popBackStack() + Column( + modifier = Modifier + .layerBackdrop(backdrop) + .verticalScroll(scrollState) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(topPadding)) + + LaunchedEffect(state.isPremium) { + if (state.isPremium) { + if (backStack.size > 1) { + backStack.removeAt(backStack.lastIndex) } } - if (!state.isPremium) { - Box( - modifier = Modifier - .background(backgroundColor) - .padding(horizontal = 16.dp, vertical = 4.dp) - ) { - Text( - text = stringResource(R.string.free_features), - 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(cardBackgroundColor, RoundedCornerShape(28.dp)) - .padding(horizontal = 8.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - text = stringResource(R.string.ear_detection), - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Normal, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor - ) - ) - Text( - text = stringResource(R.string.ear_detection_description), - style = TextStyle( - fontSize = 12.sp, - color = textColor.copy(0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)), - ) - ) - } - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier - .padding(horizontal = 12.dp) - ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - text = stringResource(R.string.battery), - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Normal, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor - ) - ) - Text( - text = stringResource(R.string.battery_description), - style = TextStyle( - fontSize = 12.sp, - color = textColor.copy(0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)), - ) - ) - } - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier - .padding(horizontal = 12.dp) - ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - text = stringResource(R.string.noise_control), - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Normal, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor - ) - ) - Text( - text = stringResource(R.string.noise_control_description), - style = TextStyle( - fontSize = 12.sp, - color = textColor.copy(0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)), - ) - ) - } - if (XposedState.isAvailable) { - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier - .padding(horizontal = 12.dp) - ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - text = stringResource(R.string.hearing_aid) + " (" + stringResource( - R.string.requires_xposed - ) + ")", - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Normal, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor - ) - ) - Text( - text = stringResource(R.string.hearing_aid_description).split("\n\n")[0], - style = TextStyle( - fontSize = 12.sp, - color = textColor.copy(0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)), - ) - ) - } - } - } - - Spacer(modifier = Modifier.height(24.dp)) - Box( - modifier = Modifier - .background(backgroundColor) - .padding(horizontal = 16.dp, vertical = 4.dp) - ) { - Text( - text = stringResource(R.string.advanced_features), - 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(cardBackgroundColor, RoundedCornerShape(28.dp)) - .padding(horizontal = 8.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - text = stringResource(R.string.conversational_awareness), - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Normal, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor - ) - ) - Text( - text = stringResource(R.string.conversational_awareness_description), - style = TextStyle( - fontSize = 12.sp, - color = textColor.copy(0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)), - ) - ) - } - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier - .padding(horizontal = 12.dp) - ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - text = stringResource(R.string.digital_assistant_on_long_press), - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Normal, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor - ) - ) - Text( - text = stringResource(R.string.digital_assistant_on_long_press_description), - style = TextStyle( - fontSize = 12.sp, - color = textColor.copy(0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)), - ) - ) - } - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier - .padding(horizontal = 12.dp) - ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - text = stringResource(R.string.head_gestures), - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Normal, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor - ) - ) - Text( - text = stringResource(R.string.head_gestures_details), - style = TextStyle( - fontSize = 12.sp, - color = textColor.copy(0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)), - ) - ) - } - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier - .padding(horizontal = 12.dp) - ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - text = stringResource(R.string.advanced_device_settings), - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Normal, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor - ) - ) - Text( - text = stringResource(R.string.advanced_device_settings_description), - style = TextStyle( - fontSize = 12.sp, - color = textColor.copy(0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)), - ) - ) - } - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier - .padding(horizontal = 12.dp) - ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - text = stringResource(R.string.automatic_connection), - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Normal, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor - ) - ) - Text( - text = stringResource(R.string.automatic_connection_description), - style = TextStyle( - fontSize = 12.sp, - color = textColor.copy(0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)), - ) - ) - } - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier - .padding(horizontal = 12.dp) - ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - text = stringResource(R.string.customizations), - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Normal, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor - ) - ) - Text( - text = stringResource(R.string.customizations_description), - style = TextStyle( - fontSize = 12.sp, - color = textColor.copy(0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)), - ) - ) - } - HorizontalDivider( - thickness = 1.dp, - color = Color(0x40888888), - modifier = Modifier - .padding(horizontal = 12.dp) - ) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - Text( - text = stringResource(R.string.support_the_development), - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Normal, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor - ) - ) - Text( - text = stringResource(R.string.support_development_description), - style = TextStyle( - fontSize = 12.sp, - color = textColor.copy(0.6f), - fontFamily = FontFamily(Font(R.font.sf_pro)), - ) - ) - } - } - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = stringResource(R.string.feature_availability_disclaimer), - modifier = Modifier.fillMaxWidth(), - style = TextStyle( - fontSize = 14.sp, - fontWeight = FontWeight.Light, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor.copy(alpha = 0.6f), - textAlign = TextAlign.Center - ), + } + if (!state.isPremium) { + StyledList(title = stringResource(R.string.free_features)) { + StyledListItem( + name = stringResource(R.string.ear_detection), + description = stringResource(R.string.ear_detection_description), + enabled = false, + orientation = ListItemOrientation.Vertical ) + StyledListItem( + name = stringResource(R.string.battery), + description = stringResource(R.string.battery_description), + enabled = false, + orientation = ListItemOrientation.Vertical + ) - Spacer(modifier = Modifier.height(24.dp)) + StyledListItem( + name = stringResource(R.string.noise_control), + description = stringResource(R.string.noise_control_description), + enabled = false, + orientation = ListItemOrientation.Vertical + ) - StyledButton( - onClick = { - viewModel.purchase(context) - }, - backdrop = rememberLayerBackdrop(), - modifier = Modifier.fillMaxWidth(), - maxScale = 0.05f, - surfaceColor = if (isSystemInDarkTheme()) Color(0xFF0091FF) - else Color(0xFF0088FF) // if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) - ) { - Text( - stringResource(R.string.buy_price, state.price), - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = Color.White - ), - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - StyledButton( - onClick = { - viewModel.restorePurchases() - }, - backdrop = rememberLayerBackdrop(), - modifier = Modifier.fillMaxWidth(), - maxScale = 0.05f, - isInteractive = false - ) { - Text( - stringResource(R.string.restore_purchases), - style = TextStyle( - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - fontFamily = FontFamily(Font(R.font.sf_pro)), - color = textColor - ), + if (XposedState.isAvailable) { + StyledListItem( + name = "${stringResource(R.string.hearing_aid)} (${stringResource(R.string.requires_xposed)})", + description = stringResource(R.string.hearing_aid_description) + .substringBefore("\n\n"), + enabled = false, + orientation = ListItemOrientation.Vertical ) } } - Spacer(modifier = Modifier.height(bottomPadding)) + + Spacer(modifier = Modifier.height(24.dp)) + + StyledList(title = stringResource(R.string.advanced_features), description = stringResource(R.string.feature_availability_disclaimer)) { + StyledListItem( + name = stringResource(R.string.conversational_awareness), + description = stringResource(R.string.conversational_awareness_description), + enabled = false, + orientation = ListItemOrientation.Vertical + ) + + StyledListItem( + name = stringResource(R.string.digital_assistant_on_long_press), + description = stringResource(R.string.digital_assistant_on_long_press_description), + enabled = false, + orientation = ListItemOrientation.Vertical + ) + + StyledListItem( + name = stringResource(R.string.head_gestures), + description = stringResource(R.string.head_gestures_details), + enabled = false, + orientation = ListItemOrientation.Vertical + ) + + StyledListItem( + name = stringResource(R.string.advanced_device_settings), + description = stringResource(R.string.advanced_device_settings_description), + enabled = false, + orientation = ListItemOrientation.Vertical + ) + + StyledListItem( + name = stringResource(R.string.automatic_connection), + description = stringResource(R.string.automatic_connection_description), + enabled = false, + orientation = ListItemOrientation.Vertical + ) + + StyledListItem( + name = stringResource(R.string.customizations), + description = stringResource(R.string.customizations_description), + enabled = false, + orientation = ListItemOrientation.Vertical + ) + + StyledListItem( + name = stringResource(R.string.support_the_development), + description = stringResource(R.string.support_development_description), + enabled = false, + orientation = ListItemOrientation.Vertical + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + StyledButton( + onClick = { + viewModel.purchase(context) + }, + backdrop = rememberLayerBackdrop(), + modifier = Modifier.fillMaxWidth(), + maxScale = 0.05f, + surfaceColor = MaterialTheme.colorScheme.primary, + materialButtonStyle = MaterialButtonStyle.Filled + ) { + Text( + stringResource(R.string.buy_price, state.price), + style = MaterialTheme.typography.bodyMediumEmphasized, + color = MaterialTheme.colorScheme.onPrimary + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + StyledButton( + onClick = { + viewModel.restorePurchases() + }, + backdrop = rememberLayerBackdrop(), + modifier = Modifier.fillMaxWidth(), + maxScale = 0.05f, + isInteractive = false, + materialButtonStyle = MaterialButtonStyle.Outlined + ) { + Text( + stringResource(R.string.restore_purchases), + style = MaterialTheme.typography.bodyMedium, + ) + } } + Spacer(modifier = Modifier.height(bottomPadding)) } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/ReleaseNotesScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/ReleaseNotesScreen.kt new file mode 100644 index 00000000..635c8663 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/ReleaseNotesScreen.kt @@ -0,0 +1,471 @@ +package me.kavishdevar.librepods.presentation.screens + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +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.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +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.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.carousel.CarouselDefaults +import androidx.compose.material3.carousel.HorizontalUncontainedCarousel +import androidx.compose.material3.carousel.rememberCarouselState +import androidx.compose.material3.toPath +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.center +import androidx.compose.ui.graphics.Matrix +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_NO +import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_YES +import androidx.compose.ui.tooling.preview.Devices.PIXEL_9_PRO_XL +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.Wallpapers.GREEN_DOMINATED_EXAMPLE +import androidx.compose.ui.tooling.preview.Wallpapers.RED_DOMINATED_EXAMPLE +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import androidx.graphics.shapes.Morph +import me.kavishdevar.librepods.BuildConfig +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.data.updates.UpdateItem +import me.kavishdevar.librepods.data.updates.update0_3_1 +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import kotlin.math.min + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReleaseNotesScreen( + updates: List, + releaseNotesShown: () -> Unit +) { + val state = rememberCarouselState( + initialItem = 0, + itemCount = { updates.size + 1 } + ) + + val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + + LibrePodsTheme(m3eEnabled = true) { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background, RoundedCornerShape(52.dp)), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(topPadding)) + Text( + text = stringResource(R.string.what_s_new), + style = MaterialTheme.typography.displayMedium, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center + ) + + val versionName = BuildConfig.VERSION_NAME.removeSuffix("-debug") + val url = "https://github.com/kavishdevar/librepods/releases/v$versionName" + val fullText = "${stringResource(R.string.version)} $versionName" + val textColor = MaterialTheme.colorScheme.primary + + val annotatedString = buildAnnotatedString { + append(fullText) + addLink( + url = LinkAnnotation.Url( + url = url, + styles = TextLinkStyles( + style = SpanStyle(color = textColor) + ) + ), + start = 0, + end = fullText.length + ) + } + Text( + text = annotatedString, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.tertiary, + textDecoration = TextDecoration.Underline + ) + + Spacer(modifier = Modifier.height(16.dp)) + HorizontalUncontainedCarousel( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + state = state, + itemSpacing = 16.dp, + contentPadding = PaddingValues(horizontal = 16.dp), + itemWidth = LocalWindowInfo.current.containerDpSize.width - 64.dp, + flingBehavior = CarouselDefaults.singleAdvanceFlingBehavior(state) + ) { index -> + + val shape = rememberMaskShape(RoundedCornerShape(48.dp)) + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + shape = shape, + modifier = Modifier.fillMaxSize() + ) { + Box( + modifier = Modifier + .fillMaxSize() + ) { + if (index != updates.size) { + val updateItem = updates[index] + + val deviceBorderColor = MaterialTheme.colorScheme.tertiary + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(top = 16.dp) + .heightIn(max = 700.dp) + .align(Alignment.TopCenter) + .drawWithCache { + val path = createTopWavyRectPath( + width = size.width, + height = size.height, + cornerRadius = 52.dp.toPx(), + amplitude = 4.dp.toPx(), + wavelength = 36.dp.toPx() + ) + onDrawWithContent { + drawContent() + + drawPath( + path, + color = deviceBorderColor, + style = Stroke( + width = 4.dp.toPx() + ) + ) + } + } + + ) { + Box( + modifier = Modifier + .padding(2.dp) + .clip(RoundedCornerShape(50.dp)) + ) { + CompositionLocalProvider( + LocalDensity provides Density( + density = LocalDensity.current.density * 0.8f, + fontScale = LocalDensity.current.fontScale + ), + LocalDesignSystem provides if (m3eEnabled) DesignSystem.Material else DesignSystem.Apple + ) { + updateItem.demoComposeable() + } + } + } + + Box( + modifier = Modifier + .padding(12.dp) + .aspectRatio(1f) + .background( + MaterialTheme.colorScheme.tertiary.copy(alpha = 0.9f), + MaterialShapes.Arch.toShape() + ) + .align(Alignment.BottomCenter) + ) { + Column( + modifier = Modifier + .padding(32.dp) + .align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = stringResource(updateItem.titleRes), + style = MaterialTheme.typography.displayMediumEmphasized, + color = MaterialTheme.colorScheme.onTertiary, + textAlign = TextAlign.Center + ) + Text( + text = stringResource(updateItem.descriptionRes), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onTertiary, + textAlign = TextAlign.Center + ) + } + } + } else { + var pressed by remember { mutableStateOf(false) } + + val morph = remember { + Morph( + MaterialShapes.Cookie7Sided.normalized(), + MaterialShapes.SoftBurst.normalized() + ) + } + + val morphProgress by animateFloatAsState( + targetValue = if (pressed) 1f else 0f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ), + label = "morph", + ) + + val rotationSpeed by animateFloatAsState( + targetValue = if (pressed) 0f else 1f, + animationSpec = tween(1200), + label = "rotationSpeed" + ) + + var rotation by remember { mutableFloatStateOf(0f) } + + LaunchedEffect(Unit) { + var lastFrame = withFrameNanos { it } + + while (true) { + val frame = withFrameNanos { it } + + val dt = (frame - lastFrame) / 1_000_000_000f + lastFrame = frame + + rotation += 60f * rotationSpeed * dt + } + } + + val path = remember { Path() } + val matrix = remember { Matrix() } + + val tertiary = MaterialTheme.colorScheme.tertiary + + Box( + modifier = Modifier + .fillMaxSize() + .align(Alignment.Center) + .pointerInput(Unit) { + detectTapGestures( + onPress = { + pressed = true + tryAwaitRelease() + pressed = false + } + ) + } + ) { + Box( + modifier = Modifier + .size(260.dp) + .align(Alignment.Center) + .pointerInput(Unit) { + detectTapGestures( + onTap = { releaseNotesShown() }, + onPress = { + pressed = true + tryAwaitRelease() + pressed = false + } + ) + } + .drawBehind { + val shapePath = morph.toPath( + progress = morphProgress, + path = path + ) + + val bounds = shapePath.getBounds() + + val scale = min( + size.width / bounds.width, + size.height / bounds.height + ) * 0.9f + + matrix.reset() + matrix.scale(scale, scale) + + shapePath.transform(matrix) + + shapePath.translate( + size.center - + shapePath.getBounds().center + ) + + rotate(rotation) { + drawPath( + path = shapePath, + color = tertiary + ) + } + }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Default.ArrowForward, + contentDescription = null, + modifier = Modifier.size(100.dp), + tint = MaterialTheme.colorScheme.onTertiary + ) + } + } + } + } + } + } + Spacer(modifier = Modifier.height(bottomPadding)) + } + } +} + +@Preview(uiMode = UI_MODE_NIGHT_YES, wallpaper = GREEN_DOMINATED_EXAMPLE, device = PIXEL_9_PRO_XL) +@Preview(uiMode = UI_MODE_NIGHT_NO, wallpaper = RED_DOMINATED_EXAMPLE, device = PIXEL_9_PRO_XL) +@Composable +fun ReleaseNotesScreenPreview() { + LibrePodsTheme( + m3eEnabled = false + ) { + ReleaseNotesScreen( + updates = update0_3_1, + releaseNotesShown = { } + ) + } +} + +// ai gen'd helper +fun createTopWavyRectPath( + width: Float, + height: Float, + cornerRadius: Float, + amplitude: Float, + wavelength: Float +): Path { + return Path().apply { + moveTo(cornerRadius, 0f) + + var x = cornerRadius + + while (x < width - cornerRadius - wavelength) { + quadraticTo( + x + wavelength / 4f, + -amplitude, + x + wavelength / 2f, + 0f + ) + + quadraticTo( + x + wavelength * 3f / 4f, + amplitude, + x + wavelength, + 0f + ) + + x += wavelength + } + + arcTo( + rect = Rect( + left = width - 2 * cornerRadius, + top = 0f, + right = width, + bottom = 2 * cornerRadius + ), + startAngleDegrees = -90f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + + lineTo(width, height - cornerRadius) + + arcTo( + rect = Rect( + left = width - 2 * cornerRadius, + top = height - 2 * cornerRadius, + right = width, + bottom = height + ), + startAngleDegrees = 0f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + + lineTo(cornerRadius, height) + + arcTo( + rect = Rect( + left = 0f, + top = height - 2 * cornerRadius, + right = 2 * cornerRadius, + bottom = height + ), + startAngleDegrees = 90f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + + lineTo(0f, cornerRadius) + + arcTo( + rect = Rect( + left = 0f, + top = 0f, + right = 2 * cornerRadius, + bottom = 2 * cornerRadius + ), + startAngleDegrees = 180f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + + close() + } +} 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 d06177b6..44e97e6a 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 @@ -21,13 +21,19 @@ package me.kavishdevar.librepods.presentation.screens import android.content.Context +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember @@ -35,13 +41,12 @@ import androidx.compose.ui.Modifier 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.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import kotlin.io.encoding.ExperimentalEncodingApi @@ -58,28 +63,32 @@ 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 m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - 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()) - } + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(topPadding)) - StyledInputField( - textFieldState, - focusRequester - ) + 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()) } + + StyledInputField( + textFieldState, + focusRequester + ) + + Spacer(modifier = Modifier.height(bottomPadding)) + } } 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 52677121..c6700e57 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 @@ -28,16 +28,21 @@ 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.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text @@ -61,17 +66,15 @@ 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.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel import kotlin.io.encoding.ExperimentalEncodingApi @@ -90,83 +93,52 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) { 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 m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - 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 }) } + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + .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 initialized = rememberSaveable { mutableStateOf(false) } + 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 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 initialized = rememberSaveable { mutableStateOf(false) } - LaunchedEffect( - enabled.value, - amplificationSliderValue.floatValue, - balanceSliderValue.floatValue, - toneSliderValue.floatValue, - conversationBoostEnabled.value, - ambientNoiseReductionSliderValue.floatValue, - eq.value - ) { - if (!initialized.value) return@LaunchedEffect - transparencySettings.value = TransparencySettings( + val transparencySettings = remember { + mutableStateOf( + 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, + 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, @@ -176,193 +148,218 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) { 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 - } + if (state.vendorIdHook) { + StyledToggle( + label = stringResource(R.string.transparency_mode), + checked = enabled.value, + 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 + ) - 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, + ) - 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.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, + ) - 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, + description = stringResource(R.string.conversation_boost_description), + onCheckedChange = { conversationBoostEnabled.value = it } + ) - StyledToggle( - label = stringResource(R.string.conversation_boost), - checked = conversationBoostEnabled.value, - independent = true, - description = stringResource(R.string.conversation_boost_description), - onCheckedChange = { conversationBoostEnabled.value = it } - ) + 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) + ) - 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) - ) + 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) + ) - 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, + Slider( + value = eqValue.floatValue, + onValueChange = { newVal -> + eqValue.floatValue = newVal + val newEQ = eq.value.copyOf() + newEQ[i] = eqValue.floatValue + eq.value = newEQ + }, + valueRange = 0f..100f, modifier = Modifier - .fillMaxWidth() - .height(38.dp) - ) { - Text( - text = String.format("%.2f", eqValue.floatValue), - fontSize = 12.sp, - color = textColor, - modifier = Modifier.padding(bottom = 4.dp) - ) - - Slider( - value = eqValue.floatValue, - onValueChange = { newVal -> - eqValue.floatValue = newVal - val newEQ = eq.value.copyOf() - newEQ[i] = eqValue.floatValue - 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 = { + .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(12.dp), - contentAlignment = Alignment.CenterStart + .height(4.dp) + .background(trackColor, RoundedCornerShape(4.dp)) + ) + Box( + modifier = Modifier + .fillMaxWidth(eqValue.floatValue / 100f) + .height(4.dp) + .background( + activeTrackColor, + RoundedCornerShape(4.dp) + ) ) - { - 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) - ) - } + Text( + text = stringResource(R.string.band_label, i + 1), + fontSize = 12.sp, + color = textColor, + modifier = Modifier.padding(top = 4.dp) + ) } } - - Spacer(modifier = Modifier.height(16.dp)) } - Spacer(modifier = Modifier.height(bottomPadding)) + 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 d9195718..38d299e3 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 @@ -38,10 +38,14 @@ 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.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape @@ -84,17 +88,14 @@ 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.utils.LogCollector import java.io.File import java.text.SimpleDateFormat @@ -118,7 +119,7 @@ fun CustomIconButton( @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @Composable -fun TroubleshootingScreen(navController: NavController) { +fun TroubleshootingScreen() { val context = LocalContext.current val scrollState = rememberScrollState() val coroutineScope = rememberCoroutineScope() @@ -214,538 +215,539 @@ fun TroubleshootingScreen(navController: NavController) { 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)) + val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - 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) - ) + Column( + modifier = Modifier + .fillMaxSize() + .layerBackdrop(backdrop) + .verticalScroll(scrollState) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(topPadding)) - Spacer(modifier = Modifier.height(2.dp)) + 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) + ) - if (savedLogs.isEmpty()) { - Column( + 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 + ) + } + } else { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + backgroundColor, + RoundedCornerShape(28.dp) + ) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Row( modifier = Modifier .fillMaxWidth() - .background( - backgroundColor, - RoundedCornerShape(28.dp) - ) - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { Text( - text = stringResource(R.string.no_logs_found), + 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 { - Column( - modifier = Modifier - .fillMaxWidth() - .background( - backgroundColor, - RoundedCornerShape(28.dp) - ) - .padding(horizontal = 16.dp, vertical = 8.dp) - ) { + + savedLogs.forEach { logFile -> Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, + .padding(vertical = 8.dp) + .clickable { + openLogBottomSheet(logFile) + }, verticalAlignment = Alignment.CenterVertically ) { - Text( - text = "Total Logs: ${savedLogs.size}", - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - color = textColor - ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = logFile.name, + fontSize = 16.sp, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) - if (savedLogs.size > 1) { - TextButton( - onClick = { showDeleteAllDialog = true }, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error - ) - ) { - Text("Delete All") - } + Text( + text = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.US) + .format(Date(logFile.lastModified())), + fontSize = 14.sp, + color = textColor.copy(alpha = 0.6f) + ) } - } - savedLogs.forEach { logFile -> - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - .clickable { - openLogBottomSheet(logFile) - }, - verticalAlignment = Alignment.CenterVertically + CustomIconButton( + onClick = { + selectedLogFile = logFile + showDeleteDialog = true + } ) { - 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 = { - selectedLogFile = logFile - showDeleteDialog = true - } - ) { - Icon( - Icons.Default.Delete, - contentDescription = "Delete", - tint = MaterialTheme.colorScheme.error - ) - } + Icon( + Icons.Default.Delete, + contentDescription = "Delete", + tint = MaterialTheme.colorScheme.error + ) } } } } + } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(16.dp)) - AnimatedVisibility( - visible = !showTroubleshootingSteps, - enter = fadeIn(animationSpec = tween(300)), - exit = fadeOut(animationSpec = tween(300)) + 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 ) { - 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)) - } + Text(stringResource(R.string.collect_logs)) } + } - AnimatedVisibility( - visible = showTroubleshootingSteps, - enter = fadeIn(animationSpec = tween(300)) + - slideInVertically(animationSpec = tween(300)) { it / 2 }, - exit = fadeOut(animationSpec = tween(300)) + - slideOutVertically(animationSpec = tween(300)) { it / 2 } - ) { - Column { - Spacer(modifier = Modifier.height(16.dp)) + AnimatedVisibility( + visible = showTroubleshootingSteps, + enter = fadeIn(animationSpec = tween(300)) + + slideInVertically(animationSpec = tween(300)) { it / 2 }, + exit = fadeOut(animationSpec = tween(300)) + + slideOutVertically(animationSpec = tween(300)) { it / 2 } + ) { + 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) + 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) + ) + .padding(16.dp) + ) { + val textAlpha = animateFloatAsState( + targetValue = 1f, + animationSpec = tween(durationMillis = 300), + label = "textAlpha" ) - Spacer(modifier = Modifier.height(2.dp)) + Text( + text = instructionText, + fontSize = 16.sp, + color = textColor.copy(alpha = textAlpha.value), + lineHeight = 22.sp + ) - Column( - modifier = Modifier - .fillMaxWidth() - .background( - backgroundColor, - RoundedCornerShape(28.dp) - ) - .padding(16.dp) - ) { - val textAlpha = animateFloatAsState( - targetValue = 1f, - animationSpec = tween(durationMillis = 300), - label = "textAlpha" - ) + Spacer(modifier = Modifier.height(16.dp)) - Text( - text = instructionText, - fontSize = 16.sp, - color = textColor.copy(alpha = textAlpha.value), - lineHeight = 22.sp - ) - - 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 - ) - ) { - Text("Open Xposed Settings") - } + 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 + ) + ) { + Text("Open Xposed Settings") } + } - 1 -> { - Button( - onClick = { - currentStep = 2 - isCollectingLogs = true + 1 -> { + Button( + onClick = { + 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()) + 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 - } + 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() } - } - } catch (e: Exception) { + ) + + val logFile = + logCollector.saveLogToInternalStorage( + "airpods_log_$timestamp.txt", + logContent + ) + logFile?.let { withContext(Dispatchers.Main) { + savedLogs.add(0, it) + selectedLogFile = it Toast.makeText( context, - "Error collecting logs: ${e.message}", + "Log saved: ${it.name}", 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)) + + Text( + text = if (currentStep == 2) "Preparing..." else "Collecting logs...", + fontSize = 14.sp, + color = textColor + ) + + if (currentStep == 3) { + Spacer(modifier = Modifier.height(16.dp)) + + 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() + } + } + }, + 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" + ) + ) } }, - modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(10.dp), colors = ButtonDefaults.buttonColors( containerColor = buttonBgColor, contentColor = textColor - ) + ), + modifier = Modifier.width(150.dp) ) { - Text("Continue") - } - } - - 2, 3 -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - CircularProgressIndicator( - color = accentColor + Icon( + imageVector = Icons.Default.Share, + contentDescription = "Share" ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = if (currentStep == 2) "Preparing..." else "Collecting logs...", - fontSize = 14.sp, - color = textColor - ) - - if (currentStep == 3) { - Spacer(modifier = Modifier.height(16.dp)) - - 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() - } - } - }, - 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" - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Share") - } - - Spacer(modifier = Modifier.width(16.dp)) - - Button( - onClick = { - selectedLogFile?.let { file -> - saveLauncher.launch( - file.absolutePath - ) - } - }, - 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.width(8.dp)) + Text("Share") } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.width(16.dp)) Button( onClick = { - currentStep = 0 - showTroubleshootingSteps = false + selectedLogFile?.let { file -> + saveLauncher.launch( + file.absolutePath + ) + } }, - modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(10.dp), colors = ButtonDefaults.buttonColors( containerColor = buttonBgColor, contentColor = textColor - ) + ), + modifier = Modifier.width(150.dp) ) { - Text("Done") + 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 (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()) { - savedLogs.remove(file) + deletedCount++ + } + } + withContext(Dispatchers.Main) { + if (deletedCount > 0) { + savedLogs.clear() Toast.makeText( context, - "Log file deleted", + "Deleted $deletedCount log files", Toast.LENGTH_SHORT - ) - .show() + ).show() } else { Toast.makeText( context, - "Failed to delete log file", + "Failed to delete log files", Toast.LENGTH_SHORT ).show() } } - showDeleteDialog = false } - ) { - Text("Delete", color = MaterialTheme.colorScheme.error) - } - }, - dismissButton = { - TextButton(onClick = { showDeleteDialog = false }) { - Text("Cancel") + showDeleteAllDialog = false } + ) { + Text("Delete All", color = MaterialTheme.colorScheme.error) } - ) - } - - 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 - } - ) { - Text("Delete All", color = MaterialTheme.colorScheme.error) - } - }, - dismissButton = { - TextButton(onClick = { showDeleteAllDialog = false }) { - Text("Cancel") - } + }, + dismissButton = { + TextButton(onClick = { showDeleteAllDialog = false }) { + Text("Cancel") } - ) - } + } + ) } + + Spacer(modifier = Modifier.height(bottomPadding)) } if (showBottomSheet) { 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 175a830c..514f3943 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,19 +19,26 @@ package me.kavishdevar.librepods.presentation.screens import android.util.Log -import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.background 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.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState 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,135 +52,104 @@ 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.tooling.preview.Preview +import androidx.compose.ui.unit.Dp 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.bluetooth.ATTHandles 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.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem +import me.kavishdevar.librepods.presentation.viewmodel.AirPodsUiState import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel +import me.kavishdevar.librepods.presentation.viewmodel.demoState -private const val TAG = "HearingAidAdjustments" +private const val TAG = "UpdateHearingTestScreen" @Composable -fun UpdateHearingTestScreen(viewModel: AirPodsViewModel) { - val verticalScrollState = rememberScrollState() +fun UpdateHearingTestRoute(viewModel: AirPodsViewModel) { 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 + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - Spacer(modifier = Modifier.height(topPadding)) + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + UpdateHearingTestScreen( + state = state, + topPadding = topPadding, + bottomPadding = bottomPadding, + setATTCharacteristicValue = viewModel::setATTCharacteristicValue + ) + } +} - 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, +@Composable +fun UpdateHearingTestScreen( + state: AirPodsUiState, + topPadding: Dp = 16.dp, + bottomPadding: Dp = 16.dp, + setATTCharacteristicValue: (ATTHandles, ByteArray) -> Unit +) { + val verticalScrollState = rememberScrollState() + + Column( + modifier = Modifier + .verticalScroll(verticalScrollState) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(topPadding)) + + Text( + text = stringResource(R.string.hearing_test_value_instruction), + style = MaterialTheme.typography.labelMedium, + 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()) } ) - 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)) - } + ) { + 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 - ) - ) - } - - 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( + val hearingAidSettings = remember { + mutableStateOf( + HearingAidSettings( leftEQ = leftEQ.value, rightEQ = rightEQ.value, leftAmplification = leftAmplification.floatValue, @@ -188,98 +164,169 @@ fun UpdateHearingTestScreen(viewModel: AirPodsViewModel) { 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(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") } + } - val frequencies = - listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz") + 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, setATTCharacteristicValue) + } + 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 = MaterialTheme.typography.labelMediumEmphasized + ) + Text( + text = stringResource(R.string.right), + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelMediumEmphasized + ) + } + + frequencies.forEachIndexed { index, freq -> 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 - ) + text = freq, + modifier = Modifier + .width(60.dp) + .align(Alignment.CenterVertically), + textAlign = TextAlign.End, + style = MaterialTheme.typography.labelSmall ) - Text( - text = stringResource(R.string.right), - modifier = Modifier.weight(1f), - textAlign = TextAlign.Center, - style = TextStyle( - fontSize = 18.sp, + 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)), - color = textColor - ) + 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) ) } + } + Spacer(modifier = Modifier.height(bottomPadding)) + } +} - frequencies.forEachIndexed { index, freq -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = freq, - 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) - ) - } - } - Spacer(modifier = Modifier.height(bottomPadding)) +@Preview(name = "Apple") +@Composable +fun UpdateHearingTestScreenPreviewApple() { + LibrePodsTheme( + m3eEnabled = false + ) { + Box ( + modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer) + ) { + UpdateHearingTestScreen( + state = demoState, + setATTCharacteristicValue = { _, _ -> } + ) } } } + +@Preview(name = "Material") +@Composable +fun UpdateHearingTestScreenPreviewMaterial() { + LibrePodsTheme( + m3eEnabled = true + ) { + Box ( + modifier = Modifier + .wrapContentHeight() + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + UpdateHearingTestScreen( + state = demoState, + setATTCharacteristicValue = { _, _ -> } + ) + } + } +} + 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 26695876..0cfcda92 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 @@ -19,162 +19,63 @@ 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.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars 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.layout.statusBars +import androidx.compose.material3.MaterialTheme 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.components.StyledList +import me.kavishdevar.librepods.presentation.components.StyledListItem +import me.kavishdevar.librepods.presentation.theme.DesignSystem +import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem 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 backdrop = rememberLayerBackdrop() + val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material + val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp + val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp - StyledScaffold( - title = stringResource(R.string.version) - ) { spacerHeight -> - Column( - modifier = Modifier - .fillMaxSize() - .layerBackdrop(backdrop) - .padding(horizontal = 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 + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(topPadding)) + StyledList(title = stringResource(R.string.version)) { + StyledListItem( + name = stringResource(R.string.version) + " 1", + description = state.version1, + enabled = false + ) - 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)) - ) - ) - } - 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)) - ) - ) - Text( - text = state.version3, - style = TextStyle( - fontSize = 16.sp, - color = textColor.copy(0.8f), - fontFamily = FontFamily(Font(R.font.sf_pro)) - ) - ) - } - } + StyledListItem( + name = stringResource(R.string.version) + " 2", + description = state.version2, + enabled = false + ) + + StyledListItem( + name = stringResource(R.string.version) + " 3", + description = state.version3, + enabled = false + ) } + Spacer(modifier = Modifier.height(bottomPadding)) } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/NotSupportedPage.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/NotSupportedPage.kt new file mode 100644 index 00000000..66125ea6 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/NotSupportedPage.kt @@ -0,0 +1,57 @@ +package me.kavishdevar.librepods.presentation.screens.onboarding + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +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.StyledListItem + +@Composable +fun NotSupportedPage( + bypassCompatibilityCheck: () -> Unit +) { + val scrollState = rememberScrollState() + + Box( + modifier = Modifier.background( + color = MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(42.dp) + ) + ) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = stringResource(R.string.check_the_repository_for_more_info), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = stringResource(R.string.enable_app_in_xposed_or_update_device), + style = MaterialTheme.typography.bodyMedium, + ) + DeviceInfoCard() + AppInfoCard() + + StyledListItem( + name = stringResource(R.string.bypass_compatibility_check), + onClick = bypassCompatibilityCheck + ) + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/OnboardingScreen.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/OnboardingScreen.kt new file mode 100644 index 00000000..ca6816c5 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/OnboardingScreen.kt @@ -0,0 +1,222 @@ +package me.kavishdevar.librepods.presentation.screens.onboarding + +import android.content.Context +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +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.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.carousel.HorizontalUncontainedCarousel +import androidx.compose.material3.carousel.rememberCarouselState +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +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.platform.LocalContext +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import kotlinx.coroutines.launch +import me.kavishdevar.librepods.R +import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme +import me.kavishdevar.librepods.utils.XposedState +import me.kavishdevar.librepods.utils.bypassDeviceCheck +import me.kavishdevar.librepods.utils.isSupported + +@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class) +@Composable +fun OnboardingScreen( + onOnboardingComplete: () -> Unit, +) { + val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE) + val isSupported = isSupported(sharedPreferences) || XposedState.bluetoothScopeEnabled + + val state = rememberCarouselState( + initialItem = 0, + itemCount = { 4 } + ) + + val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + + val titles = listOf( + null, + stringResource(R.string.privacy_policy), + stringResource(R.string.not_supported), + stringResource(R.string.permissions), + ) + + val animationScope = rememberCoroutineScope() + + BackHandler { + animationScope.launch { + if (state.canScrollBackward) { + val targetItem = if (isSupported && state.currentItem == 3) 1 else state.currentItem - 1 + state.animateScrollToItem(targetItem) + } + } + } + + LibrePodsTheme( + m3eEnabled = true + ) { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.height(topPadding)) + HorizontalUncontainedCarousel( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(12.dp), + state = state, + itemWidth = LocalWindowInfo.current.containerDpSize.width - 24.dp, + userScrollEnabled = false + ) { index -> + val shape = rememberMaskShape(RoundedCornerShape(52.dp)) + Surface( + shape = shape, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(52.dp)) + ) { + Column( + modifier = Modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + titles[index]?.let { + Text( + text = it, + style = MaterialTheme.typography.displayMedium, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center + ) + } + when (index) { + 0 -> { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier + .fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Welcome to", + style = MaterialTheme.typography.displayLarge, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.displayLargeEmphasized, + color = MaterialTheme.colorScheme.tertiary, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.app_description), + style = MaterialTheme.typography.bodyMediumEmphasized, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(64.dp)) + FilledTonalIconButton( + onClick = { + animationScope.launch { + state.animateScrollToItem(1) + } + }, + modifier = Modifier + .minimumInteractiveComponentSize() + .size( + IconButtonDefaults.largeContainerSize( + IconButtonDefaults.IconButtonWidthOption.Wide + ) + ), + shape = IconButtonDefaults.largeRoundShape + ) { + Icon( + Icons.AutoMirrored.Default.ArrowForward, + contentDescription = "forward", + modifier = Modifier.size(IconButtonDefaults.largeIconSize), + ) + } + } + } + } + 1 -> { + PrivacyPolicyPage( + onForward = { + animationScope.launch { + if (isSupported) state.animateScrollToItem(3) else state.animateScrollToItem(2) + } + } + ) + } + 2 -> { + NotSupportedPage( + bypassCompatibilityCheck = { + bypassDeviceCheck(sharedPreferences) + } + ) + } + 3 -> { + PermissionsPage( + onBackward = { + animationScope.launch { + if (state.canScrollBackward) state.animateScrollToItem(if (isSupported) 1 else 2) + } + }, + onForward = onOnboardingComplete + ) + } + } + } + } + } + Spacer(modifier = Modifier.height(bottomPadding)) + } + } +} + + +@OptIn(ExperimentalPermissionsApi::class) +@Preview +@Composable +fun OnboardingScreenPreview(){ + OnboardingScreen {} +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/PermissionsPage.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/PermissionsPage.kt new file mode 100644 index 00000000..8f428d0b --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/PermissionsPage.kt @@ -0,0 +1,345 @@ +package me.kavishdevar.librepods.presentation.screens.onboarding + +import android.content.Intent +import android.provider.Settings +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material3.Button +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialShapes +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.material3.toShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.google.accompanist.permissions.rememberPermissionState +import me.kavishdevar.librepods.presentation.MaterialIcons +import me.kavishdevar.librepods.presentation.components.ListItemOrientation +import me.kavishdevar.librepods.presentation.components.StyledList +import me.kavishdevar.librepods.presentation.components.StyledListItem + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun PermissionsPage( + onBackward: () -> Unit, + onForward: () -> Unit +) { + + var grantingAll = false + + val context = LocalContext.current + val canDrawOverlays = remember { mutableStateOf(Settings.canDrawOverlays(context)) } + + val phonePermissionState = rememberMultiplePermissionsState( + listOf( + "android.permission.READ_PHONE_STATE", + "android.permission.ANSWER_PHONE_CALLS" + ) + ) { + if (grantingAll) { + if (!canDrawOverlays.value) { + val intent = Intent( + Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + "package:${context.packageName}".toUri() + ) + context.startActivity(intent) + } + } + } + + + val notificationPermissionState = rememberPermissionState("android.permission.POST_NOTIFICATIONS") { + if (grantingAll) { + if (!phonePermissionState.allPermissionsGranted) phonePermissionState.launchMultiplePermissionRequest() + else if (!canDrawOverlays.value) canDrawOverlays.value = Settings.canDrawOverlays(context) + } + } + + + val bluetoothPermissionsState = rememberMultiplePermissionsState( + listOf( + "android.permission.BLUETOOTH_CONNECT", + "android.permission.BLUETOOTH_SCAN", + "android.permission.BLUETOOTH", + "android.permission.BLUETOOTH_ADMIN", + "android.permission.BLUETOOTH_ADVERTISE" + ) + ) { + if (grantingAll) { + if (!notificationPermissionState.status.isGranted) notificationPermissionState.launchPermissionRequest() + else if (!phonePermissionState.allPermissionsGranted) phonePermissionState.launchMultiplePermissionRequest() + else if (!canDrawOverlays.value) canDrawOverlays.value = Settings.canDrawOverlays(context) + } + } + + val lifecycleOwner = LocalLifecycleOwner.current + + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + canDrawOverlays.value = Settings.canDrawOverlays(context) + } + } + + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + val scrollState = rememberScrollState() + + Box( + modifier = Modifier.background( + color = MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(42.dp) + ) + ) { + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxSize() + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + StyledList(title = "Required Permissions") { + val animatedBluetoothIconColor by animateColorAsState(if (bluetoothPermissionsState.allPermissionsGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface) + val animatedBluetoothContainerColor by animateColorAsState( + if (bluetoothPermissionsState.allPermissionsGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest + ) + + StyledListItem( + name = "Bluetooth", + onClick = if (!bluetoothPermissionsState.allPermissionsGranted) { + { + grantingAll = false + bluetoothPermissionsState.launchMultiplePermissionRequest() + } + } else null, + description = "Required to communicate with AirPods", + orientation = ListItemOrientation.Vertical, + leadingContent = { + Box( + modifier = Modifier + .size(48.dp) + .background( + animatedBluetoothContainerColor, + MaterialShapes.SoftBurst.normalized() + .toShape() + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = MaterialIcons.bluetooth, + contentDescription = "bluetooth", + modifier = Modifier.size(24.dp), + tint = animatedBluetoothIconColor + ) + } + }, + ) + } + StyledList(title = "Optional Permissions") { + val animatedNotificationsIconColor by animateColorAsState( + if (notificationPermissionState.status.isGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface + ) + val animatedNotificationsContainerColor by animateColorAsState( + if (notificationPermissionState.status.isGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest + ) + val animatedPhoneIconColor by animateColorAsState(if (phonePermissionState.allPermissionsGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface) + val animatedPhoneContainerColor by animateColorAsState( + if (phonePermissionState.allPermissionsGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest + ) + + StyledListItem( + name = "Notifications", + onClick = if (!notificationPermissionState.status.isGranted) { + { + grantingAll = false + notificationPermissionState.launchPermissionRequest() + } + } else null, + description = "Show battery status", + orientation = ListItemOrientation.Vertical, + leadingContent = { + Box( + modifier = Modifier + .size(48.dp) + .background( + animatedNotificationsContainerColor, + MaterialShapes.SoftBurst.normalized() + .toShape() + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = MaterialIcons.notifications, + contentDescription = "notifications", + modifier = Modifier.size(24.dp), + tint = animatedNotificationsIconColor + ) + } + }, + ) + StyledListItem( + name = "Phone", + onClick = if (!phonePermissionState.allPermissionsGranted) { + { + grantingAll = false + phonePermissionState.launchMultiplePermissionRequest() + } + } else null, + description = "Respond to phone calls with head gestures", + orientation = ListItemOrientation.Vertical, + leadingContent = { + Box( + modifier = Modifier + .size(48.dp) + .background( + animatedPhoneContainerColor, + MaterialShapes.SoftBurst.normalized() + .toShape() + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = MaterialIcons.call, + contentDescription = "bluetooth", + modifier = Modifier.size(24.dp), + tint = animatedPhoneIconColor + ) + } + }, + ) + } + + val animatedOverlayIconColor by animateColorAsState(if (canDrawOverlays.value) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface) + val animatedOverlayContainerColor by animateColorAsState(if (canDrawOverlays.value) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest) + + StyledListItem( + name = "Display over other apps", + onClick = if (!canDrawOverlays.value) { + { + grantingAll = false + val intent = Intent( + Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + "package:${context.packageName}".toUri() + ) + context.startActivity(intent) + } + } else null, + description = "Show popups when AirPods are nearby or audio switches to them.", + orientation = ListItemOrientation.Vertical, + leadingContent = { + Box( + modifier = Modifier + .size(48.dp) + .background( + animatedOverlayContainerColor, + MaterialShapes.SoftBurst.normalized() + .toShape() + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = MaterialIcons.stack, + contentDescription = "bluetooth", + modifier = Modifier.size(24.dp), + tint = animatedOverlayIconColor + ) + } + }, + ) + + Spacer(modifier = Modifier.weight(1f)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilledIconButton( + onClick = onBackward, + modifier = Modifier + .minimumInteractiveComponentSize() + .size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Narrow)), + shape = IconButtonDefaults.mediumRoundShape + ) { + Icon( + Icons.AutoMirrored.Default.ArrowBack, + contentDescription = "backward", + modifier = Modifier.size(IconButtonDefaults.mediumIconSize), + ) + } + Button( + onClick = { + grantingAll = true + if (!bluetoothPermissionsState.allPermissionsGranted) bluetoothPermissionsState.launchMultiplePermissionRequest() + else if (!notificationPermissionState.status.isGranted) notificationPermissionState.launchPermissionRequest() + else if (!phonePermissionState.allPermissionsGranted) phonePermissionState.launchMultiplePermissionRequest() + else if (!canDrawOverlays.value) canDrawOverlays.value = + Settings.canDrawOverlays(context) + }, + modifier = Modifier + .height(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Narrow).height) + .weight(1f), + enabled = !bluetoothPermissionsState.allPermissionsGranted || !notificationPermissionState.status.isGranted || !phonePermissionState.allPermissionsGranted || !canDrawOverlays.value + ) { + Text( + text = "Grant all", + style = MaterialTheme.typography.labelMedium + ) + } + FilledIconButton( + onClick = onForward, + modifier = Modifier + .minimumInteractiveComponentSize() + .size( + IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Narrow) + ), + shape = IconButtonDefaults.mediumRoundShape, + enabled = bluetoothPermissionsState.allPermissionsGranted + ) { + Icon( + Icons.AutoMirrored.Default.ArrowForward, + contentDescription = "forward", + modifier = Modifier.size(IconButtonDefaults.mediumIconSize), + ) + } + } + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/PrivacyPolicyPage.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/PrivacyPolicyPage.kt new file mode 100644 index 00000000..23eaa837 --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/screens/onboarding/PrivacyPolicyPage.kt @@ -0,0 +1,197 @@ +package me.kavishdevar.librepods.presentation.screens.onboarding + +import androidx.compose.foundation.background +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.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.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import me.kavishdevar.librepods.BuildConfig +import me.kavishdevar.librepods.R + +@Composable +fun PrivacyPolicyPage( + onForward: () -> Unit +) { + val scrollState = rememberScrollState() + + Box( + modifier = Modifier.background( + color = MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(42.dp) + ) + ) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = "Last updated: 20 June 2026", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Text( + text = "Overview", + style = MaterialTheme.typography.titleLarge + ) + + Text( + text = "LibrePods does not collect, store, sell, or share personal information for advertising, analytics, tracking, or profiling purposes. The app does not include analytics, crash reporting, telemetry, advertising SDKs, or tracking services.", + style = MaterialTheme.typography.bodyMedium + ) + + Text( + text = "All information remains on your device unless you explicitly choose to contact me, create a GitHub issue from the app, or make a purchase or sponsorship through a third-party platform.", + style = MaterialTheme.typography.bodyMedium + ) + + Text( + text = "Third Party Services", + style = MaterialTheme.typography.titleLarge + ) + + Text( + text = "LibrePods provides several ways to contact me, including email, Discord, and GitHub Issues.", + style = MaterialTheme.typography.bodyMedium + ) + + Text( + text = "Email", + style = MaterialTheme.typography.titleMedium + ) + + Text( + text = "If you contact me by email, I receive your email address and any information you choose to include in your message. When using the contact form within LibrePods, your email client will open with a pre-filled email address, the subject line and body that you fill out. The body will also include LibrePods version information and device information to help with troubleshooting.", + style = MaterialTheme.typography.bodyMedium + ) + + Text( + text = "You can edit or remove any of this information before sending the email.", + style = MaterialTheme.typography.bodyMedium + ) + + Text( + text = "Discord", + style = MaterialTheme.typography.titleMedium + ) + + Text( + text = "The app provides a link to the LibrePods Discord server. If you choose to join the Discord server, you will be subject to Discord's privacy policy.", + style = MaterialTheme.typography.bodyMedium + ) + + Text( + text = "I do not receive any information about you from Discord other than what is publicly visible in the Discord server, such as your username, joining date, common servers, and any messages or content you post in the server, unless you choose to share it with me in the Discord server.", + style = MaterialTheme.typography.bodyMedium + ) + + Text( + text = "GitHub Issues", + style = MaterialTheme.typography.titleMedium + ) + + Text( + text = "When creating a GitHub issue through LibrePods, the app will pre-fill the issue form with:", + style = MaterialTheme.typography.bodyMedium + ) + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(start = 8.dp) + ) { + Text( + "• LibrePods version name and version code", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "• Device manufacturer and model", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "• Android build information", + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = "• Installation source (Google Play or GitHub)", + style = MaterialTheme.typography.bodyMedium + ) + } + + Text( + text = "This information helps diagnose bugs and provide support. No information is sent automatically. The information is only submitted if you choose to create the GitHub issue.", + style = MaterialTheme.typography.bodyMedium + ) + + Text( + text = "Payments", style = MaterialTheme.typography.titleLarge + ) + + if (BuildConfig.PLAY_BUILD) { + Text( + text = "Google Play", style = MaterialTheme.typography.titleMedium + ) + + Text( + text = "When using the version available on Google Play, purchases are processed by Google Play.", + style = MaterialTheme.typography.bodyMedium + ) + + Text( + text = "LibrePods verifies the purchase with Google Play on-device, not with a remote server that I control. I do not receive any information about you or your purchase from Google Play. Payment processing is handled entirely by Google Play, and I do not have access to any of your payment information.", + style = MaterialTheme.typography.bodyMedium + ) + } else { + Text( + text = "GitHub Sponsors", style = MaterialTheme.typography.titleMedium + ) + + Text( + text = "When using the FOSS version available on GitHub, the upgrade button links to GitHub Sponsors. If you choose to sponsor LibrePods, your sponsorship is processed by GitHub.", + style = MaterialTheme.typography.bodyMedium + ) + + Text( + text = "Your username and country/region are shared with me when you sponsor LibrePods. Depending on your GitHub Sponsors privacy settings, I may also receive your email address.", + style = MaterialTheme.typography.bodyMedium + ) + } + + Text( + text = "Contact", style = MaterialTheme.typography.titleLarge + ) + + Text( + text = "If you have questions about this privacy policy, please contact me via email at privacy@kavish.xyz.", + style = MaterialTheme.typography.bodyMedium + ) + + Button( + onClick = onForward, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.i_agree), + style = MaterialTheme.typography.labelMediumEmphasized + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + } + } +} diff --git a/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/LocalDesignSystem.kt b/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/LocalDesignSystem.kt new file mode 100644 index 00000000..1580e6ba --- /dev/null +++ b/android/app/src/main/java/me/kavishdevar/librepods/presentation/theme/LocalDesignSystem.kt @@ -0,0 +1,12 @@ +package me.kavishdevar.librepods.presentation.theme + +import androidx.compose.runtime.compositionLocalOf + +enum class DesignSystem { + Apple, + Material +} + +val LocalDesignSystem = compositionLocalOf { + DesignSystem.Apple +} 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 04225ccd..5d76d69e 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 @@ -18,47 +18,71 @@ package me.kavishdevar.librepods.presentation.theme -import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.MotionScheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80 +val ColorScheme.sectionHeader: Color + get() = onBackground.copy(alpha = 0.6f) + +private val AppleDarkColorScheme = darkColorScheme( + surfaceContainer = Color(0xFF000000), // for some reason background is not used as the background in gmail and settings app, but surfacecontainer, so using that + onBackground = Color(0xFFFFFFFF), + surface = Color(0xFF1C1C1E), + onSurface = Color(0xFFFFFFFF), + surfaceDim = Color(0x40888888), + primary = Color(0xFF0091FF), + secondaryContainer = Color(0xFF366AA8), + onSecondaryContainer = Color(0xFF0091FF), + onPrimary = Color(0xFFFFFFFF) ) -private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40 +private val AppleLightColorScheme = lightColorScheme( + surfaceContainer = Color(0xFFF2F2F7), + onBackground = Color(0xFF000000), + surface = Color(0xFFFFFFFF), + onSurface = Color(0xFF000000), + surfaceDim = Color(0x40D9D9D9), + secondaryContainer = Color(0xFF6BC0FF), + onSecondaryContainer = Color(0xFF0088FF), + primary = Color(0xFF0088FF), + onPrimary = Color(0xFFFFFFFF) ) @Composable fun LibrePodsTheme( darkTheme: Boolean = isSystemInDarkTheme(), - dynamicColor: Boolean = true, + m3eEnabled: Boolean = false, content: @Composable () -> Unit ) { val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + m3eEnabled -> { val context = LocalContext.current if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } - - darkTheme -> DarkColorScheme - else -> LightColorScheme + darkTheme -> AppleDarkColorScheme + else -> AppleLightColorScheme } - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) + CompositionLocalProvider( + LocalDesignSystem provides + if (m3eEnabled) DesignSystem.Material + else DesignSystem.Apple + ) { + MaterialExpressiveTheme( + colorScheme = colorScheme, + motionScheme = MotionScheme.expressive(), + typography = if (m3eEnabled) MaterialTypography else AppleTypography, + 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 72a44242..a62b9fdd 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 @@ -18,35 +18,400 @@ package me.kavishdevar.librepods.presentation.theme +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +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.Text import androidx.compose.material3.Typography -import androidx.compose.ui.text.TextStyle +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +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.text.googlefonts.GoogleFont +import androidx.compose.ui.tooling.preview.Devices.PIXEL_9_PRO_XL +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp 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 +val sfProFamily = FontFamily(Font(R.font.sf_pro)) + +val AppleTypography = Typography().run { + copy( + displayLarge = displayLarge.copy(fontFamily = sfProFamily), + displayMedium = displayMedium.copy(fontFamily = sfProFamily), + displaySmall = displaySmall.copy(fontFamily = sfProFamily), + + headlineLarge = headlineLarge.copy(fontFamily = sfProFamily), + headlineMedium = headlineMedium.copy(fontFamily = sfProFamily), + headlineSmall = headlineSmall.copy(fontFamily = sfProFamily), + + titleLarge = titleLarge.copy(fontFamily = sfProFamily), + titleMedium = titleMedium.copy(fontFamily = sfProFamily), + titleSmall = titleSmall.copy(fontFamily = sfProFamily), + + bodyLarge = bodyLarge.copy(fontFamily = sfProFamily), + bodyMedium = bodyMedium.copy( + fontFamily = sfProFamily, + fontSize = 16.sp + ), + bodySmall = bodySmall.copy( + fontFamily = sfProFamily, + fontSize = 14.sp, + lineHeight = 18.sp + ), + + labelLarge = labelLarge.copy(fontFamily = sfProFamily), + + labelMedium = labelMedium.copy( + fontFamily = sfProFamily, + fontSize = 16.sp, + ), + labelMediumEmphasized = labelMediumEmphasized.copy( + fontFamily = sfProFamily, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ), + labelSmallEmphasized = labelSmallEmphasized.copy( + fontFamily = sfProFamily, + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ) ) - /* 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 - ) - */ +} + +val provider = GoogleFont.Provider( + providerAuthority = "com.google.android.gms.fonts", + providerPackage = "com.google.android.gms", + certificates = R.array.com_google_android_gms_fonts_certs ) + +private fun robotoFlex( + wght: Float = 400f, + slnt: Float = 0f, + grad: Float = 0f, + wdth: Float = 100f, + xtra: Float = 468f, + xopq: Float = 96f, + yopq: Float = 79f, +) = FontFamily( + androidx.compose.ui.text.googlefonts.Font( +// Font( +// resId = R.font.roboto_flex, + googleFont = GoogleFont("Roboto Flex"), + fontProvider = provider, + variationSettings = FontVariation.Settings( + FontVariation.Setting("wght", wght), + FontVariation.Setting("wdth", wdth), + FontVariation.Setting("slnt", slnt), + FontVariation.Setting("grad", grad), + FontVariation.Setting("xtra", xtra), + FontVariation.Setting("xopq", xopq), + FontVariation.Setting("yopq", yopq), + ) + ) +) + +val display = robotoFlex( + wght = 800f, + grad = 100f, + wdth = 100f +) + +val displayEmphasized = robotoFlex( + wght = 1000f, + slnt = -2f, + grad = 150f, + wdth = 150f, +) + +val body = robotoFlex() + +val bodyEmphasized = robotoFlex( + wght = 600f, + wdth = 130f, + grad = 75f, +) + +val label = robotoFlex( + wght = 450f, + grad = 50f +) + +val labelEmphasized = robotoFlex( + wght = 600f, + wdth = 140f, + grad = 75f +) + + +val MaterialTypography = Typography().run { + copy( + titleSmall = titleSmall.copy( + fontFamily = display, + fontSize = 24.sp, + lineHeight = 30.sp, + ), + + titleMedium = titleMedium.copy( + fontFamily = display, + fontSize = 28.sp, + lineHeight = 32.sp, + ), + + titleLarge = titleLarge.copy( + fontFamily = display, + fontSize = 32.sp, + lineHeight = 36.sp, + ), + + titleSmallEmphasized = titleSmallEmphasized.copy( + fontFamily = display, + fontSize = 24.sp, + lineHeight = 30.sp, + ), + + titleMediumEmphasized = titleMediumEmphasized.copy( + fontFamily = displayEmphasized, + fontSize = 28.sp, + lineHeight = 32.sp, + ), + + titleLargeEmphasized = titleLargeEmphasized.copy( + fontFamily = displayEmphasized, + fontSize = 32.sp, + lineHeight = 36.sp, + ), + + displaySmall = displaySmall.copy( + fontFamily = display, + fontSize = 32.sp, + lineHeight = 36.sp, + ), + + displayMedium = displayMedium.copy( + fontFamily = display, + fontSize = 36.sp, + lineHeight = 40.sp, + ), + + displayLarge = displayLarge.copy( + fontFamily = display, + fontSize = 40.sp, + lineHeight = 44.sp, + ), + + displaySmallEmphasized = displaySmallEmphasized.copy( + fontFamily = displayEmphasized, + fontSize = 38.sp, + lineHeight = 42.sp, + ), + + displayMediumEmphasized = displayMediumEmphasized.copy( + fontFamily = displayEmphasized, + fontSize = 42.sp, + lineHeight = 48.sp, + ), + + displayLargeEmphasized = displayLargeEmphasized.copy( + fontFamily = displayEmphasized, + fontSize = 48.sp, + lineHeight = 52.sp, + ), + + bodySmall = bodySmall.copy( + fontFamily = body, + fontSize = 14.sp, + lineHeight = 20.sp, + ), + + bodyMedium = bodyMedium.copy( + fontFamily = body, + fontSize = 16.sp, + lineHeight = 24.sp, + ), + + bodyLarge = bodyLarge.copy( + fontFamily = body, + fontSize = 18.sp, + lineHeight = 28.sp, + ), + + bodySmallEmphasized = bodySmallEmphasized.copy( + fontFamily = bodyEmphasized, + fontSize = 14.sp, + lineHeight = 20.sp, + ), + + bodyMediumEmphasized = bodyMediumEmphasized.copy( + fontFamily = bodyEmphasized, + fontSize = 16.sp, + lineHeight = 24.sp, + ), + + bodyLargeEmphasized = bodyLargeEmphasized.copy( + fontFamily = bodyEmphasized, + fontSize = 18.sp, + lineHeight = 28.sp, + ), + + labelSmall = labelSmall.copy( + fontFamily = label, + fontSize = 14.sp, + lineHeight = 18.sp, + ), + + labelMedium = labelMedium.copy( + fontFamily = label, + fontSize = 16.sp, + lineHeight = 20.sp, + ), + + labelLarge = labelLarge.copy( + fontFamily = label, + fontSize = 18.sp, + lineHeight = 22.sp, + ), + + labelSmallEmphasized = labelSmallEmphasized.copy( + fontFamily = labelEmphasized, + fontSize = 14.sp, + lineHeight = 18.sp, + ), + + labelMediumEmphasized = labelMediumEmphasized.copy( + fontFamily = labelEmphasized, + fontSize = 16.sp, + lineHeight = 20.sp, + ), + + labelLargeEmphasized = labelLargeEmphasized.copy( + fontFamily = labelEmphasized, + fontSize = 18.sp, + lineHeight = 22.sp, + ), + ) +} + +@Preview( + name = "Typography Showcase", + showBackground = true, + device = PIXEL_9_PRO_XL +) +@Composable +private fun TypographyPreview() { + LibrePodsTheme (m3eEnabled = true) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceContainer, RoundedCornerShape(28.dp)) + .padding(24.dp) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + "Display Large", + style = MaterialTheme.typography.displayLarge + ) + + Text( + "Display Large Emphasized", + style = MaterialTheme.typography.displayLargeEmphasized + ) + + Text( + "Display Medium", + style = MaterialTheme.typography.displayMedium + ) + + Text( + "Display Medium Emphasized", + style = MaterialTheme.typography.displayMediumEmphasized + ) + + Text( + "Display Small", + style = MaterialTheme.typography.displaySmall + ) + + Text( + "Display Small Emphasized", + style = MaterialTheme.typography.displaySmallEmphasized + ) + + HorizontalDivider() + + Text( + "Body Large", + style = MaterialTheme.typography.bodyLarge + ) + + Text( + "Body Large Emphasized", + style = MaterialTheme.typography.bodyLargeEmphasized + ) + + Text( + "Body Medium", + style = MaterialTheme.typography.bodyMedium + ) + + Text( + "Body Medium Emphasized", + style = MaterialTheme.typography.bodyMediumEmphasized + ) + + Text( + "Body Small", + style = MaterialTheme.typography.bodySmall + ) + + Text( + "Body Small Emphasized", + style = MaterialTheme.typography.bodySmallEmphasized + ) + + HorizontalDivider() + + Text( + "Label Large", + style = MaterialTheme.typography.labelLarge + ) + + Text( + "Label Large Emphasized", + style = MaterialTheme.typography.labelLargeEmphasized + ) + + Text( + "Label Medium", + style = MaterialTheme.typography.labelMedium + ) + + Text( + "Label Medium Emphasized", + style = MaterialTheme.typography.labelMediumEmphasized + ) + + Text( + "Label Small", + style = MaterialTheme.typography.labelSmall + ) + + Text( + "Label Small Emphasized", + style = MaterialTheme.typography.labelSmallEmphasized + ) + } + } + } +} 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 939588e9..8c99178d 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 @@ -24,13 +24,14 @@ import android.content.Intent import android.content.IntentFilter import android.content.SharedPreferences import android.content.pm.PackageManager -import android.util.Log import android.widget.Toast +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.core.content.edit import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -57,7 +58,7 @@ import me.kavishdevar.librepods.services.AirPodsService @Suppress("ArrayInDataClass") data class AirPodsUiState( - val deviceName: String, + val deviceName: String = "AirPods", val isLocallyConnected: Boolean = false, @@ -103,24 +104,127 @@ data class AirPodsUiState( val customEq: CustomEq = CustomEq(1, 50, 50, 50) // disabled ) -class AirPodsViewModel( - private val service: AirPodsService, - private val sharedPreferences: SharedPreferences, - private val controlRepo: ControlCommandRepository, - private val appContext: Context -) : ViewModel() { - private val _uiState = MutableStateFlow( - AirPodsUiState( - deviceName = sharedPreferences.getString( - "name", - "AirPods Pro" - ) ?: "AirPods Pro" - ) +val demoInstance = AirPodsInstance( + name = "AirPods Pro", + model = AirPodsModels.getModelByModelNumber("A3064")!!, + actualModelNumber = "A3064", + serialNumber = "JXF9Q94A40", + leftSerialNumber = "L-DEMO", + rightSerialNumber = "R-DEMO", + version1 = "90.3388000000000000.1786", + version2 = "90.3388000000000000.1786", + version3 = "9441861", +) + +val demoState = AirPodsUiState( + deviceName = demoInstance.name, + + isLocallyConnected = true, + + capabilities = demoInstance.model.capabilities, + + battery = listOf( + Battery(BatteryComponent.LEFT, 80, BatteryStatus.OPTIMIZED_CHARGING), + Battery(BatteryComponent.RIGHT, 18, BatteryStatus.CHARGING), + Battery(BatteryComponent.CASE, 76, BatteryStatus.NOT_CHARGING) + ), + + ancMode = 3, + offListeningMode = false, + + modelName = demoInstance.model.displayName, + actualModel = demoInstance.actualModelNumber, + serialNumbers = listOf( + demoInstance.serialNumber?: "", + demoInstance.leftSerialNumber?: "", + demoInstance.rightSerialNumber?: "" + ), + + version1 = demoInstance.version1?: "", + version2 = demoInstance.version2?: "", + version3 = demoInstance.version3?: "", + + headTrackingActive = true, + headGesturesEnabled = true, + + automaticEarDetectionEnabled = true, + automaticConnectionEnabled = true, + + leftAction = StemAction.CYCLE_NOISE_CONTROL_MODES, + rightAction = StemAction.DIGITAL_ASSISTANT, + + loudSoundReductionEnabled = true, + + isPremium = true, + vendorIdHook = true, + + dynamicEndOfCharge = true, + + connectionSuccessful = true, + + customEq = CustomEq(state = 2, low = 65, mid = 50, high = 70), + + controlStates = mapOf( + ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG to byteArrayOf(0x01), + ControlCommandIdentifiers.STEM_CONFIG to byteArrayOf(0x00), + ControlCommandIdentifiers.CLICK_HOLD_INTERVAL to byteArrayOf(0x00), + ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL to byteArrayOf(0x00), + ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL to byteArrayOf(0x00), + ControlCommandIdentifiers.VOLUME_SWIPE_MODE to byteArrayOf(0x01), + ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG to byteArrayOf(0x00, 0x03), + ControlCommandIdentifiers.CHIME_VOLUME to byteArrayOf(0x46, 0x50), + ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG to byteArrayOf(0x01), + ControlCommandIdentifiers.HEARING_AID to byteArrayOf(0x01, 0x02), + ControlCommandIdentifiers.HPS_GAIN_SWIPE to byteArrayOf(0x01), + ControlCommandIdentifiers.HEARING_ASSIST_CONFIG to byteArrayOf(0x02), + ControlCommandIdentifiers.HRM_STATE to byteArrayOf(0x01), + ControlCommandIdentifiers.AUTO_ANC_STRENGTH to byteArrayOf(0x45), + ControlCommandIdentifiers.ONE_BUD_ANC_MODE to byteArrayOf(0x01), + ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG to byteArrayOf(0x01), + ControlCommandIdentifiers.PPE_TOGGLE_CONFIG to byteArrayOf(0x01), + ControlCommandIdentifiers.PPE_CAP_LEVEL_CONFIG to byteArrayOf(0x52), + ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE to byteArrayOf(0x01), + ControlCommandIdentifiers.LISTENING_MODE to byteArrayOf(0x04) ) +) + +class AirPodsViewModel( + +) : ViewModel() { + private lateinit var sharedPreferences: SharedPreferences + private lateinit var appContext: Context + private lateinit var service: AirPodsService + private lateinit var controlRepo: ControlCommandRepository + + var isReady by mutableStateOf(false) + private set + + fun init(service: AirPodsService, controlRepo: ControlCommandRepository, sharedPreferences: SharedPreferences, appContext: Context) { + this.service = service + this.controlRepo = controlRepo + this.sharedPreferences = sharedPreferences + this.appContext = appContext + + observeBroadcasts() + loadName() + loadInstance() + loadSharedPreferences() + observeAACP() + loadCurrentStatus() + loadEq() + loadATT() + observeATT() + observeSharedPreferences() + observeBilling() + if (isDemoMode) activateDemoMode() + isReady = true + } + + private val _uiState = MutableStateFlow(AirPodsUiState()) + val uiState: StateFlow = _uiState private var isDemoMode = false - val demoActivated = MutableSharedFlow() private val listeners = mutableMapOf() @@ -129,19 +233,19 @@ class AirPodsViewModel( private lateinit var broadcastReceiver: BroadcastReceiver - private val _cameraAction = MutableStateFlow( - sharedPreferences.getString("camera_action", null) - ?.let { value -> AACPManager.Companion.StemPressType.entries.find { it.name == value } }) - - val cameraAction: StateFlow = _cameraAction - - fun setCameraAction(action: AACPManager.Companion.StemPressType?) { - sharedPreferences.edit { - if (action == null) remove("camera_action") - else putString("camera_action", action.name) - } - _cameraAction.value = action - } +// private val _cameraAction = MutableStateFlow( +// sharedPreferences.getString("camera_action", null) +// ?.let { value -> AACPManager.Companion.StemPressType.entries.find { it.name == value } }) +// +// val cameraAction: StateFlow = _cameraAction +// +// fun setCameraAction(action: AACPManager.Companion.StemPressType?) { +// sharedPreferences.edit { +// if (action == null) remove("camera_action") +// else putString("camera_action", action.name) +// } +// _cameraAction.value = action +// } fun setCustomEq(low: Int, mid: Int, high: Int) { require(low in 0..100) @@ -165,29 +269,12 @@ class AirPodsViewModel( } } - init { - observeBroadcasts() - loadName() - loadInstance() - loadSharedPreferences() - observeAACP() - loadControlList() - loadEq() - loadATT() - observeATT() - observeSharedPreferences() - observeBilling() - if (isDemoMode) activateDemoMode() - } - override fun onCleared() { listeners.forEach { (id, listener) -> controlRepo.remove(id, listener) } service.aacpManager.customEqCallback = null appContext.unregisterReceiver(broadcastReceiver) - - super.onCleared() } private fun loadName() { @@ -198,12 +285,7 @@ class AirPodsViewModel( private fun observeBilling() { if (isDemoMode) return viewModelScope.launch { -// if (!BuildConfig.PLAY_BUILD) billingFirstCollectDone = true // FOSS doesn't send multiple events BillingManager.provider.isPremium.collect { premium -> -// if (!billingFirstCollectDone) { -// billingFirstCollectDone = true -// return@collect -// } if (premium) { sharedPreferences.edit { remove("premium_expiry_time") @@ -378,12 +460,15 @@ class AirPodsViewModel( } } - fun refreshInitialData() { + fun loadCurrentStatus() { if (isDemoMode) return service.let { service -> _uiState.update { it.copy( - isLocallyConnected = BluetoothConnectionManager.getAACPSocket()?.isConnected == true, battery = service.getBattery() + isLocallyConnected = BluetoothConnectionManager.aacpSocket?.isConnected == true, + battery = service.getBattery(), + ancMode = controlRepo.getValue(ControlCommandIdentifiers.LISTENING_MODE)?.get(0)?.toInt() ?: 1, + controlStates = controlRepo.getMap() ) } } @@ -500,14 +585,6 @@ class AirPodsViewModel( } } - private fun loadControlList() { - _uiState.update { - it.copy( - controlStates = controlRepo.getMap() - ) - } - } - private fun loadEq() { _uiState.update { it.copy( @@ -607,7 +684,6 @@ class AirPodsViewModel( viewModelScope.launch(Dispatchers.IO) { service.attManager.enableNotification(ATTCCCDHandles.HEARING_AID) service.attManager.enableNotification(ATTCCCDHandles.TRANSPARENCY) -// service.attManager.enableNotification(ATTCCCDHandles.LOUD_SOUND_REDUCTION) } service.attManager.setOnNotificationReceived { handle, value -> when (handle) { @@ -655,40 +731,7 @@ class AirPodsViewModel( fun activateDemoMode() { isDemoMode = true - viewModelScope.launch { - demoActivated.emit(Unit) - } - val fakeInstance = AirPodsInstance( - name = "AirPods Pro (Demo)", - model = AirPodsModels.getModelByModelNumber("A3049")!!, - actualModelNumber = "A3049", - serialNumber = "DEMO123", - leftSerialNumber = "L-DEMO", - rightSerialNumber = "R-DEMO", - version1 = "1.0", - version2 = "1.0", - version3 = "1.0", - ) - - _uiState.update { - it.copy( - isLocallyConnected = true, - instance = fakeInstance, - capabilities = fakeInstance.model.capabilities, - - battery = listOf( - Battery(BatteryComponent.LEFT, 85, BatteryStatus.CHARGING), - Battery(BatteryComponent.RIGHT, 25, BatteryStatus.NOT_CHARGING), - Battery(BatteryComponent.CASE, 85, BatteryStatus.CHARGING), - ), - - modelName = fakeInstance.model.displayName, - actualModel = fakeInstance.actualModelNumber, - serialNumbers = listOf("DEMO", "DEMO", "DEMO"), - version3 = "Demo Firmware", - isPremium = true - ) - } + _uiState.update {demoState} } fun sendPhoneMediaEQ(eq: FloatArray, phoneByte: Byte, mediaByte: Byte) { @@ -725,10 +768,19 @@ class AirPodsViewModel( } fun disconnect() { - service.disconnectAirPods() - if (appContext.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") != PackageManager.PERMISSION_GRANTED) { - Toast.makeText(appContext, "App has disconnected, disconnect from Android Settings.", - Toast.LENGTH_LONG).show() + if (isDemoMode) { + isDemoMode = false + _uiState.update { + it.copy(isLocallyConnected = false) + } + } else { + service.disconnectAirPods() + if (appContext.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") != PackageManager.PERMISSION_GRANTED) { + Toast.makeText( + appContext, "App has disconnected, disconnect from Android Settings.", + Toast.LENGTH_LONG + ).show() + } } } } 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 168e98b2..2c1716c3 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 @@ -36,7 +36,8 @@ data class AppSettingsUiState( val connectionSuccessful: Boolean = false, val showBottomSheetPopup: Boolean = true, val showIslandPopup: Boolean = true, - val timeUntilFOSSPremiumExpiry: Long = 0L + val timeUntilFOSSPremiumExpiry: Long = 0L, + val m3eEnabled: Boolean = false ) class AppSettingsViewModel(application: Application) : AndroidViewModel(application) { @@ -62,7 +63,6 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat override fun onCleared() { sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPrefListener) - super.onCleared() } private fun observeBilling() { @@ -71,7 +71,7 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat if (premium) { sharedPreferences.edit { remove("premium_expiry_time") - remove("foss_upgraded") + if (BuildConfig.PLAY_BUILD) remove("foss_upgraded") } _uiState.update { it.copy(isPremium = true, timeUntilFOSSPremiumExpiry = 0L) } } else { @@ -151,7 +151,8 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat 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) + showIslandPopup = sharedPreferences.getBoolean("show_island_popup", true), + m3eEnabled = sharedPreferences.getBoolean("m3e_enabled", true) ) } } @@ -251,4 +252,9 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat sharedPreferences.edit { putBoolean("show_island_popup", enabled) } _uiState.update { it.copy(showIslandPopup = enabled) } } + + fun setm3eEnabled(enabled: Boolean) { + sharedPreferences.edit { putBoolean("m3e_enabled", enabled) } + _uiState.update { it.copy(m3eEnabled = enabled) } + } } diff --git a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt index eea7c6fc..8a708016 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/services/AirPodsQSService.kt @@ -99,7 +99,7 @@ class AirPodsQSService : TileService() { Log.d("AirPodsQSService", "onStartListening") val service = ServiceManager.getService() - isAirPodsConnected = BluetoothConnectionManager.getAACPSocket()?.isConnected == true + isAirPodsConnected = BluetoothConnectionManager.aacpSocket?.isConnected == true currentAncMode = service?.getANC() ?: (NoiseControlMode.OFF.ordinal + 1) if (currentAncMode == NoiseControlMode.OFF.ordinal + 1 && !isOffModeEnabled()) { 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 7dff6f69..0cf08c11 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 @@ -85,11 +85,11 @@ import me.kavishdevar.librepods.MainActivity import me.kavishdevar.librepods.R import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.StemPressType -import me.kavishdevar.librepods.bluetooth.ATTCCCDHandles import me.kavishdevar.librepods.bluetooth.ATTHandles import me.kavishdevar.librepods.bluetooth.ATTManagerv2 import me.kavishdevar.librepods.bluetooth.BLEManager import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager +import me.kavishdevar.librepods.bluetooth.createBluetoothSocket import me.kavishdevar.librepods.data.AirPodsInstance import me.kavishdevar.librepods.data.AirPodsModels import me.kavishdevar.librepods.data.AirPodsNotifications @@ -246,7 +246,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList override fun onDeviceStatusChanged( device: BLEManager.AirPodsStatus, previousStatus: BLEManager.AirPodsStatus? ) { - if (device.connectionState == "Disconnected" && BluetoothConnectionManager.getAACPSocket()?.isConnected != true) { // should never happen unless android messes up and sends us a stale broadcast + if (device.connectionState == "Disconnected" && BluetoothConnectionManager.aacpSocket?.isConnected != true) { // should never happen unless android messes up and sends us a stale broadcast Log.d(TAG, "Seems no device has taken over, we will.") val bluetoothManager = getSystemService(BluetoothManager::class.java) val bluetoothAdapter = bluetoothManager.adapter @@ -258,7 +258,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList connectToSocket(bluetoothAdapter, bluetoothDevice) } Log.d(TAG, "Device status changed") - if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) return + if (BluetoothConnectionManager.aacpSocket?.isConnected == true) return val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0 val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0 val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0 @@ -291,7 +291,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList getSharedPreferences("settings", MODE_PRIVATE).getString("name", "AirPods Pro") ?: "AirPods" ) - if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) return + if (BluetoothConnectionManager.aacpSocket?.isConnected == true) return val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0 val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0 val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0 @@ -325,7 +325,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } override fun onBatteryChanged(device: BLEManager.AirPodsStatus) { - if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) return + if (BluetoothConnectionManager.aacpSocket?.isConnected == true) return val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0 val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0 val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0 @@ -697,8 +697,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList popupShown = false updateNotificationContent(false) aacpManager.disconnected() - attManager.disconnected() - BluetoothConnectionManager.setCurrentConnection(null, null) + BluetoothConnectionManager.aacpSocket = null + BluetoothConnectionManager.attSocket = null } } } @@ -1747,7 +1747,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList val socketFailureChannel = NotificationChannel( "socket_connection_failure", - "AirPods BluetoothConnectionManager.getAACPSocket()? Connection Issues", + "AirPods BluetoothConnectionManager.aacpSocket? Connection Issues", NotificationManager.IMPORTANCE_HIGH ).apply { description = "Notifications about problems connecting to AirPods protocol" @@ -1793,7 +1793,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList if (BuildConfig.FLAVOR != "xposed") { Log.w( TAG, - "Not showing BluetoothConnectionManager.getAACPSocket()? error notification to user, the service shouldn't be running if it isn't supported." + "Not showing BluetoothConnectionManager.aacpSocket? error notification to user, the service shouldn't be running if it isn't supported." ) return } @@ -2048,10 +2048,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - if (BluetoothConnectionManager.getAACPSocket() == null) { + if (BluetoothConnectionManager.aacpSocket == null) { return } - if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) { + if (BluetoothConnectionManager.aacpSocket?.isConnected == true) { val updatedNotificationBuilder = NotificationCompat.Builder(this, "airpods_connection_status") .setSmallIcon(R.drawable.airpods) @@ -2099,8 +2099,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList notificationManager.cancel(1) } else if (!connected) { notificationManager.cancel(2) - } else if (!config.bleOnlyMode && BluetoothConnectionManager.getAACPSocket()?.isConnected != true) { - showSocketConnectionFailureNotification("BluetoothConnectionManager.getAACPSocket()? created, but not connected. Check logs") + } else if (!config.bleOnlyMode && BluetoothConnectionManager.aacpSocket?.isConnected != true) { + showSocketConnectionFailureNotification("BluetoothConnectionManager.aacpSocket? created, but not connected. Check logs") } } @@ -2379,7 +2379,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } else { Log.w( TAG, - "AirPods instance is not of type AirPodsInstance, skipping metadata setting" + "AirPods demoInstance is not of type AirPodsInstance, skipping metadata setting" ) } } @@ -2475,7 +2475,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList Log.d( TAG, "owns connection: $ownsConnection" ) - if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) { + if (BluetoothConnectionManager.aacpSocket?.isConnected == true) { if (!XposedRemotePrefProvider.create().getBoolean("vendor_id_hook", false) || ownsConnection == 0) { Log.d(TAG, "not taking over, vendorid is probably not set to apple") return @@ -2633,58 +2633,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList // CrossDevice.isAvailable = false } - private fun createBluetoothSocket( - adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid, psm: Int - ): BluetoothSocket { - val type = 3 // L2CAP - val constructorSpecs = listOf( - arrayOf(adapter, device, type, true, true, psm, uuid), // A16QPR3 - arrayOf(device, type, true, true, psm, uuid), - arrayOf(device, type, 1, true, true, psm, uuid), - arrayOf(type, 1, true, true, device, psm, uuid), - arrayOf(type, true, true, device, psm, uuid) - ) - - val constructors = BluetoothSocket::class.java.declaredConstructors - Log.d(TAG, "BluetoothSocket has ${constructors.size} constructors:") - - constructors.forEachIndexed { index, constructor -> - val params = constructor.parameterTypes.joinToString(", ") { it.simpleName } - Log.d(TAG, "Constructor $index: ($params)") - } - - var lastException: Exception? = null - var attemptedConstructors = 0 - - for ((index, params) in constructorSpecs.withIndex()) { - try { - Log.d(TAG, "Trying constructor signature #${index + 1}") - attemptedConstructors++ - - val paramTypes = - params.map { it::class.javaPrimitiveType ?: it::class.java }.toTypedArray() - val constructor = BluetoothSocket::class.java.getDeclaredConstructor(*paramTypes) - constructor.isAccessible = true - return constructor.newInstance(*params) as BluetoothSocket - - } catch (e: Exception) { - Log.e(TAG, "Constructor signature #${index + 1} failed: ${e.message}") - lastException = e - } - } - - val errorMessage = - "Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures" - Log.e(TAG, errorMessage) - showSocketConnectionFailureNotification(errorMessage) - throw lastException ?: IllegalStateException(errorMessage) - } - @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag") fun connectToSocket( adapter: BluetoothAdapter, device: BluetoothDevice, manual: Boolean = false ) { - if (BluetoothConnectionManager.getAACPSocket() != null && BluetoothConnectionManager.getAACPSocket()?.isConnected == true) return + if (BluetoothConnectionManager.aacpSocket != null && BluetoothConnectionManager.aacpSocket?.isConnected == true) return Log.d(TAG, " Connecting to socket") val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") // if (!isConnectedLocally) { @@ -2701,7 +2654,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList withTimeout(5000.milliseconds) { try { socket.connect() -// isConnectedLocally = true this@AirPodsService.device = device val xposedRemotePref = XposedRemotePrefProvider.create() val attSocket = if (xposedRemotePref.getBoolean("vendor_id_hook", false)) { @@ -2713,17 +2665,17 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList ) } else null attSocket?.connect() - BluetoothConnectionManager.setCurrentConnection(socket, attSocket) + if (attSocket != null) { attManager.startReader() attManager.readCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) attManager.readCharacteristic(ATTHandles.TRANSPARENCY) attManager.readCharacteristic(ATTHandles.HEARING_AID) - attManager.enableNotification(ATTCCCDHandles.HEARING_AID) -// attManager.enableNotification(ATTCCCDHandles.LOUD_SOUND_REDUCTION) - attManager.enableNotification(ATTCCCDHandles.TRANSPARENCY) } + BluetoothConnectionManager.aacpSocket = socket + BluetoothConnectionManager.attSocket = attSocket + // Create AirPodsInstance from stored config if available if (airpodsInstance == null && config.airpodsModelNumber.isNotEmpty()) { val model = @@ -2787,7 +2739,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList return } this@AirPodsService.device = device - BluetoothConnectionManager.getAACPSocket()?.let { + BluetoothConnectionManager.aacpSocket?.let { aacpManager.sendPacket(aacpManager.createHandshakePacket()) aacpManager.sendSetFeatureFlagsPacket() aacpManager.sendNotificationRequest() @@ -2878,19 +2830,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } } catch (e: Exception) { e.printStackTrace() - Log.d(TAG, "Failed to connect to BluetoothConnectionManager.getAACPSocket()?: ${e.message}") + Log.d(TAG, "Failed to connect to BluetoothConnectionManager.aacpSocket?: ${e.message}") showSocketConnectionFailureNotification("Failed to establish connection: ${e.localizedMessage}") // isConnectedLocally = false this@AirPodsService.device = device updateNotificationContent(false) } // } else { -// Log.d(TAG, "Already connected locally, skipping BluetoothConnectionManager.getAACPSocket()? connection (isConnectedLocally = $isConnectedLocally, BluetoothConnectionManager.getAACPSocket()?.isConnected = ${this::BluetoothConnectionManager.getAACPSocket()?.isInitialized && BluetoothConnectionManager.getAACPSocket()?.isConnected})") +// Log.d(TAG, "Already connected locally, skipping BluetoothConnectionManager.aacpSocket? connection (isConnectedLocally = $isConnectedLocally, BluetoothConnectionManager.aacpSocket?.isConnected = ${this::BluetoothConnectionManager.aacpSocket?.isInitialized && BluetoothConnectionManager.aacpSocket?.isConnected})") // } } fun disconnectForCD() { - BluetoothConnectionManager.getAACPSocket()?.close() + BluetoothConnectionManager.aacpSocket?.close() MediaController.pausedWhileTakingOver = false Log.d(TAG, "Disconnected from AirPods, showing island.") showIsland( @@ -2921,16 +2873,18 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList } fun disconnectAirPods() { - if (BluetoothConnectionManager.getAACPSocket() == null) return + if (BluetoothConnectionManager.aacpSocket == null) return try { - BluetoothConnectionManager.getAACPSocket()?.close() + BluetoothConnectionManager.aacpSocket?.close() } catch(e: Exception) { Log.e(TAG, "error closing aacp socket ${e.message}") } // isConnectedLocally = false aacpManager.disconnected() - attManager.disconnected() - BluetoothConnectionManager.setCurrentConnection(null, null) + + BluetoothConnectionManager.aacpSocket = null + BluetoothConnectionManager.attSocket = null + updateNotificationContent(false) sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { setPackage(packageName) @@ -3222,6 +3176,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList aacpManager.sendStopHeadTracking() } isHeadTrackingActive = false + gestureDetector?.stopDetection() } @SuppressLint("MissingPermission") diff --git a/android/app/src/main/java/me/kavishdevar/librepods/utils/RootlessSupport.kt b/android/app/src/main/java/me/kavishdevar/librepods/utils/RootlessSupport.kt index e30464fb..57741a81 100644 --- a/android/app/src/main/java/me/kavishdevar/librepods/utils/RootlessSupport.kt +++ b/android/app/src/main/java/me/kavishdevar/librepods/utils/RootlessSupport.kt @@ -20,13 +20,17 @@ package me.kavishdevar.librepods.utils import android.content.SharedPreferences import android.os.Build +import androidx.core.content.edit fun isSupported(sharedPreferences: SharedPreferences): Boolean { - if (Build.VERSION.SDK_INT == 37) return true + if (Build.VERSION.SDK_INT >= 37) return true + val isBypassFlagActive = sharedPreferences.getBoolean("bypass_device_check.v2", false) if (isBypassFlagActive) return true + val isPixel = Build.MANUFACTURER.lowercase() == "google" val isOppoFamily = Build.MANUFACTURER.lowercase() in listOf("oneplus", "oppo", "realme") + if (isPixel && Build.VERSION.SDK_INT == 36) { return Build.ID.startsWith("CP1A") } else if (isOppoFamily) { @@ -34,3 +38,7 @@ fun isSupported(sharedPreferences: SharedPreferences): Boolean { } return false } + +fun bypassDeviceCheck(sharedPreferences: SharedPreferences) { + sharedPreferences.edit{ putBoolean("bypass_device_check.v2", true) } +} diff --git a/android/app/src/main/res/values/arrays.xml b/android/app/src/main/res/values/arrays.xml new file mode 100644 index 00000000..dc69d9b4 --- /dev/null +++ b/android/app/src/main/res/values/arrays.xml @@ -0,0 +1,17 @@ + + + + @array/com_google_android_gms_fonts_certs_dev + @array/com_google_android_gms_fonts_certs_prod + + + + MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpFeVyxW0qMBujb8X8ETrWy550NaFtI6t9+u7hZeTfHwqNvacKhp1RbE6dBRGWynwMVX8XW8N1+UjFaq6GCJukT4qmpN2afb8sCjUigq0GuMwYXrFVee74bQgLHWGJwPmvmLHC69EH6kWr22ijx4OKXlSIx2xT1AsSHee70w5iDBiK4aph27yH3TxkXy9V89TDdexAcKk/cVHYNnDBapcavl7y0RiQ4biu8ymM8Ga/nmzhRKya6G0cGw8CAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs= + + + + + MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAQOjgdkwgdYwHQYDVR0OBBYEFMd9jMIhF1Ylmn/Tgt9r45jk14alMIGmBgNVHSMEgZ4wgZuAFMd9jMIhF1Ylmn/Tgt9r45jk14aloXikdjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEUMBIGA1UEChMLR29vZ2xlIEluYy4xEDAOBgNVBAsTB0FuZHJvaWQxEDAOBgNVBAMTB0FuZHJvaWSCCQDC4IdGZEowjTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBAUAA4IBAQBt0lLO74UwLDYKqs6Tm8/yzKkEu116FmH4rkaymUIE0P9KaMftGlMexFlaYjzmB2OxZyl6euNXEsQH8gjwyxCUKRJNexBiGcCEyj6z+a1fuHHvkiaai+KL8W1EyNmgjmyy8AW7P+LLlkR+ho5zEHatRbM/YAnqGcFh5iZBqpknHf1SKMXFh4dd239FJ1jWYfbMDMy3NS5CTMQ2XFI1MvcyUTdZPErjQfTbQe3aDQsQcafEQPD+nqActifKZ0Np0IS9L9kR/wbNvyz6ENwPiTrjV2KRkEjH78ZMcUQXg0L3BYHJ3lc69Vs5Ddf9uUGGMYldX3WfMBEmh/9iFBDAaTCK + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index f866a274..3356edc4 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -275,8 +275,19 @@ Describe your issue Optimized Charge Limit AirPods can learn from your daily usage and determine when to charge to an optmized limit and when to allow or full charge. This limit adapts to your daily usage and preserves your battery lifespan over time.\nThis setting may not affect unsupported AirPods, or AirPods on an older firmware version. - Enable LibrePods in Xposed or update your device to proceed. + Update your device or enable LibrePods in Xposed and reopen the app to proceed. Due to an error in billing, premium access will expire in %1$d days. If you already upgraded the app, please click on this message to email billing@kavish.xyz to restore or verify access. Apologies for the inconvenience. Custom Recommended + Appearance + Use Material 3 Expressive + Material 3 Expressive + LibrePods now supports a whole new look based on Material 3 Expressive including updated typography and adaptive color schemes.\nYou can switch back to the Apple look from the app\'s settings. + Available only on the latest AirPods Beta firmware. An Apple device running OS version 27 is required to install the beta firmware. + What\'s New + Reconnecting + Tap to reconnect + Permissions + Privacy Policy + I agree diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index b81f1bd7..9b651841 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -20,6 +20,12 @@ hilt = "2.59.2" xposed = "101.0.0" lifecycleProcess = "2.10.0" play = "2.0.2" +nav3Core = "1.1.2" +lifecycleViewmodelNav3 = "2.11.0-rc01" +navevent = "1.1.1" +m3 = "1.5.0-alpha21" +foundationLayout = "1.11.2" +uiTextGoogleFonts = "1.11.2" [libraries] accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } @@ -30,7 +36,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 = "m3" } 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" } @@ -52,6 +58,12 @@ libxposed-service = { group = "io.github.libxposed", name = "service", version.r androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleProcess" } play-review = { group = "com.google.android.play", name="review", version.ref = "play" } play-review-ktx = { group = "com.google.android.play", name="review-ktx", version.ref = "play" } +androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" } +androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" } +androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" } +androidx-navigationevent = { module = "androidx.navigationevent:navigationevent", version.ref = "navevent"} +androidx-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" } +androidx-compose-ui-text-google-fonts = { group = "androidx.compose.ui", name = "ui-text-google-fonts", version.ref = "uiTextGoogleFonts" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }