android: add material 3 expressive theme

All of the new UI was written by hand!
also fixed upgraded being set to false in FOSS builds when opening App settings. should've been a different commit, but ¯\_(ツ)_/¯
This commit is contained in:
Kavish Devar
2026-06-20 19:58:36 +05:30
parent 633d036dd7
commit 790e396345
74 changed files with 10568 additions and 9830 deletions
+15 -2
View File
@@ -1,6 +1,6 @@
import java.util.Properties import java.util.Properties
val appVersionName = "0.3.0" val appVersionName = "1.0.0-rc1"
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
@@ -24,6 +24,14 @@ val releaseSigningAvailable = listOf(
"RELEASE_KEY_PASSWORD" "RELEASE_KEY_PASSWORD"
).all { props[it]?.toString()?.isNotBlank() == true } ).all { props[it]?.toString()?.isNotBlank() == true }
kotlin {
compilerOptions {
optIn.add(
"androidx.compose.material3.ExperimentalMaterial3ExpressiveApi"
)
}
}
android { android {
signingConfigs { signingConfigs {
if (releaseSigningAvailable) { if (releaseSigningAvailable) {
@@ -41,7 +49,7 @@ android {
defaultConfig { defaultConfig {
applicationId = "me.kavishdevar.librepods" applicationId = "me.kavishdevar.librepods"
targetSdk = 37 targetSdk = 37
versionCode = 56 versionCode = 61
versionName = appVersionName versionName = appVersionName
} }
buildTypes { buildTypes {
@@ -117,6 +125,7 @@ android {
dependencies { dependencies {
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
implementation(libs.accompanist.permissions) implementation(libs.accompanist.permissions)
implementation(libs.androidx.compose.ui.text.google.fonts)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
@@ -145,6 +154,10 @@ dependencies {
implementation(libs.libxposed.service) implementation(libs.libxposed.service)
implementation(libs.play.review) implementation(libs.play.review)
implementation(libs.play.review.ktx) 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 { aboutLibraries {
@@ -31,125 +31,32 @@ import android.content.Context
import android.content.Context.MODE_PRIVATE import android.content.Context.MODE_PRIVATE
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.os.Build import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.provider.Settings
import android.util.Log import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge 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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.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.content.edit
import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel 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.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.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.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.rememberHazeState
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
import me.kavishdevar.librepods.data.AirPodsNotifications import me.kavishdevar.librepods.data.AirPodsNotifications
import me.kavishdevar.librepods.data.ControlCommandRepository import me.kavishdevar.librepods.data.ControlCommandRepository
import me.kavishdevar.librepods.presentation.components.AppInfoCard import me.kavishdevar.librepods.presentation.navigation.NavigationRoot
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.theme.LibrePodsTheme import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel 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.services.AirPodsService
import me.kavishdevar.librepods.utils.XposedState import me.kavishdevar.librepods.utils.XposedState
import me.kavishdevar.librepods.utils.isSupported
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
lateinit var serviceConnection: ServiceConnection lateinit var serviceConnection: ServiceConnection
@@ -173,7 +80,29 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
setContent { 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() Main()
} }
} }
@@ -220,323 +149,64 @@ class MainActivity : ComponentActivity() {
fun Main() { fun Main() {
val context = LocalContext.current val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE) 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<AirPodsService?>(null) } val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
val airPodsViewModel = remember(airPodsService.value) { val airPodsViewModel: AirPodsViewModel = viewModel()
airPodsService.value?.let { service ->
AirPodsViewModel(
service = service,
sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE),
controlRepo = ControlCommandRepository(service.aacpManager),
appContext = context.applicationContext
)
}
}
LaunchedEffect(Unit) { 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() val releaseNotesShownPrefKey = "release_notes_shown_${BuildConfig.VERSION_NAME.removeSuffix("-debug")}"
val releaseNotesShown = sharedPreferences.getBoolean(releaseNotesShownPrefKey, false)
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
)
}
}
fun bindService() {
context.startForegroundService(Intent(context, AirPodsService::class.java)) 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 { if (!sharedPreferences.contains("first_connection_successful_time")) {
object : ServiceConnection { sharedPreferences.edit {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) { putLong("first_connection_successful_time", System.currentTimeMillis())
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())
}
} }
} }
}
override fun onServiceDisconnected(name: ComponentName?) { override fun onServiceDisconnected(name: ComponentName?) {
airPodsService.value = null airPodsService.value = null
}
} }
} }
@@ -545,16 +215,22 @@ fun Main() {
serviceConnection, serviceConnection,
Context.BIND_AUTO_CREATE 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) { 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
)
)
}
}
}
}
@@ -1159,7 +1159,7 @@ class AACPManager {
) )
} }
val socket = BluetoothConnectionManager.getAACPSocket() ?: return false val socket = BluetoothConnectionManager.aacpSocket ?: return false
if (socket.isConnected) { if (socket.isConnected) {
socket.outputStream?.write(packet) socket.outputStream?.write(packet)
@@ -34,7 +34,7 @@ enum class ATTHandles(val value: Int) {
enum class ATTCCCDHandles(val value: Int) { enum class ATTCCCDHandles(val value: Int) {
TRANSPARENCY(ATTHandles.TRANSPARENCY.value + 1), 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) HEARING_AID(ATTHandles.HEARING_AID.value + 1)
} }
@@ -86,7 +86,7 @@ class ATTManagerv2 {
} }
fun readCharacteristic(handle: ATTHandles, timeoutMillis: Long = 2000): ByteArray? { fun readCharacteristic(handle: ATTHandles, timeoutMillis: Long = 2000): ByteArray? {
val socket = BluetoothConnectionManager.getATTSocket() ?: return null val socket = BluetoothConnectionManager.attSocket ?: return null
try { try {
val output = socket.outputStream val output = socket.outputStream
val pdu = byteArrayOf(0x0A, handle.value.toByte(), 0x00) val pdu = byteArrayOf(0x0A, handle.value.toByte(), 0x00)
@@ -117,7 +117,7 @@ class ATTManagerv2 {
} }
fun writeCharacteristic(handle: Byte, data: ByteArray, timeoutMillis: Long = 2000) { fun writeCharacteristic(handle: Byte, data: ByteArray, timeoutMillis: Long = 2000) {
val socket = BluetoothConnectionManager.getATTSocket() ?: return val socket = BluetoothConnectionManager.attSocket ?: return
try { try {
val output = socket.outputStream val output = socket.outputStream
val pdu = byteArrayOf(0x12, handle, 0x00) + data // 0x00 for LE val pdu = byteArrayOf(0x12, handle, 0x00) + data // 0x00 for LE
@@ -141,7 +141,7 @@ class ATTManagerv2 {
fun disconnected() { fun disconnected() {
characteristicList.clear() characteristicList.clear()
stopReader() stopReader()
val socket = BluetoothConnectionManager.getATTSocket() ?: return val socket = BluetoothConnectionManager.attSocket?: return
try { try {
socket.close() socket.close()
} catch (e: Exception) { } catch (e: Exception) {
@@ -151,7 +151,7 @@ class ATTManagerv2 {
} }
private fun runReaderLoop() { private fun runReaderLoop() {
val socket = BluetoothConnectionManager.getATTSocket() ?: run { val socket = BluetoothConnectionManager.attSocket ?: run {
Log.w(TAG, "ATT socket not available. stopping reader") Log.w(TAG, "ATT socket not available. stopping reader")
readerRunning.set(false) readerRunning.set(false)
return return
@@ -18,22 +18,59 @@
package me.kavishdevar.librepods.bluetooth package me.kavishdevar.librepods.bluetooth
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket import android.bluetooth.BluetoothSocket
import android.os.ParcelUuid
import android.util.Log
object BluetoothConnectionManager { object BluetoothConnectionManager {
private var aacpSocket: BluetoothSocket? = null var aacpSocket: BluetoothSocket? = null
private var attSocket: BluetoothSocket? = null var attSocket: BluetoothSocket? = null
}
fun setCurrentConnection(aacpSocket: BluetoothSocket?, attSocket: BluetoothSocket?) {
BluetoothConnectionManager.aacpSocket = aacpSocket fun createBluetoothSocket(
BluetoothConnectionManager.attSocket = attSocket adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid, psm: Int
} ): BluetoothSocket {
val type = 3 // L2CAP
fun getAACPSocket(): BluetoothSocket? { val constructorSpecs = listOf(
return aacpSocket arrayOf(adapter, device, type, true, true, psm, uuid), // A16QPR3
} arrayOf(device, type, true, true, psm, uuid),
arrayOf(device, type, 1, true, true, psm, uuid),
fun getATTSocket(): BluetoothSocket? { arrayOf(type, 1, true, true, device, psm, uuid),
return attSocket arrayOf(type, true, true, device, psm, uuid)
} )
val constructors = BluetoothSocket::class.java.declaredConstructors
Log.d("createSocket<psm>", "BluetoothSocket has ${constructors.size} constructors:")
constructors.forEachIndexed { index, constructor ->
val params = constructor.parameterTypes.joinToString(", ") { it.simpleName }
Log.d("createSocket<psm>", "Constructor $index: ($params)")
}
var lastException: Exception? = null
var attemptedConstructors = 0
for ((index, params) in constructorSpecs.withIndex()) {
try {
Log.d("createSocket<psm>", "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<psm>", "Constructor signature #${index + 1} failed: ${e.message}")
lastException = e
}
}
val errorMessage =
"Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures"
Log.e("createSocket<psm>", errorMessage)
throw lastException ?: IllegalStateException(errorMessage)
} }
@@ -29,6 +29,7 @@ import me.kavishdevar.librepods.bluetooth.ATTHandles
import java.io.IOException import java.io.IOException
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import kotlin.time.Duration.Companion.milliseconds
private const val TAG = "HearingAidUtils" private const val TAG = "HearingAidUtils"
@@ -144,7 +145,7 @@ fun sendHearingAidSettings(
) { ) {
debounceJob.value?.cancel() debounceJob.value?.cancel()
debounceJob.value = CoroutineScope(Dispatchers.IO).launch { debounceJob.value = CoroutineScope(Dispatchers.IO).launch {
delay(100) delay(100.milliseconds)
try { try {
Log.d(TAG, "Current data before update: ${currentData.joinToString(" ") { String.format("%02X", it) }}") Log.d(TAG, "Current data before update: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
if (currentData.size < 104) { if (currentData.size < 104) {
@@ -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
)
@@ -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
@@ -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
}
@@ -20,187 +20,60 @@
package me.kavishdevar.librepods.presentation.components 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.Composable
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.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.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@Composable @Composable
fun AboutCard( fun AboutCard(
navController: NavController,
modelName: String, modelName: String,
actualModel: String, actualModel: String,
serialNumbers: List<String>, serialNumbers: List<String>,
version: String? version: String?,
navigateToVersion: () -> Unit
) { ) {
val isDarkTheme = isSystemInDarkTheme() val serialNumbers = when (LocalDesignSystem.current) {
val textColor = if (isDarkTheme) Color.White else Color.Black DesignSystem.Apple -> listOf(
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(
serialNumbers[0], serialNumbers[0],
"􀀛 ${serialNumbers[1]}", "􀀛 ${serialNumbers[1]}",
"􀀧 ${serialNumbers[2]}" "􀀧 ${serialNumbers[2]}"
) )
val serialNumber = remember { mutableIntStateOf(0) }
Row( DesignSystem.Material -> listOf(
modifier = Modifier serialNumbers[0],
.fillMaxWidth() stringResource(R.string.left) + " " + {serialNumbers[1]},
.padding(16.dp), stringResource(R.string.right) + " " + {serialNumbers[2]},
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)
) )
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), name = stringResource(R.string.version),
currentState = version, description = version,
independent = false, onClick = navigateToVersion,
height = rowHeight.value + 32.dp
) )
} }
} }
@@ -18,176 +18,35 @@
package me.kavishdevar.librepods.presentation.components 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.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.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.BuildConfig
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
@Composable @Composable
fun AppInfoCard() { fun AppInfoCard(
val rowHeight = remember { mutableStateOf(0.dp) } navigateToReleaseNotesScreen: (() -> Unit)? = null,
val density = LocalDensity.current ) {
val isDarkTheme = isSystemInDarkTheme() StyledList(title = stringResource(R.string.about)) {
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) StyledListItem(
val textColor = if (isDarkTheme) Color.White else Color.Black name = stringResource(R.string.version),
description = BuildConfig.VERSION_NAME,
onClick = navigateToReleaseNotesScreen
)
Column { StyledListItem(
Box( name = stringResource(R.string.version_code),
modifier = Modifier description = BuildConfig.VERSION_CODE.toString(),
.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))
)
)
}
Column( StyledListItem(
modifier = Modifier name = stringResource(R.string.flavor),
.clip(RoundedCornerShape(28.dp)) description = BuildConfig.FLAVOR,
.fillMaxWidth() )
.background(backgroundColor, RoundedCornerShape(28.dp))
) { StyledListItem(
Row( name = stringResource(R.string.build_type),
modifier = Modifier description = BuildConfig.BUILD_TYPE,
.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))
)
)
}
}
} }
} }
@@ -20,35 +20,13 @@
package me.kavishdevar.librepods.presentation.components 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.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.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 me.kavishdevar.librepods.R
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@Composable @Composable
fun AudioSettings( fun AudioSettings(
navController: NavController,
adaptiveVolumeCapability: Boolean, adaptiveVolumeCapability: Boolean,
conversationalAwarenessCapability: Boolean, conversationalAwarenessCapability: Boolean,
loudSoundReductionCapability: Boolean, loudSoundReductionCapability: Boolean,
@@ -64,135 +42,57 @@ fun AudioSettings(
loudSoundReductionChecked: Boolean, loudSoundReductionChecked: Boolean,
onLoudSoundReductionCheckedChange: (Boolean) -> Unit, onLoudSoundReductionCheckedChange: (Boolean) -> Unit,
navigateToAdaptiveStrength: () -> Unit,
navigateToEqualizer: () -> Unit,
vendorIdHook: Boolean, vendorIdHook: Boolean,
isPremium: Boolean isPremium: Boolean
) { ) {
val isDarkTheme = isSystemInDarkTheme() if (adaptiveVolumeCapability || conversationalAwarenessCapability || loudSoundReductionCapability || adaptiveAudioCapability) {
val textColor = if (isDarkTheme) Color.White else Color.Black 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) { if (conversationalAwarenessCapability) {
return StyledToggle(
} label = stringResource(R.string.conversational_awareness),
Box( description = stringResource(R.string.conversational_awareness_description),
modifier = Modifier checked = conversationalAwarenessChecked,
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) onCheckedChange = onConversationalAwarenessCheckedChange,
.padding(horizontal = 16.dp, vertical = 4.dp) enabled = isPremium,
){ )
Text( }
text = stringResource(R.string.audio),
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
)
)
}
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) 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( if (adaptiveAudioCapability) {
modifier = Modifier StyledListItem(
.clip(RoundedCornerShape(28.dp)) name = stringResource(R.string.adaptive_audio),
.fillMaxWidth() onClick = navigateToAdaptiveStrength,
.background(backgroundColor, RoundedCornerShape(28.dp)) )
.padding(top = 2.dp) }
) {
if (adaptiveVolumeCapability) { if (customEqCapability) {
StyledToggle( StyledListItem(
label = stringResource(R.string.personalized_volume), name = stringResource(R.string.equalizer),
description = stringResource(R.string.personalized_volume_description), onClick = navigateToEqualizer,
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
)
} }
} }
} }
@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
)
}
@@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -53,6 +54,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.BatteryStatus import me.kavishdevar.librepods.data.BatteryStatus
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.min import kotlin.math.min
import kotlin.math.sin import kotlin.math.sin
@@ -66,7 +68,6 @@ fun BatteryIndicator(
previousCharging: Boolean = false, previousCharging: Boolean = false,
) { ) {
val isDarkTheme = isSystemInDarkTheme() val isDarkTheme = isSystemInDarkTheme()
val backgroundColor = if (isDarkTheme) Color.Black else Color(0xFFF2F2F7)
val batteryTextColor = if (isDarkTheme) Color.White else Color.Black val batteryTextColor = if (isDarkTheme) Color.White else Color.Black
val batteryFillColor = val batteryFillColor =
if (batteryPercentage > 25) if (isDarkTheme) Color(0xFF2ED158) else Color(0xFF35C759) if (batteryPercentage > 25) if (isDarkTheme) Color(0xFF2ED158) else Color(0xFF35C759)
@@ -82,7 +83,7 @@ fun BatteryIndicator(
} }
Column( 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 horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Box( Box(
@@ -200,10 +201,7 @@ fun BatteryIndicator(
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable @Composable
fun BatteryIndicatorPreview() { fun BatteryIndicatorPreview() {
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7) LibrePodsTheme(m3eEnabled = false) {
Box(
modifier = Modifier.background(bg)
) {
BatteryIndicator( BatteryIndicator(
batteryPercentage = 50, batteryPercentage = 50,
status = BatteryStatus.OPTIMIZED_CHARGING, status = BatteryStatus.OPTIMIZED_CHARGING,
@@ -64,9 +64,6 @@ fun BatteryView(
val rightLevel = right?.level ?: 0 val rightLevel = right?.level ?: 0
val caseLevel = case?.level ?: 0 val caseLevel = case?.level ?: 0
val caseCharging = case?.status == BatteryStatus.CHARGING ||
case?.status == BatteryStatus.OPTIMIZED_CHARGING
val singleDisplayed = remember { mutableStateOf(false) } val singleDisplayed = remember { mutableStateOf(false) }
Box( Box(
@@ -20,413 +20,70 @@
package me.kavishdevar.librepods.presentation.components 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.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue 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.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 dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@Composable @Composable
fun CallControlSettings( fun CallControlSettings(
hazeState: HazeState,
flipped: Boolean, flipped: Boolean,
onCallControlValueChanged: (Boolean) -> Unit navigateToCallControlScreen: (action: String) -> Unit,
) { ) {
val isDarkTheme = isSystemInDarkTheme() val pressOnceText = stringResource(R.string.press_once)
val textColor = if (isDarkTheme) Color.White else Color.Black val pressTwiceText = stringResource(R.string.press_twice)
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Box( var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) }
modifier = Modifier var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) }
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
.padding(horizontal = 16.dp, vertical = 4.dp) val muteUnmuteText = stringResource(R.string.mute_unmute)
){ val hangUpText = stringResource(R.string.hang_up)
Text(
text = stringResource(R.string.call_controls), StyledList(title = stringResource(R.string.call_controls)) {
style = TextStyle( StyledListItem(
fontSize = 14.sp, name = stringResource(R.string.answer_call),
fontWeight = FontWeight.Bold, description = stringResource(R.string.press_once),
color = textColor.copy(alpha = 0.6f), enabled = false
fontFamily = FontFamily(Font(R.font.sf_pro))
)
) )
}
Column( StyledListItem(
modifier = Modifier name = muteUnmuteText,
.fillMaxWidth() description = singlePressAction,
.background(backgroundColor, RoundedCornerShape(28.dp)) onClick = { navigateToCallControlScreen(muteUnmuteText) } ,
.padding(top = 2.dp) )
) {
val scope = rememberCoroutineScope() StyledListItem(
val haptics = LocalHapticFeedback.current name = hangUpText,
description = doublePressAction,
onClick = { navigateToCallControlScreen(hangUpText) }
)
val pressOnceText = stringResource(R.string.press_once) // StyledListItem(
val pressTwiceText = stringResource(R.string.press_twice) // name = pressOnceText,
// selected = doublePressAction == pressOnceText,
var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) } // onClick = {
var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) } // doublePressAction = pressOnceText
// singlePressAction = pressTwiceText
var showSinglePressDropdown by remember { mutableStateOf(false) } //
var touchOffsetSingle by remember { mutableStateOf<Offset?>(null) } // onCallControlValueChanged(true)
var boxPositionSingle by remember { mutableStateOf(Offset.Zero) } // }
var lastDismissTimeSingle by remember { mutableLongStateOf(0L) } // )
var parentHoveredIndexSingle by remember { mutableStateOf<Int?>(null) } //
var parentDragActiveSingle by remember { mutableStateOf(false) } // StyledListItem(
var previousIdxSingle by remember { mutableStateOf<Int?>(null) } // name = pressTwiceText,
// selected = doublePressAction == pressTwiceText,
var showDoublePressDropdown by remember { mutableStateOf(false) } // onClick = {
var touchOffsetDouble by remember { mutableStateOf<Offset?>(null) } // doublePressAction = pressTwiceText
var boxPositionDouble by remember { mutableStateOf(Offset.Zero) } // singlePressAction = pressOnceText
var lastDismissTimeDouble by remember { mutableLongStateOf(0L) } //
var parentHoveredIndexDouble by remember { mutableStateOf<Int?>(null) } // onCallControlValueChanged(false)
var parentDragActiveDouble by remember { mutableStateOf(false) } // }
var previousIdxDouble by remember { mutableStateOf<Int?>(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
)
}
}
}
} }
} }
@@ -37,7 +37,11 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredWidthIn import androidx.compose.foundation.layout.requiredWidthIn
import androidx.compose.foundation.shape.RoundedCornerShape 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.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.DialogProperties
import com.kyant.backdrop.backdrops.LayerBackdrop import com.kyant.backdrop.backdrops.LayerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.kyant.backdrop.drawBackdrop import com.kyant.backdrop.drawBackdrop
@@ -59,7 +64,10 @@ import com.kyant.backdrop.effects.lens
import com.kyant.backdrop.effects.vibrancy import com.kyant.backdrop.effects.vibrancy
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
@OptIn(ExperimentalMaterial3Api::class)
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@Composable @Composable
fun ConfirmationDialog( fun ConfirmationDialog(
@@ -72,105 +80,136 @@ fun ConfirmationDialog(
onDismiss: () -> Unit = { showDialog.value = false }, onDismiss: () -> Unit = { showDialog.value = false },
backdrop: LayerBackdrop, backdrop: LayerBackdrop,
) { ) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val accentColor = if (isDarkTheme) Color(0xFF0091FF) else Color(0xFF0088FF)
AnimatedVisibility( AnimatedVisibility(
visible = showDialog.value, visible = showDialog.value,
enter = scaleIn(initialScale = 1.05f) + fadeIn(), enter = scaleIn(initialScale = 1.05f) + fadeIn(),
exit = scaleOut(targetScale = 1.05f) + fadeOut() exit = scaleOut(targetScale = 1.05f) + fadeOut()
) { ) {
Box( when (LocalDesignSystem.current) {
modifier = Modifier.fillMaxSize(), DesignSystem.Material -> {
contentAlignment = Alignment.Center BasicAlertDialog(
) { onDismissRequest = onDismiss,
val innerBackdrop = rememberLayerBackdrop() properties = DialogProperties(
Box( dismissOnBackPress = true,
modifier = Modifier dismissOnClickOutside = false
.fillMaxSize() )
.background(Color.Black.copy(alpha = 0.4f)) ) {
.clickable(enabled = false, onClick = {}), Column(
contentAlignment = Alignment.Center modifier = Modifier.padding(16.dp),
) { verticalArrangement = Arrangement.spacedBy(16.dp)
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( Text(
title, text = title,
style = TextStyle( style = MaterialTheme.typography.titleMediumEmphasized
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = textColor,
fontFamily = FontFamily(Font(R.font.sf_pro))
),
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp)
) )
Spacer(modifier = Modifier.height(12.dp))
Text( Text(
message, text = message,
style = TextStyle( style = MaterialTheme.typography.bodyMedium
fontSize = 14.sp,
color = textColor.copy(alpha = 0.8f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 16.dp)
) )
Spacer(modifier = Modifier.height(16.dp)) Row(modifier = Modifier.align(Alignment.End)) {
Row( TextButton(
modifier = Modifier.fillMaxWidth(0.9f), onClick = onDismiss
horizontalArrangement = Arrangement.spacedBy(24.dp)
) {
StyledButton(
onClick = onDismiss,
backdrop = innerBackdrop,
modifier = Modifier.weight(1f),
) { ) {
Text( Text(
text = dismissText, style = TextStyle( text = dismissText,
fontFamily = FontFamily(Font(R.font.sf_pro)), style = MaterialTheme.typography.labelMedium
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = textColor
)
) )
} }
StyledButton( TextButton(
onClick = onConfirm, onClick = onConfirm
backdrop = innerBackdrop,
modifier = Modifier.weight(1f),
surfaceColor = accentColor
) { ) {
Text( Text(
text = confirmText, style = TextStyle( text = confirmText,
fontFamily = FontFamily(Font(R.font.sf_pro)), style = MaterialTheme.typography.labelMedium
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
color = Color.White
)
) )
} }
} }
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))
}
}
} }
} }
} }
@@ -20,18 +20,8 @@
package me.kavishdevar.librepods.presentation.components 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.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -42,32 +32,16 @@ fun ConnectionSettings(
automaticConnectionEnabled: Boolean, automaticConnectionEnabled: Boolean,
onAutomaticConnectionChanged: (Boolean) -> Unit, onAutomaticConnectionChanged: (Boolean) -> Unit,
) { ) {
val isDarkTheme = isSystemInDarkTheme() StyledList {
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
Column(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(top = 2.dp)
) {
StyledToggle( StyledToggle(
label = stringResource(R.string.ear_detection), label = stringResource(R.string.ear_detection),
independent = false,
checked = automaticEarDetectionEnabled, checked = automaticEarDetectionEnabled,
onCheckedChange = onAutomaticEarDetectionChanged onCheckedChange = onAutomaticEarDetectionChanged
) )
HorizontalDivider(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
StyledToggle( StyledToggle(
label = stringResource(R.string.automatically_connect), label = stringResource(R.string.automatically_connect),
description = stringResource(R.string.automatically_connect_description), description = stringResource(R.string.automatically_connect_description),
independent = false,
checked = automaticConnectionEnabled, checked = automaticConnectionEnabled,
onCheckedChange = onAutomaticConnectionChanged onCheckedChange = onAutomaticConnectionChanged
) )
@@ -1,235 +1,56 @@
package me.kavishdevar.librepods.presentation.components package me.kavishdevar.librepods.presentation.components
import android.os.Build 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.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.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.R
import me.kavishdevar.librepods.utils.XposedState import me.kavishdevar.librepods.utils.XposedState
@Composable @Composable
fun DeviceInfoCard() { 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 StyledListItem(
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) name = stringResource(R.string.model_number),
description = Build.MODEL,
enabled = false
)
val rowHeight = remember { mutableStateOf(0.dp) } StyledListItem(
val density = LocalDensity.current name = stringResource(R.string.build_id),
description = Build.DISPLAY,
enabled = false
)
Column ( StyledListItem(
verticalArrangement = Arrangement.spacedBy(8.dp) name = stringResource(R.string.version),
) { description = "${Build.ID} (${Build.VERSION.SDK_INT_FULL})",
Box( enabled = false
modifier = Modifier )
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
.padding(start = 16.dp, top = 24.dp, end = 4.dp) StyledListItem(
) { name = stringResource(R.string.xposed_available),
Text( description = if (XposedState.isAvailable) {
text = stringResource(R.string.device_info), style = TextStyle( stringResource(R.string.yes)
fontSize = 14.sp, } else {
fontWeight = FontWeight.Bold, stringResource(R.string.no)
color = textColor.copy(alpha = 0.6f), },
fontFamily = FontFamily(Font(R.font.sf_pro)) enabled = false
) )
)
} StyledListItem(
Column( name = stringResource(R.string.app_enabled_in_xposed),
modifier = Modifier description = if (XposedState.bluetoothScopeEnabled) {
.clip(RoundedCornerShape(28.dp)) stringResource(R.string.yes)
.fillMaxWidth() } else {
.background(backgroundColor, RoundedCornerShape(28.dp)) stringResource(R.string.no)
) { },
Row( enabled = false
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))
)
)
}
}
} }
} }
@@ -20,99 +20,43 @@
package me.kavishdevar.librepods.presentation.components 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.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.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.R
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@Composable @Composable
fun HearingHealthSettings( fun HearingHealthSettings(
navController: NavController,
hasPPECapability: Boolean, hasPPECapability: Boolean,
hasHearingAidCapability: 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 val shouldShowHearingAid = hasHearingAidCapability && vendorIdHook
if (hasPPECapability && shouldShowHearingAid) { if (hasPPECapability && shouldShowHearingAid) {
Box( StyledList(title = stringResource(R.string.hearing_health)) {
modifier = Modifier StyledListItem(
.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",
name = stringResource(R.string.hearing_protection), name = stringResource(R.string.hearing_protection),
navController = navController, onClick = navigateToHearingProtection
independent = false
) )
HorizontalDivider( StyledListItem(
thickness = 1.dp,
color = Color(0x40888888),
modifier = Modifier
.padding(horizontal = 12.dp)
)
NavigationButton(
to = "hearing_aid",
name = stringResource(R.string.hearing_aid), name = stringResource(R.string.hearing_aid),
navController = navController, onClick = navigateToHearingAid
independent = false
) )
} }
} else if (shouldShowHearingAid) { } else if (shouldShowHearingAid) {
NavigationButton( StyledListItem(
to = "hearing_aid",
name = stringResource(R.string.hearing_aid), name = stringResource(R.string.hearing_aid),
navController = navController onClick = navigateToHearingAid
) )
} else if (hasPPECapability) { } else if (hasPPECapability) {
NavigationButton( StyledListItem(
to = "hearing_protection",
name = stringResource(R.string.hearing_protection),
title = stringResource(R.string.hearing_health), title = stringResource(R.string.hearing_health),
navController = navController name = stringResource(R.string.hearing_protection),
onClick = navigateToHearingProtection
) )
} }
} }
@@ -1,263 +0,0 @@
/*
LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
@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<Offset?>(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<Int?>(null) }
var parentDragActive by remember { mutableStateOf(false) }
var previousIdx by remember { mutableStateOf<Int?>(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
)
}
}
}
}
@@ -1,179 +0,0 @@
/*
LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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))
}
@@ -30,6 +30,7 @@ import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column 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.height
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.ToggleButton
import androidx.compose.material3.ToggleButtonDefaults
import androidx.compose.material3.VerticalDivider import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -51,6 +58,7 @@ import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color 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.imageResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.NoiseControlMode 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.io.encoding.ExperimentalEncodingApi
import kotlin.math.roundToInt import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope") @SuppressLint("UnspecifiedRegisterReceiverFlag", "UnusedBoxWithConstraintsScope")
@Composable @Composable
fun NoiseControlSettings( fun NoiseControlSettings(
@@ -79,319 +90,433 @@ fun NoiseControlSettings(
noiseControlModeValue: Int, noiseControlModeValue: Int,
onNoiseControlModeChanged: (Int) -> Unit onNoiseControlModeChanged: (Int) -> Unit
) { ) {
val isDarkTheme = isSystemInDarkTheme() when (LocalDesignSystem.current) {
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8) DesignSystem.Material -> {
val textColor = if (isDarkTheme) Color.White else Color.Black val options = buildList {
val textColorSelected = if (isDarkTheme) Color.White else Color.Black if (showOffListeningMode) {
val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF) add(
Triple(
NoiseControlMode.OFF,
R.string.off,
val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) } R.drawable.noise_cancellation
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<Float> = 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
) )
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( Box(
modifier = Modifier modifier = Modifier
.width(buttonWidth) .background(Color.Transparent)
.fillMaxHeight() .padding(horizontal = 16.dp)
.offset { IntOffset(animatedOffset.roundToInt(), 0) } .padding(top = 4.dp, bottom = 12.dp)
.zIndex(0f) ) {
.draggable( Text(
orientation = Orientation.Horizontal, text = stringResource(R.string.noise_control),
state = rememberDraggableState { delta -> color = MaterialTheme.colorScheme.primary,
dragOffset = (dragOffset + delta).coerceIn( style = MaterialTheme.typography.labelSmallEmphasized
0f, )
with(density) { (buttonWidth * (buttonCount - 1)).toPx() } }
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<Float> = 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxWidth()
.padding(3.dp) .height(60.dp)
.background(selectedBackground, RoundedCornerShape(26.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( Box(
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 modifier = Modifier
.padding(vertical = 10.dp) .width(buttonWidth)
.alpha(d1a.floatValue), .fillMaxHeight()
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) .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)
) { @Preview
if (showOffListeningMode) { @Composable
Text( fun NoiseControlSettingsPreview() {
text = stringResource(R.string.off), LibrePodsTheme(
style = TextStyle(fontSize = 12.sp, color = textColor), m3eEnabled = true
textAlign = TextAlign.Center, ) {
modifier = Modifier.weight(1f) Box(
) modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
} ) {
Text( NoiseControlSettings(
text = stringResource(R.string.transparency), showOffListeningMode = false,
style = TextStyle(fontSize = 12.sp, color = textColor), noiseControlModeValue = 2,
textAlign = TextAlign.Center, onNoiseControlModeChanged = { }
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)
)
}
} }
} }
} }
@@ -18,40 +18,18 @@
package me.kavishdevar.librepods.presentation.components 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.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.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.R
import me.kavishdevar.librepods.data.StemAction import me.kavishdevar.librepods.data.StemAction
@Composable @Composable
fun PressAndHoldSettings( fun PressAndHoldSettings(
navController: NavController,
leftAction: StemAction, 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) { val leftActionText = when (leftAction) {
StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control) StemAction.CYCLE_NOISE_CONTROL_MODES -> stringResource(R.string.noise_control)
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant" StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
@@ -63,46 +41,19 @@ fun PressAndHoldSettings(
StemAction.DIGITAL_ASSISTANT -> "Digital Assistant" StemAction.DIGITAL_ASSISTANT -> "Digital Assistant"
else -> "INVALID!!" else -> "INVALID!!"
} }
Box(
modifier = Modifier StyledList(
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)) title = stringResource(R.string.press_and_hold_airpods)
.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))
) { ) {
NavigationButton( StyledListItem(
to = "long_press/Left",
name = stringResource(R.string.left), name = stringResource(R.string.left),
navController = navController, description = leftActionText,
independent = false, onClick = navigateToLeftLongPress
currentState = leftActionText,
) )
HorizontalDivider( StyledListItem(
thickness = 1.dp,
color = dividerColor,
modifier = Modifier
.padding(horizontal = 16.dp)
)
NavigationButton(
to = "long_press/Right",
name = stringResource(R.string.right), name = stringResource(R.string.right),
navController = navController, description = rightActionText,
independent = false, onClick = navigateToRightLongPress,
currentState = rightActionText,
) )
} }
} }
@@ -32,6 +32,11 @@ import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape 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.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf 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.effects.vibrancy
import com.kyant.backdrop.highlight.Highlight import com.kyant.backdrop.highlight.Highlight
import kotlinx.coroutines.launch 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 me.kavishdevar.librepods.utils.inspectDragGestures
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.atan2 import kotlin.math.atan2
@@ -68,6 +75,13 @@ import kotlin.math.cos
import kotlin.math.sin import kotlin.math.sin
import kotlin.math.tanh import kotlin.math.tanh
enum class MaterialButtonStyle {
Tonal,
Normal,
Outlined,
Filled
}
@Composable @Composable
fun StyledButton( fun StyledButton(
onClick: () -> Unit, onClick: () -> Unit,
@@ -78,20 +92,58 @@ fun StyledButton(
surfaceColor: Color = Color.Unspecified, surfaceColor: Color = Color.Unspecified,
maxScale: Float = 0.1f, maxScale: Float = 0.1f,
enabled: Boolean = true, 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, content: @Composable RowScope.() -> Unit,
) { ) {
val isInteractive = enabled && isInteractive when (LocalDesignSystem.current) {
val scope = rememberCoroutineScope() DesignSystem.Material -> {
val haptics = LocalHapticFeedback.current when (materialButtonStyle) {
val progressAnimation = remember { Animatable(0f) } MaterialButtonStyle.Filled -> {
var pressStartPosition by remember { mutableStateOf(Offset.Zero) } Button(
val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) } modifier = modifier.height(48.dp),
var isPressed by remember { mutableStateOf(false) } onClick = onClick,
content = content
)
}
MaterialButtonStyle.Tonal -> {
FilledTonalButton(
modifier = modifier.height(48.dp),
onClick = onClick,
content = content,
colors = ButtonDefaults.filledTonalButtonColors(containerColor = surfaceColor)
)
}
val interactiveHighlightShader = remember { MaterialButtonStyle.Outlined -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { OutlinedButton(
RuntimeShader( 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; uniform float2 size;
layout(color) uniform half4 color; layout(color) uniform half4 color;
uniform float radius; uniform float radius;
@@ -103,238 +155,250 @@ half4 main(float2 coord) {
float intensity = smoothstep(radius, radius * 0.5, dist); float intensity = smoothstep(radius, radius * 0.5, dist);
return color * intensity; 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 { } else {
Modifier.drawBackdrop( null
backdrop = backdrop, }
shape = { RoundedCornerShape(28f.dp) }, }
effects = {
vibrancy() Row(
blur(2f.dp.toPx()) modifier
lens( .then(
refractionHeight = 12f.dp.toPx(), if (!isInteractive) {
refractionAmount = 24f.dp.toPx(), Modifier.drawBackdrop(
depthEffect = true, backdrop = backdrop,
chromaticAberration = true 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 {
layerBlock = { Modifier.drawBackdrop(
val width = size.width backdrop = backdrop,
val height = size.height 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 progress = progressAnimation.value
val scale = lerp(1f, 1f + maxScale, progress) val scale = lerp(1f, 1f + maxScale, progress)
val maxOffset = size.minDimension val maxOffset = size.minDimension
val initialDerivative = 0.05f val initialDerivative = 0.05f
val offset = offsetAnimation.value val offset = offsetAnimation.value
translationX = translationX =
maxOffset * tanh(initialDerivative * offset.x / maxOffset) maxOffset * tanh(initialDerivative * offset.x / maxOffset)
translationY = translationY =
maxOffset * tanh(initialDerivative * offset.y / maxOffset) maxOffset * tanh(initialDerivative * offset.y / maxOffset)
val maxDragScale = 0.1f val maxDragScale = 0.1f
val offsetAngle = atan2(offset.y, offset.x) val offsetAngle = atan2(offset.y, offset.x)
scaleX = scaleX =
scale + scale +
maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) * maxDragScale * abs(cos(offsetAngle) * offset.x / size.maxDimension) *
(width / height).fastCoerceAtMost(1f) (width / height).fastCoerceAtMost(1f)
scaleY = scaleY =
scale + scale +
maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) * maxDragScale * abs(sin(offsetAngle) * offset.y / size.maxDimension) *
(height / width).fastCoerceAtMost(1f) (height / width).fastCoerceAtMost(1f)
}, },
onDrawSurface = { onDrawSurface = {
if (tint.isSpecified) { if (tint.isSpecified) {
drawRect(tint, blendMode = BlendMode.Hue) drawRect(tint, blendMode = BlendMode.Hue)
drawRect(tint.copy(alpha = 0.75f)) drawRect(tint.copy(alpha = 0.75f))
} else { } else {
drawRect(Color.White.copy(0.1f)) drawRect(Color.White.copy(0.1f))
}
if (surfaceColor.isSpecified) {
val color = if (!isInteractive && isPressed) {
Color(
red = surfaceColor.red * 0.5f,
green = surfaceColor.green * 0.5f,
blue = surfaceColor.blue * 0.5f,
alpha = surfaceColor.alpha
)
} else {
surfaceColor
}
drawRect(color)
}
},
onDrawFront = {
val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
if (progress > 0f) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) {
drawRect(
Color.White.copy(0.1f * progress),
blendMode = BlendMode.Plus
)
interactiveHighlightShader.apply {
val offset = pressStartPosition + offsetAnimation.value
setFloatUniform("size", size.width, size.height)
setColorUniform(
"color",
Color.White.copy(0.15f * progress).toArgb()
)
setFloatUniform("radius", size.maxDimension)
setFloatUniform(
"offset",
offset.x.fastCoerceIn(0f, size.width),
offset.y.fastCoerceIn(0f, size.height)
)
} }
drawRect( if (surfaceColor.isSpecified) {
ShaderBrush(interactiveHighlightShader), val color = if (!isInteractive && isPressed) {
blendMode = BlendMode.Plus Color(
) red = surfaceColor.red * 0.5f,
} else { green = surfaceColor.green * 0.5f,
drawRect( blue = surfaceColor.blue * 0.5f,
Color.White.copy(0.25f * progress), alpha = surfaceColor.alpha
blendMode = BlendMode.Plus )
) } else {
} surfaceColor
} }
} drawRect(color)
)
}
)
.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
)
} }
launch { },
offsetAnimation.animateTo( onDrawFront = {
Offset.Zero, val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
offsetAnimationSpec if (progress > 0f) {
) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) {
} drawRect(
} Color.White.copy(0.1f * progress),
} blendMode = BlendMode.Plus
} )
inspectDragGestures( interactiveHighlightShader.apply {
onDragStart = { down -> val offset =
pressStartPosition = down.position pressStartPosition + offsetAnimation.value
if (enabled) { setFloatUniform("size", size.width, size.height)
scope.launch { setColorUniform(
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) } "color",
launch { Color.White.copy(0.15f * progress).toArgb()
progressAnimation.animateTo( )
1f, setFloatUniform("radius", size.maxDimension)
progressAnimationSpec 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() )
}, .clickable(
onDragCancel = onDragStop interactionSource = null,
) { _, dragAmount -> indication = null,
role = Role.Button,
onClick = {
if (enabled) { if (enabled) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick)
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback( onClick()
HapticFeedbackType.SegmentFrequentTick
)
offsetAnimation.snapTo(offsetAnimation.value + dragAmount)
}
} }
} }
} )
} else { .then(
Modifier.pointerInput(Unit) { if (isInteractive) {
detectTapGestures( Modifier.pointerInput(scope) {
onPress = { val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
isPressed = true val offsetAnimationSpec =
tryAwaitRelease() spring(1f, 300f, Offset.VisibilityThreshold)
isPressed = false val onDragStop: () -> Unit = {
}, if (enabled) {
onTap = { scope.launch {
if (enabled) { launch {
haptics.performHapticFeedback(HapticFeedbackType.ContextClick) haptics.performHapticFeedback(
onClick() 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
)
} }
@@ -1,259 +0,0 @@
/*
LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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<String>,
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<Int?>(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<Offset?>(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)
)
}
}
}
}
}
}
}
}
@@ -34,6 +34,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape 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.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -81,6 +85,8 @@ import com.kyant.backdrop.highlight.Highlight
import com.kyant.backdrop.shadow.InnerShadow import com.kyant.backdrop.shadow.InnerShadow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R 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 me.kavishdevar.librepods.utils.inspectDragGestures
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.atan2 import kotlin.math.atan2
@@ -96,25 +102,92 @@ fun StyledIconButton(
surfaceColor: Color = Color.Unspecified, surfaceColor: Color = Color.Unspecified,
backdrop: LayerBackdrop = rememberLayerBackdrop(), backdrop: LayerBackdrop = rememberLayerBackdrop(),
onClick: () -> Unit, onClick: () -> Unit,
enabled: Boolean = true enabled: Boolean = true,
materialButtonStyle: MaterialButtonStyle = MaterialButtonStyle.Normal
) { ) {
val haptics = LocalHapticFeedback.current when (LocalDesignSystem.current) {
val darkMode = isSystemInDarkTheme() DesignSystem.Material -> {
val scope = rememberCoroutineScope() when (materialButtonStyle) {
val progressAnimationSpec = spring(0.5f, 300f, 0.001f) MaterialButtonStyle.Tonal -> {
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold) FilledTonalIconButton(
val progressAnimation = remember { Animatable(0f) } onClick = onClick,
val offsetAnimation = remember { Animatable(Offset.Zero, Offset.VectorConverter) } enabled = enabled,
var pressStartPosition by remember { mutableStateOf(Offset.Zero) } modifier = Modifier.size(52.dp)
val innerShadowLayer = rememberGraphicsLayer().apply { ) {
compositingStrategy = CompositingStrategy.Offscreen Text(
} text = icon,
val density = LocalDensity.current 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 { val interactiveHighlightShader = remember {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
RuntimeShader( RuntimeShader(
""" """
uniform float2 size; uniform float2 size;
layout(color) uniform half4 color; layout(color) uniform half4 color;
uniform float radius; uniform float radius;
@@ -126,203 +199,222 @@ half4 main(float2 coord) {
float intensity = smoothstep(radius, radius * 0.5, dist); float intensity = smoothstep(radius, radius * 0.5, dist);
return color * intensity; return color * intensity;
}""" }"""
) )
} else { } else {
null null
} }
}
val isDarkTheme = isSystemInDarkTheme()
TextButton(
onClick = {
if (enabled) {
scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
onClick()
} }
}, val isDarkTheme = isSystemInDarkTheme()
shape = RoundedCornerShape(56.dp), TextButton(
modifier = modifier onClick = {
.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) { if (enabled) {
scope.launch { scope.launch { haptics.performHapticFeedback(HapticFeedbackType.ContextClick) }
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) } onClick()
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
}
} }
} },
inspectDragGestures( shape = RoundedCornerShape(56.dp),
onDragStart = { down -> modifier = modifier
if (enabled) { .padding(horizontal = 12.dp)
pressStartPosition = down.position .drawBackdrop(
scope.launch { backdrop = backdrop,
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) } shape = { RoundedCornerShape(56.dp) },
launch { progressAnimation.animateTo(1f, progressAnimationSpec) } highlight = { Highlight.Ambient.copy(alpha = if (isDarkTheme) 1f else 0f) },
launch { offsetAnimation.snapTo(Offset.Zero) } 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
)
}
}
} }
} }
}, inspectDragGestures(
onDragEnd = { onDragStop() }, onDragStart = { down ->
onDragCancel = onDragStop if (enabled) {
) { _, dragAmount -> pressStartPosition = down.position
scope.launch { scope.launch {
if (enabled) { launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback( launch {
HapticFeedbackType.SegmentFrequentTick progressAnimation.animateTo(
) 1f,
offsetAnimation.snapTo(offsetAnimation.value + dragAmount) 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") @Preview(uiMode = UI_MODE_NIGHT_YES, name = "Dark")
@Composable @Composable
fun StyledIconButtonPreview() { 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( StyledIconButton(
icon = "􀍟", icon = "􀍟",
onClick = { } onClick = { }
@@ -1,6 +1,5 @@
package me.kavishdevar.librepods.presentation.components package me.kavishdevar.librepods.presentation.components
import android.R.attr.singleLine
import androidx.compose.animation.core.animateDp import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.updateTransition 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.TextFieldState
import androidx.compose.foundation.text.input.clearText import androidx.compose.foundation.text.input.clearText
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
@Composable @Composable
@@ -46,109 +49,130 @@ fun StyledInputField(
inputState: TextFieldState, inputState: TextFieldState,
focusRequester: FocusRequester, focusRequester: FocusRequester,
placeholder: String = "", placeholder: String = "",
singleLine: Boolean = true singleLine: Boolean = true,
){ forceApple: Boolean = false
val isDarkTheme = isSystemInDarkTheme() ) {
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material && !forceApple
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"
)
val transition = updateTransition(hasText, label = "floating") if(m3eEnabled) {
val yOffset by transition.animateDp(label = "y") { TextField(
if (it) with (density) { (-48).sp.toDp() } else 0.dp state = inputState,
} placeholder = {
Text(
Spacer(modifier = Modifier.height(spacerHeight)) text = placeholder,
style = MaterialTheme.typography.labelMedium,
Box( color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
modifier = Modifier.fillMaxWidth() )
) { },
Row( lineLimits = if (singleLine) TextFieldLineLimits.SingleLine else TextFieldLineLimits.Default,
verticalAlignment = verticalAlignment,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = minHeight) .padding(horizontal = 16.dp)
.background( )
backgroundColor, }
RoundedCornerShape(28.dp) else {
) val isDarkTheme = isSystemInDarkTheme()
.padding(horizontal = 16.dp, vertical = 8.dp) val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
.pointerInput(Unit) { val textColor = if (isDarkTheme) Color.White else Color.Black
detectTapGestures { val minHeight = if (singleLine) 58.dp else 120.dp
focusRequester.requestFocus() val verticalAlignment = if (singleLine) Alignment.CenterVertically else Alignment.Top
} val hasText = inputState.text.isNotEmpty()
} val density = LocalDensity.current
) { val spacerHeight by animateDpAsState(
BasicTextField( targetValue = if (hasText) with(density) { 32.sp.toDp() } else 0.dp,
state = inputState, label = "labelSpacer"
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() val transition = updateTransition(hasText, label = "floating")
} val yOffset by transition.animateDp(label = "y") {
} if (it) with(density) { (-48).sp.toDp() } else 0.dp
if (singleLine && !inputState.text.isEmpty()) { }
IconButton(
onClick = { Spacer(modifier = Modifier.height(spacerHeight))
inputState.clearText()
} Box(
) { modifier = Modifier.fillMaxWidth()
Text( ) {
text = "􀁡", Row(
style = TextStyle( verticalAlignment = verticalAlignment,
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 modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 8.dp) .heightIn(min = minHeight)
.focusRequester(focusRequester) .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)
)
}
} }
} }
} }
@@ -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 },
)
}
}
}
}
}
@@ -0,0 +1,409 @@
/*
LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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))
}
}
}
}
}
@@ -18,6 +18,15 @@
package me.kavishdevar.librepods.presentation.components 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.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.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.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text 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.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment 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.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import com.kyant.backdrop.backdrops.LayerBackdrop import com.kyant.backdrop.backdrops.LayerBackdrop
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeTint import dev.chrisbanes.haze.HazeTint
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.rememberHazeState
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.theme.DesignSystem
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun StyledScaffold( fun StyledScaffold(
modifier: Modifier = Modifier,
visible: Boolean = true,
title: String, title: String,
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(), showBackButton: Boolean = false,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, onNavigateBack: () -> Unit = {},
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,
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(), actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
StyledScaffold( val isDarkTheme = isSystemInDarkTheme()
title = title, val hazeState = rememberHazeState(blurEnabled = true)
actionButtons = actionButtons,
snackbarHostState = snackbarHostState,
) { _, _, _->
content()
}
}
@Composable when (LocalDesignSystem.current) {
fun StyledScaffold( DesignSystem.Material -> {
title: String, Scaffold(
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(), containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, snackbarHost = { SnackbarHost(snackbarHostState) },
content: @Composable (spacerValue: Dp) -> Unit topBar = {
) { AnimatedVisibility(
StyledScaffold( visible = visible,
title = title, enter = fadeIn() + slideInVertically(initialOffsetY = { -it }),
actionButtons = actionButtons, exit = fadeOut() + slideOutVertically(targetOffsetY = { -it })
snackbarHostState = snackbarHostState, ) {
) { spacerValue, _, _ -> TopAppBar(
content(spacerValue) 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()
}
}
}
}
} }
} }
@@ -1,188 +0,0 @@
/*
LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
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<SelectItem>,
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)
)
}
}
}
}
}
@@ -34,14 +34,18 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape 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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf 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.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview 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.Velocity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@@ -90,6 +95,9 @@ import com.kyant.backdrop.shadow.Shadow
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R 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 me.kavishdevar.librepods.utils.inspectDragGestures
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -221,363 +229,530 @@ fun StyledSlider(
endLabel: String? = null, endLabel: String? = null,
independent: Boolean = false, independent: Boolean = false,
description: String? = null, description: String? = null,
enabled: Boolean = true enabled: Boolean = true,
index: Int = 0,
count: Int = 1
) { ) {
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) when (LocalDesignSystem.current) {
val isLightTheme = !isSystemInDarkTheme() DesignSystem.Material -> {
val trackColor = val defaultShape = when {
if (isLightTheme) Color(0xFF787878).copy(0.2f) count == 1 -> RoundedCornerShape(24.dp)
else Color(0xFF787880).copy(0.36f)
val accentColor =
if (enabled) {
if (isLightTheme) Color(0xFF0088FF)
else Color(0xFF0091FF)
} else {
trackColor
}
val labelTextColor = if (isLightTheme) Color.Black else Color.White
val fraction by derivedStateOf { index == 0 -> RoundedCornerShape(
((value - valueRange.start) / (valueRange.endInclusive - valueRange.start)) topStart = 24.dp,
.fastCoerceIn(0f, 1f) topEnd = 24.dp,
} bottomStart = 8.dp,
bottomEnd = 8.dp
)
val sliderBackdrop = rememberLayerBackdrop() index == count - 1 -> RoundedCornerShape(
val trackWidthState = remember { mutableFloatStateOf(0f) } topStart = 8.dp,
val trackPositionState = remember { mutableFloatStateOf(0f) } topEnd = 8.dp,
val startIconWidthState = remember { mutableFloatStateOf(0f) } bottomStart = 24.dp,
val endIconWidthState = remember { mutableFloatStateOf(0f) } bottomEnd = 24.dp
val density = LocalDensity.current )
val haptics = LocalHapticFeedback.current
var lastDragValue by remember { mutableFloatStateOf(value) }
val momentumAnimation = rememberMomentumAnimation(maxScale = 1.5f) else -> RoundedCornerShape(8.dp)
}
val content = @Composable { Column {
Box( label?.let {
Modifier Text(
.fillMaxWidth(if (startIcon == null && endIcon == null) 0.95f else 1f) text = it,
) { color = MaterialTheme.colorScheme.primary,
Box( style = MaterialTheme.typography.labelSmallEmphasized,
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 modifier = Modifier
.fillMaxWidth() .padding(horizontal = 16.dp)
.padding(vertical = 4.dp) .padding(top = 4.dp, bottom = 12.dp)
.then(if (startIcon == null && endIcon == null) Modifier.padding(horizontal = 8.dp) else Modifier), )
) { }
Row( SegmentedListItem(
verticalAlignment = Alignment.CenterVertically, shapes = ListItemDefaults.shapes().copy(
horizontalArrangement = Arrangement.spacedBy(0.dp) 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(
text = startIcon, text = it,
style = TextStyle( style = MaterialTheme.typography.bodyMedium
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( if (startLabel != null || endLabel != null) {
Modifier Row(
.clip(RoundedCornerShape(28.dp)) modifier = Modifier.fillMaxWidth()
.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 -> startLabel?.let {
val pointFraction = Text(
((point - valueRange.start) / (valueRange.endInclusive - valueRange.start)) text = it,
.fastCoerceIn(0f, 1f) style = MaterialTheme.typography.labelSmall
Box( )
Modifier }
.graphicsLayer {
translationX = Spacer(Modifier.weight(1f))
startOffset + pointFraction * trackWidth - 4.dp.toPx()
} endLabel?.let {
.size(2.dp) Text(
.background( text = it,
trackColor, style = MaterialTheme.typography.labelSmall
CircleShape
)
) )
} }
} }
} }
} }
},
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..<lastDragValue)
) {
haptics.performHapticFeedback(
HapticFeedbackType.SegmentTick
)
}
}
lastDragValue = targetValue
val snappedValue =
if (snapPoints.isNotEmpty()) snapIfClose(
targetValue,
snapPoints,
snapThreshold,
) else targetValue
onValueChange(snappedValue)
}
},
Orientation.Horizontal,
startDragImmediately = true,
onDragStarted = {
lastDragValue = value
},
onDragStopped = {
onValueChange((value * 100).roundToInt() / 100f)
}
)
.then(momentumAnimation.modifier)
.drawBackdrop(
rememberCombinedBackdrop(backdrop, sliderBackdrop),
{ RoundedCornerShape(28.dp) },
highlight = {
val progress = momentumAnimation.progress
Highlight.Ambient.copy(alpha = progress)
},
shadow = {
Shadow(
radius = 4f.dp,
color = Color.Black.copy(0.05f)
)
},
innerShadow = {
val progress = momentumAnimation.progress
InnerShadow(
radius = 4f.dp * progress,
alpha = progress
)
},
layerBlock = {
scaleX = momentumAnimation.scaleX
scaleY = momentumAnimation.scaleY
val velocity = momentumAnimation.velocity / 5000f
scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(
-0.15f,
0.15f
)
scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(
-0.15f,
0.15f
)
},
onDrawSurface = {
val progress = momentumAnimation.progress
drawRect(Color.White.copy(alpha = 1f - progress))
},
effects = {
val progress = momentumAnimation.progress
blur(8f.dp.toPx() * (1f - progress))
lens(
refractionHeight = 6f.dp.toPx() * progress,
refractionAmount = size.height / 2f * progress,
depthEffect = true,
chromaticAberration = true
)
}
)
} else {
Modifier.background(trackColor, RoundedCornerShape(28.dp))
}
)
.size(40f.dp, 24f.dp)
)
} }
} }
Box( if (independent) {
Modifier Column(
.graphicsLayer { modifier = Modifier
// val startOffset = .fillMaxWidth(),
// if (startIcon != null) startIconWidthState.floatValue + with(density) { 24.dp.toPx() } else with(density) { 12.dp.toPx() } verticalArrangement = Arrangement.spacedBy(2.dp)
// translationX = ) {
// startOffset + fraction * trackWidthState.floatValue - size.width / 2f if (label != null) {
val startOffset = Text(
if (startIcon != null) text = label,
startIconWidthState.floatValue + with(density) { 24.dp.toPx() } style = TextStyle(
else fontSize = 14.sp,
with(density) { 8.dp.toPx() } fontWeight = FontWeight.Bold,
color = labelTextColor.copy(alpha = 0.6f),
translationX = fontFamily = FontFamily(Font(R.font.sf_pro))
(startOffset + fraction * trackWidthState.floatValue - size.width / 2f) ),
.fastCoerceIn( modifier = Modifier.padding(horizontal = 18.dp, vertical = 4.dp)
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..<lastDragValue)) {
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
}
}
lastDragValue = targetValue
val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose(
targetValue,
snapPoints,
snapThreshold,
) else targetValue
onValueChange(snappedValue)
}
},
Orientation.Horizontal,
startDragImmediately = true,
onDragStarted = {
lastDragValue = value
},
onDragStopped = {
onValueChange((value * 100).roundToInt() / 100f)
}
)
.then(momentumAnimation.modifier)
.drawBackdrop(
rememberCombinedBackdrop(backdrop, sliderBackdrop),
{ RoundedCornerShape(28.dp) },
highlight = {
val progress = momentumAnimation.progress
Highlight.Ambient.copy(alpha = progress)
},
shadow = {
Shadow(
radius = 4f.dp,
color = Color.Black.copy(0.05f)
)
},
innerShadow = {
val progress = momentumAnimation.progress
InnerShadow(
radius = 4f.dp * progress,
alpha = progress
)
},
layerBlock = {
scaleX = momentumAnimation.scaleX
scaleY = momentumAnimation.scaleY
val velocity = momentumAnimation.velocity / 5000f
scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.15f, 0.15f)
scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.15f, 0.15f)
},
onDrawSurface = {
val progress = momentumAnimation.progress
drawRect(Color.White.copy(alpha = 1f - progress))
},
effects = {
val progress = momentumAnimation.progress
blur(8f.dp.toPx() * (1f - progress))
lens(
refractionHeight = 6f.dp.toPx() * progress,
refractionAmount = size.height / 2f * progress,
depthEffect = true,
chromaticAberration = true
)
}
)
} else {
Modifier.background(trackColor, RoundedCornerShape(28.dp))
}
)
.size(40f.dp, 24f.dp)
)
}
}
if (independent) { Box(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(horizontal = 8.dp, vertical = 0.dp)
.heightIn(min = 58.dp),
contentAlignment = Alignment.Center
) {
content()
}
Column ( if (description != null) {
modifier = Modifier Text(
.fillMaxWidth(), text = description,
verticalArrangement = Arrangement.spacedBy(2.dp) style = TextStyle(
) { fontSize = 12.sp,
if (label != null) { fontWeight = FontWeight.Light,
Text( color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
text = label, alpha = 0.6f
style = TextStyle( ),
fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))
fontWeight = FontWeight.Bold, ),
color = labelTextColor.copy(alpha = 0.6f), modifier = Modifier
fontFamily = FontFamily(Font(R.font.sf_pro)) .padding(horizontal = 18.dp, vertical = 4.dp)
), )
modifier = Modifier.padding(horizontal = 18.dp, vertical = 4.dp) }
}
} else {
if (label != null) Log.w(
"StyledSlider",
"Label is ignored when independent is false"
)
if (description != null) Log.w(
"StyledSlider",
"Description is ignored when independent is false"
) )
}
Box(
modifier = Modifier
.fillMaxWidth()
.background(backgroundColor, RoundedCornerShape(28.dp))
.padding(horizontal = 8.dp, vertical = 0.dp)
.heightIn(min = 58.dp),
contentAlignment = Alignment.Center
) {
content() content()
} }
if (description != null) {
Text(
text = description,
style = TextStyle(
fontSize = 12.sp,
fontWeight = FontWeight.Light,
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier
.padding(horizontal = 18.dp, vertical = 4.dp)
)
}
} }
} else {
if (label != null) Log.w("StyledSlider", "Label is ignored when independent is false")
if (description != null) Log.w("StyledSlider", "Description is ignored when independent is false")
content()
} }
} }
@@ -586,46 +761,48 @@ private fun snapIfClose(value: Float, points: List<Float>, threshold: Float = 0.
return if (abs(nearest - value) <= threshold) nearest else value 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 @Composable
fun StyledSliderPreview() { fun StyledSliderPreview() {
val a = remember { mutableFloatStateOf(0.5f) } val a = remember { mutableFloatStateOf(0.5f) }
Box( LibrePodsTheme(
Modifier m3eEnabled = true
.background(if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF0F0F0))
.padding(16.dp)
.fillMaxSize()
) { ) {
Column ( StyledScaffold(
Modifier.align(Alignment.Center), title = "test",
verticalArrangement = Arrangement.spacedBy(16.dp) ) {
) Column(
{ modifier = Modifier.padding(horizontal = 16.dp),
StyledSlider( verticalArrangement = Arrangement.spacedBy(16.dp)
value = a.floatValue, ) {
onValueChange = { Spacer(modifier = Modifier.height(72.dp))
a.floatValue = it StyledSlider(
}, value = a.floatValue,
valueRange = 0f..2f, onValueChange = {
snapPoints = listOf(1f), a.floatValue = it
snapThreshold = 0.1f, },
independent = true, valueRange = 0f..2f,
startIcon = "A", snapPoints = listOf(1f),
endIcon = "B", snapThreshold = 0.1f,
) independent = true,
StyledSlider( startIcon = "A",
value = a.floatValue, endIcon = "B",
onValueChange = { )
a.floatValue = it StyledSlider(
}, label = "Small label",
valueRange = 0f..2f, description = "This is a somewhat long descriptionRes",
snapPoints = listOf(1f), value = a.floatValue,
snapThreshold = 0.1f, onValueChange = {
independent = true, a.floatValue = it
startIcon = "A", },
endIcon = "B", valueRange = 0f..2f,
enabled = false snapPoints = listOf(1f),
) snapThreshold = 0.1f,
independent = true,
startIcon = "A",
endIcon = "B",
)
}
} }
} }
} }
@@ -20,8 +20,6 @@
package me.kavishdevar.librepods.presentation.components 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures 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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape 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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -42,9 +47,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput 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.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily 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.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R 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 import kotlin.io.encoding.ExperimentalEncodingApi
@Composable @Composable
@@ -66,9 +74,102 @@ fun StyledToggle(
label: String, label: String,
description: String? = null, description: String? = null,
checked: Boolean = false, checked: Boolean = false,
independent: Boolean = true,
enabled: Boolean = true, enabled: Boolean = true,
onCheckedChange: (Boolean) -> Unit, 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) val currentChecked by rememberUpdatedState(checked)
@@ -78,196 +179,209 @@ fun StyledToggle(
val haptics = LocalHapticFeedback.current val haptics = LocalHapticFeedback.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var backgroundColor by remember { val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
mutableStateOf(
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
)
}
val animatedBackgroundColor by animateColorAsState( if (m3eEnabled) {
targetValue = backgroundColor, val defaultShape = when {
animationSpec = tween(durationMillis = 500) count == 1 -> RoundedCornerShape(24.dp)
)
if (independent) { index == 0 -> RoundedCornerShape(
Column(modifier = Modifier.padding(vertical = 8.dp)) { topStart = 24.dp,
if (title != null) { topEnd = 24.dp,
Text( bottomStart = 8.dp,
text = title, bottomEnd = 8.dp
style = TextStyle( )
fontSize = 14.sp,
fontWeight = FontWeight.Bold, index == count - 1 -> RoundedCornerShape(
color = textColor.copy(alpha = 0.6f), topStart = 8.dp,
fontFamily = FontFamily(Font(R.font.sf_pro)) topEnd = 8.dp,
), bottomStart = 24.dp,
modifier = Modifier.padding( bottomEnd = 24.dp
start = 16.dp, )
end = 16.dp,
top = 8.dp, else -> RoundedCornerShape(8.dp)
bottom = 4.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
) )
) },
} supportingContent = description?.let {
{
Box( Text(
modifier = Modifier text = it,
.background(animatedBackgroundColor, RoundedCornerShape(28.dp)) style = MaterialTheme.typography.bodySmall,
.padding(4.dp) modifier = Modifier
.pointerInput(Unit) { .padding(top = if (header) 2.dp else 4.dp, bottom = if (header) 8.dp else 4.dp)
detectTapGestures( .padding(horizontal = if (header) 8.dp else 0.dp),
onPress = { color = if (header && enabled) MaterialTheme.colorScheme.onPrimaryContainer else Color.Unspecified
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)
}
}
) )
} }
) { },
Row( content = {
modifier = Modifier
.fillMaxWidth()
.height(55.dp)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text( Text(
text = label, text = label,
modifier = Modifier.weight(1f), style = MaterialTheme.typography.labelMediumEmphasized,
style = TextStyle( modifier = Modifier
fontSize = 16.sp, .padding(
fontFamily = FontFamily(Font(R.font.sf_pro)), top = if (header) 8.dp else 4.dp,
fontWeight = FontWeight.Normal, bottom = if (header) 2.dp else 4.dp
color = textColor )
) .padding(horizontal = if (header) 8.dp else 0.dp),
color = if (header && enabled) MaterialTheme.colorScheme.onPrimaryContainer else Color.Unspecified
) )
},
StyledSwitch( enabled = enabled,
checked = checked, verticalAlignment = Alignment.CenterVertically,
enabled = enabled, modifier = Modifier.heightIn(min = 64.dp),
onCheckedChange = { colors = if (header) ListItemDefaults.segmentedColors(containerColor = MaterialTheme.colorScheme.primaryContainer) else ListItemDefaults.segmentedColors()
if (enabled) { )
scope.launch { haptics.performHapticFeedback(if (it) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) } if (index+1 != count) {
onCheckedChange(it) Spacer(modifier = Modifier.height(2.dp))
}
}
)
}
}
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))
)
)
}
} }
} }
} else { } else {
val isPressed = remember { mutableStateOf(false) } val isPressed = remember { mutableStateOf(false) }
Column {
Row( 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(
modifier = Modifier modifier = Modifier
.weight(1f) .fillMaxWidth()
.padding(end = 4.dp) .background(
) { shape = RoundedCornerShape(28.dp),
Text( color = if (isPressed.value) Color(0xFFE0E0E0) else Color.Transparent
text = label,
style = TextStyle(
fontSize = 16.sp,
fontFamily = FontFamily(Font(R.font.sf_pro)),
fontWeight = FontWeight.Normal,
color = textColor
) )
) .padding(16.dp)
.pointerInput(Unit) {
Spacer(modifier = Modifier.height(4.dp)) detectTapGestures(
onPress = {
if (description != null) { isPressed.value = true
Text( tryAwaitRelease()
text = description, isPressed.value = false
style = TextStyle( }
fontSize = 12.sp,
color = textColor.copy(0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro)),
) )
}
.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( if (description != null) {
checked = checked, Spacer(modifier = Modifier.height(4.dp))
enabled = enabled, Text(
onCheckedChange = { text = description,
if (enabled) { style = MaterialTheme.typography.bodySmall,
onCheckedChange(it) 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 @Composable
fun StyledTogglePreview() { fun StyledToggleApplePreview() {
val checked = remember { mutableStateOf(false) } val checked = remember { mutableStateOf(false) }
StyledToggle( LibrePodsTheme(m3eEnabled = false) {
label = "Example Toggle", StyledToggle(
description = "This is an example description for the styled toggle.", label = "Apple",
checked = checked.value, description = "This is an example description for the styled toggle.",
onCheckedChange = { checked.value = !checked.value } 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 }
)
}
} }
@@ -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<Screen>,
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 }
}
},
)
}
}
@@ -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,
)
}
}
@@ -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
}
}
@@ -22,89 +22,67 @@ package me.kavishdevar.librepods.presentation.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log import android.util.Log
import androidx.compose.foundation.background 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier 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.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.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.data.Capability 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.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledDropdown import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledListItem
import me.kavishdevar.librepods.presentation.components.StyledSlider import me.kavishdevar.librepods.presentation.components.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle 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.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.time.Duration.Companion.milliseconds
private var phoneMediaDebounceJob: Job? = null private var phoneMediaDebounceJob: Job? = null
private var toneVolumeDebounceJob: Job? = null
//private const val TAG = "AccessibilitySettings"
@SuppressLint("DefaultLocale") @SuppressLint("DefaultLocale")
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class, FlowPreview::class)
@Composable @Composable
fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavController) { fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit, navigateToTransparencyCustomization: () -> Unit) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val hearingAidEnabled = val hearingAidEnabled =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull( state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(
1 1
@@ -113,294 +91,261 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
0 0
)?.toInt() == 1 )?.toInt() == 1
val backdrop = rememberLayerBackdrop()
StyledScaffold( val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
title = stringResource(R.string.accessibility) val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
) { topPadding, hazeState, bottomPadding -> val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.fillMaxSize()
.hazeSource(hazeState)
.layerBackdrop(backdrop)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
if (!state.isPremium) { Column(
StyledButton( modifier = Modifier
onClick = { .fillMaxSize()
navController.navigate("purchase_screen") .background(MaterialTheme.colorScheme.surfaceContainer)
}, .verticalScroll(rememberScrollState())
backdrop = rememberLayerBackdrop(), .padding(horizontal = 16.dp),
modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)
maxScale = 0.05f, ) {
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) Spacer(modifier = Modifier.height(topPadding))
) {
Text( if (!state.isPremium) {
stringResource(R.string.unlock_advanced_features), StyledButton(
style = TextStyle( onClick = navigateToPurchase,
fontSize = 16.sp, backdrop = rememberLayerBackdrop(),
fontWeight = FontWeight.Medium, modifier = Modifier.fillMaxWidth(),
fontFamily = FontFamily(Font(R.font.sf_pro)), maxScale = 0.05f,
color = Color.White 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 phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
// val phoneEQEnabled = remember { mutableStateOf(false) } // val phoneEQEnabled = remember { mutableStateOf(false) }
// val mediaEQEnabled = remember { mutableStateOf(false) } // val mediaEQEnabled = remember { mutableStateOf(false) }
val pressSpeedOptions = mapOf( val pressSpeedOptions = mapOf(
0.toByte() to stringResource(R.string.default_option), 0.toByte() to stringResource(R.string.default_option),
1.toByte() to stringResource(R.string.slower), 1.toByte() to stringResource(R.string.slower),
2.toByte() to stringResource(R.string.slowest) 2.toByte() to stringResource(R.string.slowest)
)
val selectedPressSpeedValue =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL]?.getOrNull(
0
) )
var selectedPressSpeed by remember {
val selectedPressSpeedValue = mutableStateOf(
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL]?.getOrNull( pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]
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)
) )
}
val selectedPressAndHoldDurationValue = val pressAndHoldDurationOptions = mapOf(
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull( 0.toByte() to stringResource(R.string.default_option),
0 1.toByte() to stringResource(R.string.slower),
) 2.toByte() to stringResource(R.string.slowest)
var selectedPressAndHoldDuration by remember { )
mutableStateOf(
pressAndHoldDurationOptions[selectedPressAndHoldDurationValue]
?: pressAndHoldDurationOptions[0]
)
}
val volumeSwipeSpeedOptions = mapOf( val selectedPressAndHoldDurationValue =
1.toByte() to stringResource(R.string.default_option), state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull(
2.toByte() to stringResource(R.string.longer), 0
3.toByte() to stringResource(R.string.longest)
) )
val selectedVolumeSwipeSpeedValue = var selectedPressAndHoldDuration by remember {
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL]?.getOrNull( mutableStateOf(
0 pressAndHoldDurationOptions[selectedPressAndHoldDurationValue]
) ?: pressAndHoldDurationOptions[0]
var selectedVolumeSwipeSpeed by remember { )
mutableStateOf( }
volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue]
?: volumeSwipeSpeedOptions[1]
)
}
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) } val volumeSwipeSpeedOptions = mapOf(
val phoneEQEnabled = remember { mutableStateOf(false) } 1.toByte() to stringResource(R.string.default_option),
val mediaEQEnabled = remember { mutableStateOf(false) } 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) { val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
phoneMediaDebounceJob?.cancel() val phoneEQEnabled = remember { mutableStateOf(false) }
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch { val mediaEQEnabled = remember { mutableStateOf(false) }
delay(150)
try { LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte() phoneMediaDebounceJob?.cancel()
val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte() phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
Log.d( delay(150.milliseconds)
"AccessibilitySettingsScreen", try {
"Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})" val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
) val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
viewModel.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte) Log.d(
} catch (e: Exception) { "AccessibilitySettingsScreen",
Log.w( "Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})"
"AccessibilitySettingsScreen", )
"Error sending phone/media EQ: ${e.message}" 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) { StyledList(
Modifier.pointerInput(Unit) { title = stringResource(R.string.press_speed),
awaitPointerEventScope { description = stringResource(R.string.press_speed_description)
while (true) { ) {
val event = awaitPointerEvent(PointerEventPass.Initial) pressSpeedOptions.forEach { (value, label) ->
event.changes.forEach { it.consume() } StyledListItem(
} name = label,
} selected = selectedPressSpeed == label,
} onClick = {
} else Modifier)) { selectedPressSpeed = label
DropdownMenuComponent(
label = stringResource(R.string.press_speed),
description = stringResource(R.string.press_speed_description),
options = pressSpeedOptions.values.toList(),
selectedOption = selectedPressSpeed ?: stringResource(R.string.default_option),
onOptionSelected = { newValue ->
selectedPressSpeed = newValue
viewModel.setControlCommandByte( viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL, identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
value = pressSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() value = value
?: 0.toByte()
) )
}, }
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( viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL, identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
value = pressAndHoldDurationOptions.filterValues { it == newValue }.keys.firstOrNull() value = value
?: 0.toByte()
) )
}, }
textColor = textColor,
hazeState = hazeState,
independent = true
) )
} }
}
StyledToggle(
title = stringResource(R.string.noise_control),
label = stringResource(R.string.noise_cancellation_single_airpod),
description = stringResource(R.string.noise_cancellation_single_airpod_description),
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( StyledToggle(
title = stringResource(R.string.noise_control), label = stringResource(R.string.loud_sound_reduction),
label = stringResource(R.string.noise_cancellation_single_airpod), description = stringResource(R.string.loud_sound_reduction_description),
description = stringResource(R.string.noise_cancellation_single_airpod_description), checked = state.loudSoundReductionEnabled,
independent = true,
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull(
0
) == 0x01.toByte(),
onCheckedChange = { onCheckedChange = {
viewModel.setControlCommandBoolean( viewModel.setATTCharacteristicValue(
AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it ATTHandles.LOUD_SOUND_REDUCTION,
if (it) byteArrayOf(0x01) else byteArrayOf(0x00)
) )
}, },
enabled = state.isPremium enabled = state.isPremium
) )
}
if (state.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) && state.vendorIdHook) { if (!hearingAidEnabled && state.vendorIdHook) {
StyledToggle( StyledListItem(
label = stringResource(R.string.loud_sound_reduction), name = stringResource(R.string.customize_transparency_mode),
description = stringResource(R.string.loud_sound_reduction_description), onClick = navigateToTransparencyCustomization,
checked = state.loudSoundReductionEnabled, enabled = state.isPremium
onCheckedChange = { )
viewModel.setATTCharacteristicValue( }
ATTHandles.LOUD_SOUND_REDUCTION,
if (it) byteArrayOf(0x01) else byteArrayOf(0x00) val toneVolumeValue = remember { mutableFloatStateOf(state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(0)?.toFloat() ?: 75f) }
)
}, LaunchedEffect(toneVolumeValue) {
enabled = state.isPremium snapshotFlow {
) toneVolumeValue.floatValue
} }
.debounce(100.milliseconds)
if (!hearingAidEnabled && state.vendorIdHook) { .collect {
NavigationButton(
to = "transparency_customization",
name = stringResource(R.string.customize_transparency_mode),
navController = navController,
enabled = state.isPremium
)
}
val toneVolumeValue =
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(
0
)?.toFloat() ?: 75f
StyledSlider(
label = stringResource(R.string.tone_volume),
description = stringResource(R.string.tone_volume_description),
value = toneVolumeValue,
onValueChange = {
viewModel.setControlCommandValue( viewModel.setControlCommandValue(
AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME, AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME,
byteArrayOf(it.toInt().toByte(), 0x50) 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 enabled = state.isPremium
) )
if (state.capabilities.contains(Capability.SWIPE_FOR_VOLUME)) { StyledList(
val volumeSwipeEnabled = title = stringResource(R.string.volume_swipe_speed),
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE]?.getOrNull( description = stringResource(R.string.volume_swipe_speed_description)
0 ) {
)?.toInt() == 0x01 volumeSwipeSpeedOptions.forEach { (value, label) ->
StyledToggle( StyledListItem(
label = stringResource(R.string.volume_control), name = label,
description = stringResource(R.string.volume_control_description), selected = selectedVolumeSwipeSpeed == label,
checked = volumeSwipeEnabled, onClick = {
onCheckedChange = { selectedVolumeSwipeSpeed = label
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, it
)
},
enabled = state.isPremium
)
Box(
modifier = Modifier.then(
if (!state.isPremium) {
Modifier.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.changes.forEach { it.consume() }
}
}
}
} else Modifier)) {
DropdownMenuComponent(
label = stringResource(R.string.volume_swipe_speed),
description = stringResource(R.string.volume_swipe_speed_description),
options = volumeSwipeSpeedOptions.values.toList(),
selectedOption = selectedVolumeSwipeSpeed
?: stringResource(R.string.default_option),
onOptionSelected = { newValue ->
selectedVolumeSwipeSpeed = newValue
viewModel.setControlCommandByte( viewModel.setControlCommandByte(
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL, identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
value = volumeSwipeSpeedOptions.filterValues { it == newValue }.keys.firstOrNull() value = value
?: 1.toByte()
) )
}, }
textColor = textColor,
hazeState = hazeState,
independent = true
) )
} }
} }
}
// if (!hearingAidEnabled && XposedState.isAvailable) { // if (!hearingAidEnabled && XposedState.isAvailable) {
// Text( // Text(
@@ -635,210 +580,6 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
// } // }
// } // }
// } // }
Spacer(modifier = Modifier.height(bottomPadding)) Spacer(modifier = Modifier.height(bottomPadding))
}
}
}
@ExperimentalHazeMaterialsApi
@Composable
private fun DropdownMenuComponent(
label: String,
options: List<String>,
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<Offset?>(null) }
var boxPosition by remember { mutableStateOf(Offset.Zero) }
var lastDismissTime by remember { mutableLongStateOf(0L) }
var parentHoveredIndex by remember { mutableStateOf<Int?>(null) }
var parentDragActive by remember { mutableStateOf(false) }
var previousIdx by remember { mutableStateOf<Int?>(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))
)
)
}
}
} }
} }
@@ -18,14 +18,19 @@
package me.kavishdevar.librepods.presentation.screens 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding 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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -36,15 +41,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource 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.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -53,74 +51,72 @@ import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.presentation.components.StyledButton 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.components.StyledSlider
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.AirPodsViewModel
@Composable @Composable
fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navController: NavController) { fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
title = stringResource(R.string.customize_adaptive_audio) val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
) { spacerHeight -> val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier Column(
.fillMaxSize() modifier = Modifier
.layerBackdrop(backdrop) .fillMaxSize()
.padding(horizontal = 16.dp), .layerBackdrop(backdrop)
verticalArrangement = Arrangement.spacedBy(16.dp) .background(MaterialTheme.colorScheme.surfaceContainer)
) { .padding(horizontal = 16.dp),
Spacer(modifier = Modifier.height(spacerHeight)) verticalArrangement = Arrangement.spacedBy(16.dp)
if (!state.isPremium) { ) {
StyledButton( Spacer(modifier = Modifier.height(topPadding))
onClick = { if (!state.isPremium) {
navController.navigate("purchase_screen") StyledButton(
}, onClick = navigateToPurchase,
backdrop = rememberLayerBackdrop(), backdrop = rememberLayerBackdrop(),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
maxScale = 0.05f, maxScale = 0.05f,
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) surfaceColor = MaterialTheme.colorScheme.primary
) { ) {
Text( Text(
stringResource(R.string.unlock_advanced_features), stringResource(R.string.unlock_advanced_features),
style = TextStyle( style = MaterialTheme.typography.bodyMedium,
fontSize = 16.sp, color = MaterialTheme.colorScheme.onPrimary
fontWeight = FontWeight.Medium, )
fontFamily = FontFamily(Font(R.font.sf_pro)), }
color = Color.White 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<Job?>(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 { valueRange = 0f..100f,
mutableFloatStateOf(100f - (state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH]?.getOrNull(0)?.toFloat() ?: 50f)) snapPoints = listOf(0f, 50f, 100f),
} startIcon = "􀊥",
var job by remember { mutableStateOf<Job?>(null) } endIcon = "􀊩",
val scope = rememberCoroutineScope() independent = true,
StyledSlider( description = stringResource(R.string.adaptive_audio_description),
label = stringResource(R.string.customize_adaptive_audio), enabled = state.isPremium
value = sliderValue.floatValue, )
onValueChange = { Spacer(modifier = Modifier.height(bottomPadding))
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
)
}
} }
} }
@@ -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))
}
}
@@ -18,94 +18,64 @@
package me.kavishdevar.librepods.presentation.screens package me.kavishdevar.librepods.presentation.screens
import android.accessibilityservice.AccessibilityServiceInfo //@Composable
import android.content.ComponentName //fun CameraControlScreen(viewModel: AirPodsViewModel) {
import android.content.Context // val context = LocalContext.current
import android.content.Intent // val currentCameraAction by viewModel.cameraAction.collectAsState()
import android.provider.Settings //
import android.view.accessibility.AccessibilityManager // fun isAppListenerServiceEnabled(context: Context): Boolean {
import androidx.compose.foundation.layout.Arrangement // val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
import androidx.compose.foundation.layout.Column // val enabledServices =
import androidx.compose.foundation.layout.Spacer // am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
import androidx.compose.foundation.layout.fillMaxSize // val serviceComponent = ComponentName(context, AppListenerService::class.java)
import androidx.compose.foundation.layout.height // return enabledServices.any {
import androidx.compose.foundation.layout.padding // it.resolveInfo.serviceInfo.packageName == serviceComponent.packageName &&
import androidx.compose.runtime.Composable // it.resolveInfo.serviceInfo.name == serviceComponent.className
import androidx.compose.runtime.collectAsState // }
import androidx.compose.runtime.getValue // }
import androidx.compose.runtime.remember //
import androidx.compose.ui.Modifier // fun handleSelection(action: StemPressType?) {
import androidx.compose.ui.platform.LocalContext // if (action != null && !isAppListenerServiceEnabled(context)) {
import androidx.compose.ui.res.stringResource // context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS))
import androidx.compose.ui.unit.dp // } else {
import com.kyant.backdrop.backdrops.layerBackdrop // viewModel.setCameraAction(action)
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 // val cameraOptions = remember(currentCameraAction) {
import me.kavishdevar.librepods.presentation.components.StyledSelectList // listOf(
import me.kavishdevar.librepods.services.AppListenerService // SelectItem(
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.StemPressType // name = "Off",
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel // selected = currentCameraAction == null,
// onClick = { handleSelection(null) }
@Composable // ),
fun CameraControlScreen(viewModel: AirPodsViewModel) { // SelectItem(
val context = LocalContext.current // name = "Press once",
val currentCameraAction by viewModel.cameraAction.collectAsState() // selected = currentCameraAction == StemPressType.SINGLE_PRESS,
// onClick = { handleSelection(StemPressType.SINGLE_PRESS) }
fun isAppListenerServiceEnabled(context: Context): Boolean { // ),
val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager // SelectItem(
val enabledServices = // name = "Press and hold AirPods",
am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK) // selected = currentCameraAction == StemPressType.LONG_PRESS,
val serviceComponent = ComponentName(context, AppListenerService::class.java) // onClick = { handleSelection(StemPressType.LONG_PRESS) }
return enabledServices.any { // )
it.resolveInfo.serviceInfo.packageName == serviceComponent.packageName && // )
it.resolveInfo.serviceInfo.name == serviceComponent.className // }
} //
} // val backdrop = rememberLayerBackdrop()
//
fun handleSelection(action: StemPressType?) { // StyledScaffold(
if (action != null && !isAppListenerServiceEnabled(context)) { // titleRes = stringResource(R.string.camera_control)
context.startActivity(Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)) // ) { spacerHeight ->
} else { // Column(
viewModel.setCameraAction(action) // modifier = Modifier
} // .fillMaxSize()
} // .layerBackdrop(backdrop)
// .padding(horizontal = 16.dp),
val cameraOptions = remember(currentCameraAction) { // verticalArrangement = Arrangement.spacedBy(16.dp)
listOf( // ) {
SelectItem( // Spacer(modifier = Modifier.height(spacerHeight))
name = "Off", // StyledSelectList(items = cameraOptions)
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)
}
}
}
@@ -1,523 +0,0 @@
/*
LibrePods - AirPods liberated from Apples 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 <https://www.gnu.org/licenses/>.
*/
@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<String, String> = 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<String, String>()
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<Int>()) }
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)
)
}
}
}
}
@@ -24,10 +24,6 @@
package me.kavishdevar.librepods.presentation.screens package me.kavishdevar.librepods.presentation.screens
import android.graphics.Paint 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.AnimatedContent
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
@@ -36,21 +32,26 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@@ -66,52 +67,38 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color 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.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle 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.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledButton 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.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.presentation.viewmodel.AirPodsViewModel
import me.kavishdevar.librepods.services.ServiceManager import me.kavishdevar.librepods.services.ServiceManager
import me.kavishdevar.librepods.utils.HeadTracking import me.kavishdevar.librepods.utils.HeadTracking
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.sin
import kotlin.random.Random
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
@Composable @Composable
fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController) { fun HeadTrackingScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
DisposableEffect(Unit) { DisposableEffect(Unit) {
viewModel.startHeadTracking() viewModel.startHeadTracking()
@@ -123,511 +110,169 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val scrollState = rememberScrollState()
val backdrop = rememberLayerBackdrop() 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 m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
val coroutineScope = rememberCoroutineScope() 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 gestureText by remember { mutableStateOf("") }
var shouldExplode by remember { mutableStateOf(false) } 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 modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.verticalScroll(scrollState), .layerBackdrop(backdrop)
horizontalAlignment = Alignment.CenterHorizontally .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) { if (!state.isPremium) {
StyledButton( StyledButton(
onClick = { onClick = navigateToPurchase,
navController.navigate("purchase_screen") backdrop = rememberLayerBackdrop(),
}, modifier = Modifier.fillMaxWidth(),
backdrop = rememberLayerBackdrop(), maxScale = 0.05f,
modifier = Modifier.fillMaxWidth(), surfaceColor = MaterialTheme.colorScheme.primary
maxScale = 0.05f, ) {
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) Text(
) { stringResource(R.string.unlock_advanced_features),
Text( style = MaterialTheme.typography.bodyMedium,
stringResource(R.string.unlock_advanced_features), color = MaterialTheme.colorScheme.onPrimary
style = TextStyle( )
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily(Font(R.font.sf_pro)),
color = Color.White
),
)
}
Spacer(modifier = Modifier.height(8.dp))
} }
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)) 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( StyledToggle(
onClick = { label = "Head Gestures",
gestureText = gestureTextValue checked = state.headGesturesEnabled,
coroutineScope.launch { onCheckedChange = { viewModel.setHeadGesturesEnabled(it) },
val accepted = ServiceManager.getService()?.testHeadGestures() ?: false enabled = state.isPremium || state.headGesturesEnabled,
gestureText = if (accepted) "\"Yes\" gesture detected." else "\"No\" gesture detected." description = stringResource(R.string.head_gestures_details),
} header = true
}, )
backdrop = backdrop,
modifier = Modifier.fillMaxWidth(0.75f), Spacer(modifier = Modifier.height(16.dp))
maxScale = 0.05f
) { Spacer(modifier = Modifier.height(16.dp))
Text( Text(
"Test Head Gestures", "Velocity",
style = TextStyle( style = TextStyle(
fontSize = 16.sp, fontSize = 14.sp,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Bold,
fontFamily = FontFamily(Font(R.font.sf_pro)), color = textColor.copy(alpha = 0.6f),
color = textColor fontFamily = FontFamily(Font(R.font.sf_pro))
), ),
) modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
} )
Box( Plot()
contentAlignment = Alignment.Center,
modifier = Modifier.padding(top = 12.dp, bottom = 24.dp) Spacer(modifier = Modifier.height(16.dp))
) {
AnimatedContent( LaunchedEffect(gestureText) {
targetState = gestureText, if (gestureText.isNotEmpty()) {
transitionSpec = { lastClickTime = System.currentTimeMillis()
(fadeIn( delay(3000)
animationSpec = tween(300) if (System.currentTimeMillis() - lastClickTime >= 3000) {
) + slideInVertically( shouldExplode = true
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()
)
} }
} }
} }
} }
} val gestureTextValue = stringResource(R.string.shake_your_head_or_nod)
} StyledButton(
private data class Particle( onClick = {
val initialPosition: Offset, gestureText = gestureTextValue
val velocity: Offset, coroutineScope.launch {
var alpha: Float = 1f val accepted = ServiceManager.getService()?.testHeadGestures() ?: false
) gestureText = if (accepted) "\"Yes\" gesture detected." else "\"No\" gesture detected."
}
@Composable },
private fun ParticleText( backdrop = backdrop,
text: String, modifier = Modifier
style: TextStyle, .fillMaxWidth()
onAnimationComplete: () -> Unit, .padding(horizontal = 16.dp),
) { maxScale = 0.05f
val particles = remember { mutableStateListOf<Particle>() } ) {
val textMeasurer = rememberTextMeasurer() Text(
var isAnimating by remember { mutableStateOf(true) } "Test Head Gestures",
var textVisible by remember { mutableStateOf(true) } style = TextStyle(
fontSize = 16.sp,
Canvas(modifier = Modifier.fillMaxWidth()) { fontWeight = FontWeight.Medium,
val textLayoutResult = textMeasurer.measure(text, style) fontFamily = FontFamily(Font(R.font.sf_pro)),
val textBounds = textLayoutResult.size color = MaterialTheme.colorScheme.onSecondaryContainer
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)
) )
} }
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( Box(
modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center,
contentAlignment = Alignment.Center modifier = Modifier.padding(top = 12.dp, bottom = 24.dp)
) { ) {
Canvas( AnimatedContent(
modifier = Modifier targetState = gestureText,
.fillMaxSize() transitionSpec = {
.padding(16.dp) (fadeIn(
) { animationSpec = tween(300)
val width = size.width ) + slideInVertically(
val height = size.height initialOffsetY = { 40 },
val center = Offset(width / 2, height / 2) animationSpec = tween(300)
val faceRadius = height * 0.35f )).togetherWith(fadeOut(animationSpec = tween(150)))
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<Float, Float, Float>): Triple<Float, Float, Float> {
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)
} }
) { text ->
fun project(point: Triple<Float, Float, Float>): Pair<Float, Float> { if (shouldExplode) {
val (x, y, z) = point LaunchedEffect(Unit) {
val scale = 1f + (z / width) CoroutineScope(coroutineScope.coroutineContext).launch {
return Pair(center.x + x * scale, center.y + y * scale) delay(750)
} gestureText = ""
}
val earWidth = height * 0.08f }
val earHeight = height * 0.2f Text(
val earOffsetX = height * 0.4f text = text,
val earOffsetY = 0f style = TextStyle(
val earZ = 0f fontSize = 20.sp,
fontWeight = FontWeight.Medium,
for (xSign in listOf(-1f, 1f)) { fontFamily = FontFamily(Font(R.font.sf_pro)),
val rotated = rotate3D(Triple(earOffsetX * xSign, earOffsetY, earZ)) textAlign = TextAlign.Center
val (earX, earY) = project(rotated) ),
drawRoundRect( color = MaterialTheme.colorScheme.onBackground
color = strokeColor,
topLeft = Offset(earX - earWidth/2, earY - earHeight/2),
size = Size(earWidth, earHeight),
cornerRadius = CornerRadius(earWidth/2),
style = Stroke(width = 4.dp.toPx())
) )
} } else {
Text(
val spherePath = Path() text = text,
val firstPoint = project(rotate3D(Triple(faceRadius, 0f, 0f))) style = TextStyle(
spherePath.moveTo(firstPoint.first, firstPoint.second) fontSize = 20.sp,
fontWeight = FontWeight.Medium,
for (i in 1..32) { fontFamily = FontFamily(Font(R.font.sf_pro)),
val angle = (i * 2 * Math.PI / 32).toFloat() color = textColor,
val point = project(rotate3D(Triple( textAlign = TextAlign.Center
cos(angle) * faceRadius, ),
sin(angle) * faceRadius, modifier = Modifier
0f .fillMaxWidth()
)))
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
) )
} }
} }
} }
Spacer(modifier = Modifier.height(bottomPadding))
} }
} }
@Composable @Composable
private fun AccelerationPlot() { private fun Plot() {
val acceleration by HeadTracking.acceleration.collectAsState() val acceleration by HeadTracking.acceleration.collectAsState()
val maxPoints = 100 val maxPoints = 100
val points = remember { mutableStateListOf<Pair<Float, Float>>() } val points = remember { mutableStateListOf<Pair<Float, Float>>() }
@@ -649,11 +294,12 @@ private fun AccelerationPlot() {
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(300.dp), .height(300.dp),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
containerColor = if (darkTheme) Color(0xFF1C1C1E) else Color.White
),
shape = RoundedCornerShape(28.dp) shape = RoundedCornerShape(28.dp)
) { ) {
val horizontalColor = MaterialTheme.colorScheme.primary
val verticalColor = MaterialTheme.colorScheme.onPrimary
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -704,14 +350,14 @@ private fun AccelerationPlot() {
val x2 = (i + 1) * xScale val x2 = (i + 1) * xScale
drawLine( drawLine(
color = Color(0xFF007AFF), color = horizontalColor,
start = Offset(x1, zeroY - points[i].first * yScale), start = Offset(x1, zeroY - points[i].first * yScale),
end = Offset(x2, zeroY - points[i + 1].first * yScale), end = Offset(x2, zeroY - points[i + 1].first * yScale),
strokeWidth = 2.dp.toPx() strokeWidth = 2.dp.toPx()
) )
drawLine( drawLine(
color = Color(0xFFFF3B30), color = verticalColor,
start = Offset(x1, zeroY - points[i].second * yScale), start = Offset(x1, zeroY - points[i].second * yScale),
end = Offset(x2, zeroY - points[i + 1].second * yScale), end = Offset(x2, zeroY - points[i + 1].second * yScale),
strokeWidth = 2.dp.toPx() strokeWidth = 2.dp.toPx()
@@ -734,7 +380,7 @@ private fun AccelerationPlot() {
val legendY = 15.dp.toPx() val legendY = 15.dp.toPx()
val textOffsetY = legendY + 5.dp.toPx() / 2 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 { drawContext.canvas.nativeCanvas.apply {
val paint = Paint().apply { val paint = Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK 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) 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 { drawContext.canvas.nativeCanvas.apply {
val paint = Paint().apply { val paint = Paint().apply {
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
@@ -20,16 +20,21 @@ package me.kavishdevar.librepods.presentation.screens
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer 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.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -41,10 +46,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp 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 dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import me.kavishdevar.librepods.R 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.HearingAidSettings
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.data.sendHearingAidSettings 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.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle 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.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -65,11 +67,8 @@ private const val TAG = "HearingAidAdjustments"
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) { fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
isSystemInDarkTheme()
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
val hazeState = remember { HazeState() }
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop()
val debounceJob = remember { mutableStateOf<Job?>(null) } val debounceJob = remember { mutableStateOf<Job?>(null) }
@@ -144,81 +143,83 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::setATTCharacteristicValue) sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::setATTCharacteristicValue)
} }
StyledScaffold(title = stringResource(R.string.adjustments)) { spacerHeight -> val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
Column( val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
modifier = Modifier val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
.hazeSource(hazeState)
.fillMaxSize()
.layerBackdrop(backdrop)
.verticalScroll(verticalScrollState)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
StyledSlider( Column(
label = stringResource(R.string.amplification), modifier = Modifier
valueRange = -1f..1f, .fillMaxSize()
value = amplificationSliderValue.floatValue, .background(MaterialTheme.colorScheme.surfaceContainer)
onValueChange = { .verticalScroll(verticalScrollState)
amplificationSliderValue.floatValue = it .padding(horizontal = 16.dp),
}, verticalArrangement = Arrangement.spacedBy(16.dp)
startIcon = "􀊥", ) {
endIcon = "􀊩", Spacer(modifier = Modifier.height(topPadding))
independent = true,
)
StyledToggle( StyledSlider(
label = stringResource(R.string.swipe_to_control_amplification), label = stringResource(R.string.amplification),
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(), valueRange = -1f..1f,
onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE, it) }, value = amplificationSliderValue.floatValue,
description = stringResource(R.string.swipe_amplification_description) onValueChange = {
) amplificationSliderValue.floatValue = it
},
startIcon = "􀊥",
endIcon = "􀊩",
independent = true,
)
StyledSlider( StyledToggle(
label = stringResource(R.string.balance), label = stringResource(R.string.swipe_to_control_amplification),
valueRange = -1f..1f, checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),
value = balanceSliderValue.floatValue, onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE, it) },
onValueChange = { description = stringResource(R.string.swipe_amplification_description)
balanceSliderValue.floatValue = it )
},
snapPoints = listOf(-1f, 0f, 1f),
startLabel = stringResource(R.string.left),
endLabel = stringResource(R.string.right),
independent = true,
)
StyledSlider( StyledSlider(
label = stringResource(R.string.tone), label = stringResource(R.string.balance),
valueRange = -1f..1f, valueRange = -1f..1f,
value = toneSliderValue.floatValue, value = balanceSliderValue.floatValue,
onValueChange = { onValueChange = {
toneSliderValue.floatValue = it balanceSliderValue.floatValue = it
}, },
startLabel = stringResource(R.string.darker), snapPoints = listOf(-1f, 0f, 1f),
endLabel = stringResource(R.string.brighter), startLabel = stringResource(R.string.left),
independent = true, endLabel = stringResource(R.string.right),
) independent = true,
)
StyledSlider( StyledSlider(
label = stringResource(R.string.ambient_noise_reduction), label = stringResource(R.string.tone),
valueRange = 0f..1f, valueRange = -1f..1f,
value = ambientNoiseReductionSliderValue.floatValue, value = toneSliderValue.floatValue,
onValueChange = { onValueChange = {
ambientNoiseReductionSliderValue.floatValue = it toneSliderValue.floatValue = it
}, },
startLabel = stringResource(R.string.less), startLabel = stringResource(R.string.darker),
endLabel = stringResource(R.string.more), endLabel = stringResource(R.string.brighter),
independent = true, independent = true,
) )
StyledToggle( StyledSlider(
label = stringResource(R.string.conversation_boost), label = stringResource(R.string.ambient_noise_reduction),
checked = conversationBoostEnabled.value, valueRange = 0f..1f,
onCheckedChange = { conversationBoostEnabled.value = it }, value = ambientNoiseReductionSliderValue.floatValue,
independent = true, onValueChange = {
description = stringResource(R.string.conversation_boost_description) 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))
} }
} }
@@ -25,16 +25,17 @@ import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -43,7 +44,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers 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.parseTransparencySettingsResponse
import me.kavishdevar.librepods.data.sendTransparencySettings import me.kavishdevar.librepods.data.sendTransparencySettings
import me.kavishdevar.librepods.presentation.components.ConfirmationDialog import me.kavishdevar.librepods.presentation.components.ConfirmationDialog
import me.kavishdevar.librepods.presentation.components.NavigationButton import me.kavishdevar.librepods.presentation.components.StyledList
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.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.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -78,14 +77,11 @@ private const val TAG = "AccessibilitySettings"
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController) { fun HearingAidScreen(viewModel: AirPodsViewModel, onNavigateHearingAidAdjustments: () -> Unit, onNavigateHearingTest: () -> Unit) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val verticalScrollState = rememberScrollState() val verticalScrollState = rememberScrollState()
val snackbarHostState = remember { SnackbarHostState() } val backdrop = rememberLayerBackdrop()
val showDialog = remember { mutableStateOf(false) } val showDialog = remember { mutableStateOf(false) }
val backdrop = rememberLayerBackdrop()
val initialLoad = remember { mutableStateOf(true) } val initialLoad = remember { mutableStateOf(true) }
val state by viewModel.uiState.collectAsState() 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())) 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( val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
title = stringResource(R.string.hearing_aid), val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
snackbarHostState = snackbarHostState, val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
) { topPadding, hazeState, bottomPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.layerBackdrop(backdrop) .layerBackdrop(backdrop)
.hazeSource(hazeState) .fillMaxSize()
.fillMaxSize() .background(MaterialTheme.colorScheme.surfaceContainer)
.verticalScroll(verticalScrollState) .verticalScroll(verticalScrollState)
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
hazeStateS.value = hazeState Spacer(modifier = Modifier.height(topPadding))
Spacer(modifier = Modifier.height(topPadding))
// val mediaAssistEnabled = remember { mutableStateOf(false) } // val mediaAssistEnabled = remember { mutableStateOf(false) }
// val adjustMediaEnabled = remember { mutableStateOf(false) } // val adjustMediaEnabled = remember { mutableStateOf(false) }
// val adjustPhoneEnabled = remember { mutableStateOf(false) } // val adjustPhoneEnabled = remember { mutableStateOf(false) }
LaunchedEffect(hearingAidEnabled.value) { LaunchedEffect(hearingAidEnabled.value) {
if (hearingAidEnabled.value && !initialLoad.value) { if (hearingAidEnabled.value && !initialLoad.value) {
showDialog.value = true showDialog.value = true
} else if (!hearingAidEnabled.value && !initialLoad.value) { } else if (!hearingAidEnabled.value && !initialLoad.value) {
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x02)) viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x02))
viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x02.toByte()) viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x02.toByte())
hearingAidEnabled.value = false hearingAidEnabled.value = false
}
initialLoad.value = false
} }
initialLoad.value = false
}
// fun onAdjustPhoneChange(value: Boolean) { // fun onAdjustPhoneChange(value: Boolean) {
// // TODO // // TODO
@@ -137,105 +131,75 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
// // TODO // // TODO
// } // }
Text( StyledList (title = stringResource(R.string.hearing_aid)) {
text = stringResource(R.string.hearing_aid), StyledToggle(
style = TextStyle( label = stringResource(R.string.hearing_aid),
fontSize = 14.sp, checked = hearingAidEnabled.value,
fontWeight = FontWeight.Bold, onCheckedChange = { hearingAidEnabled.value = it },
color = textColor.copy(alpha = 0.6f),
fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(16.dp, bottom = 2.dp)
) )
StyledListItem(
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) name = stringResource(R.string.adjustments),
onClick = onNavigateHearingAidAdjustments,
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)
) )
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( ConfirmationDialog(
showDialog = showDialog, showDialog = showDialog,
title = "Enable Hearing Aid", title = "Enable Hearing Aid",
@@ -274,7 +238,6 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
hearingAidEnabled.value = false hearingAidEnabled.value = false
showDialog.value = false showDialog.value = false
}, },
// hazeState = hazeStateS.value,
backdrop = backdrop backdrop = backdrop
) )
} }
@@ -18,103 +18,101 @@
package me.kavishdevar.librepods.presentation.screens 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.Column
import androidx.compose.foundation.layout.Spacer 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding 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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource 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.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.ATTHandles import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.presentation.components.StyledButton 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.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.presentation.viewmodel.AirPodsViewModel
@Composable @Composable
fun HearingProtectionScreen(viewModel: AirPodsViewModel, navController: NavController) { fun HearingProtectionScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) {
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
val state by viewModel.uiState.collectAsState() 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( StyledToggle(
title = stringResource(R.string.workspace_use), title = stringResource(R.string.environmental_noise),
label = stringResource(R.string.ppe), label = stringResource(R.string.loud_sound_reduction),
description = stringResource(R.string.workspace_use_description), description = stringResource(R.string.loud_sound_reduction_description),
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG]?.getOrNull( checked = state.loudSoundReductionEnabled,
0
)?.toInt() == 1,
onCheckedChange = { onCheckedChange = {
viewModel.setControlCommandBoolean( viewModel.setATTCharacteristicValue(
AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, it ATTHandles.LOUD_SOUND_REDUCTION,
byteArrayOf(if (it) 1.toByte() else 0.toByte())
) )
}, },
enabled = state.isPremium enabled = state.isPremium
) )
Spacer(modifier = Modifier.height(12.dp))
} }
StyledToggle(
title = stringResource(R.string.workspace_use),
label = stringResource(R.string.ppe),
description = stringResource(R.string.workspace_use_description),
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG]?.getOrNull(
0
)?.toInt() == 1,
onCheckedChange = {
viewModel.setControlCommandBoolean(
AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, it
)
},
enabled = state.isPremium
)
Spacer(modifier = Modifier.height(bottomPadding))
} }
} }
@@ -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()
}
}
@@ -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))
}
}
@@ -19,21 +19,24 @@
package me.kavishdevar.librepods.presentation.screens package me.kavishdevar.librepods.presentation.screens
import android.annotation.SuppressLint 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer 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.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer 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 dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import me.kavishdevar.librepods.R 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 import kotlin.io.encoding.ExperimentalEncodingApi
private var debounceJob: Job? = null private var debounceJob: Job? = null
@@ -50,33 +54,34 @@ private var debounceJob: Job? = null
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
@Composable @Composable
fun OpenSourceLicensesScreen(navController: NavController) { fun OpenSourceLicensesScreen() {
val isDarkTheme = isSystemInDarkTheme()
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
title = stringResource(R.string.open_source_licenses) val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
) { spacerHeight -> val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier Column(
.fillMaxSize() modifier = Modifier
.layerBackdrop(backdrop) .fillMaxSize()
.padding(horizontal = 16.dp), .layerBackdrop(backdrop)
verticalArrangement = Arrangement.spacedBy(16.dp) .background(MaterialTheme.colorScheme.surfaceContainer)
) { .padding(horizontal = 16.dp),
Spacer(modifier = Modifier.height(spacerHeight)) verticalArrangement = Arrangement.spacedBy(16.dp)
val context = LocalContext.current ) {
val libraries by produceLibraries { Spacer(modifier = Modifier.height(topPadding))
context.resources.openRawResource(R.raw.aboutlibraries) val context = LocalContext.current
.bufferedReader() val libraries by produceLibraries {
.use { it.readText() } context.resources.openRawResource(R.raw.aboutlibraries)
} .bufferedReader()
LibrariesContainer( .use { it.readText() }
libraries = libraries,
modifier = Modifier
.padding(0.dp)
.fillMaxSize()
)
} }
LibrariesContainer(
libraries = libraries,
modifier = Modifier
.padding(0.dp)
.fillMaxSize()
)
Spacer(modifier = Modifier.height(bottomPadding))
} }
} }
@@ -21,38 +21,42 @@
package me.kavishdevar.librepods.presentation.screens package me.kavishdevar.librepods.presentation.screens
import android.util.Log 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.Column
import androidx.compose.foundation.layout.Spacer 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding 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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier 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.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.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.data.StemAction 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.StyledButton
import me.kavishdevar.librepods.presentation.components.StyledScaffold import me.kavishdevar.librepods.presentation.components.StyledList
import me.kavishdevar.librepods.presentation.components.StyledSelectList 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 me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.experimental.and import kotlin.experimental.and
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -60,10 +64,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi
@ExperimentalHazeMaterialsApi @ExperimentalHazeMaterialsApi
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LongPress(viewModel: AirPodsViewModel, name: String, navController: NavController) { fun LongPress(viewModel: AirPodsViewModel, name: String, navigateToPurchase: () -> Unit) {
val isDarkTheme = isSystemInDarkTheme()
val textColor = if (isDarkTheme) Color.White else Color.Black
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val modesByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0) ?: 0 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()}") Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
val longPressAction = if (name.lowercase() == "left") state.leftAction else state.rightAction val longPressAction = if (name.lowercase() == "left") state.leftAction else state.rightAction
val backdrop = rememberLayerBackdrop()
StyledScaffold( val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
title = name val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
) { spacerHeight -> val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column (
modifier = Modifier val scrollState = rememberScrollState()
.layerBackdrop(backdrop)
.fillMaxSize() Column (
.padding(top = 8.dp) modifier = Modifier
.padding(horizontal = 16.dp) .fillMaxSize()
) { .background(MaterialTheme.colorScheme.surfaceContainer)
Spacer(modifier = Modifier.height(spacerHeight)) .verticalScroll(scrollState)
val actionItems = listOf( .padding(top = 8.dp)
SelectItem( .padding(horizontal = 16.dp)
name = stringResource(R.string.noise_control), ) {
selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES, Spacer(modifier = Modifier.height(topPadding))
onClick = {
viewModel.setLongPressAction(name, StemAction.CYCLE_NOISE_CONTROL_MODES) StyledList {
} StyledListItem(
), name = stringResource(R.string.noise_control),
SelectItem( selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES,
name = stringResource(R.string.digital_assistant), onClick = {
selected = longPressAction == StemAction.DIGITAL_ASSISTANT, viewModel.setLongPressAction(
onClick = { name,
viewModel.setLongPressAction(name, StemAction.DIGITAL_ASSISTANT) StemAction.CYCLE_NOISE_CONTROL_MODES
}, )
enabled = state.isPremium }
)
) )
StyledSelectList(items = actionItems)
if (!state.isPremium) { StyledListItem(
Spacer(modifier = Modifier.height(24.dp)) name = stringResource(R.string.digital_assistant),
StyledButton( selected = longPressAction == StemAction.DIGITAL_ASSISTANT,
onClick = { onClick = {
navController.navigate("purchase_screen") viewModel.setLongPressAction(
}, name,
backdrop = rememberLayerBackdrop(), StemAction.DIGITAL_ASSISTANT
modifier = Modifier.fillMaxWidth(), )
maxScale = 0.05f, },
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900) enabled = state.isPremium
) { )
Text( }
stringResource(R.string.unlock_advanced_features),
style = TextStyle( if (!state.isPremium) {
fontSize = 16.sp, Spacer(modifier = Modifier.height(24.dp))
fontWeight = FontWeight.Medium, StyledButton(
fontFamily = FontFamily(Font(R.font.sf_pro)), onClick = navigateToPurchase,
color = Color.White 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) { StyledListItem(
Spacer(modifier = Modifier.height(32.dp)) name = stringResource(R.string.transparency),
Text( description = stringResource(R.string.listening_mode_transparency_description),
text = stringResource(R.string.noise_control), selected = (currentByte and 0x04) != 0,
style = TextStyle( onClick = {
fontSize = 14.sp, viewModel.toggleListeningMode(0x04)
fontWeight = FontWeight.Bold, },
color = textColor.copy(alpha = 0.6f), orientation = ListItemOrientation.Vertical,
fontFamily = FontFamily(Font(R.font.sf_pro)) leadingContent = {
), Icon(
fontFamily = FontFamily(Font(R.font.sf_pro)), painter = painterResource(R.drawable.transparency),
modifier = Modifier contentDescription = "Icon",
.padding(horizontal = 18.dp) tint = MaterialTheme.colorScheme.primary,
modifier = Modifier
.height(42.dp)
.wrapContentWidth()
)
}
) )
Spacer(modifier = Modifier.height(8.dp)) StyledListItem(
name = stringResource(R.string.adaptive),
val currentByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0)?.toInt() ?: 0 description = stringResource(R.string.listening_mode_adaptive_description),
selected = (currentByte and 0x08) != 0,
val listeningModeItems = mutableListOf<SelectItem>() onClick = {
if (state.offListeningMode) { viewModel.toggleListeningMode(0x08)
listeningModeItems.add( },
SelectItem( orientation = ListItemOrientation.Vertical,
name = stringResource(R.string.off), leadingContent = {
description = stringResource(R.string.listening_mode_off_description), Icon(
iconRes = R.drawable.noise_cancellation, painter = painterResource(R.drawable.adaptive),
selected = (currentByte and 0x01) != 0, contentDescription = "Icon",
onClick = { tint = MaterialTheme.colorScheme.primary,
viewModel.toggleListeningMode(0x01) modifier = Modifier
} .height(42.dp)
.wrapContentWidth()
) )
) }
} )
listeningModeItems.addAll(listOf(
SelectItem( StyledListItem(
name = stringResource(R.string.transparency), name = stringResource(R.string.noise_cancellation),
description = stringResource(R.string.listening_mode_transparency_description), description = stringResource(R.string.listening_mode_noise_cancellation_description),
iconRes = R.drawable.transparency, selected = (currentByte and 0x02) != 0,
selected = (currentByte and 0x04) != 0, onClick = {
onClick = { viewModel.toggleListeningMode(0x02)
viewModel.toggleListeningMode(0x04) },
} orientation = ListItemOrientation.Vertical,
), leadingContent = {
SelectItem( Icon(
name = stringResource(R.string.adaptive), painter = painterResource(R.drawable.noise_cancellation),
description = stringResource(R.string.listening_mode_adaptive_description), contentDescription = "Icon",
iconRes = R.drawable.adaptive, tint = MaterialTheme.colorScheme.primary,
selected = (currentByte and 0x08) != 0, modifier = Modifier
onClick = { .height(42.dp)
viewModel.toggleListeningMode(0x08) .wrapContentWidth()
} )
), }
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)
) )
} }
} }
Spacer(modifier = Modifier.height(bottomPadding))
} }
} }
@@ -19,510 +19,199 @@
package me.kavishdevar.librepods.presentation.screens package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background 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.Column
import androidx.compose.foundation.layout.Spacer 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.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource 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.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.kyant.backdrop.backdrops.layerBackdrop import com.kyant.backdrop.backdrops.layerBackdrop
import com.kyant.backdrop.backdrops.rememberLayerBackdrop import com.kyant.backdrop.backdrops.rememberLayerBackdrop
import dev.chrisbanes.haze.hazeSource
import me.kavishdevar.librepods.R 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.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.presentation.viewmodel.PurchaseViewModel
import me.kavishdevar.librepods.utils.XposedState import me.kavishdevar.librepods.utils.XposedState
@Composable @Composable
fun PurchaseScreen( fun PurchaseScreen(
viewModel: PurchaseViewModel = viewModel(), viewModel: PurchaseViewModel = viewModel(),
navController: NavController backStack: SnapshotStateList<Screen>
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop() val backdrop = rememberLayerBackdrop()
StyledScaffold( val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
title = stringResource(R.string.unlock_advanced_features) val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
) { topPadding, hazeState, bottomPadding -> val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.fillMaxSize()
.layerBackdrop(backdrop)
.hazeSource(state = hazeState)
.verticalScroll(scrollState)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
val isDarkTheme = isSystemInDarkTheme() Column(
val backgroundColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7) modifier = Modifier
val cardBackgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF) .layerBackdrop(backdrop)
val textColor = if (isDarkTheme) Color.White else Color.Black .verticalScroll(scrollState)
LaunchedEffect(state.isPremium) { .background(MaterialTheme.colorScheme.surfaceContainer)
if (state.isPremium) { .padding(horizontal = 16.dp)
navController.popBackStack() ) {
Spacer(modifier = Modifier.height(topPadding))
LaunchedEffect(state.isPremium) {
if (state.isPremium) {
if (backStack.size > 1) {
backStack.removeAt(backStack.lastIndex)
} }
} }
if (!state.isPremium) { }
Box( if (!state.isPremium) {
modifier = Modifier StyledList(title = stringResource(R.string.free_features)) {
.background(backgroundColor) StyledListItem(
.padding(horizontal = 16.dp, vertical = 4.dp) name = stringResource(R.string.ear_detection),
) { description = stringResource(R.string.ear_detection_description),
Text( enabled = false,
text = stringResource(R.string.free_features), orientation = ListItemOrientation.Vertical
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
),
) )
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( if (XposedState.isAvailable) {
onClick = { StyledListItem(
viewModel.purchase(context) name = "${stringResource(R.string.hearing_aid)} (${stringResource(R.string.requires_xposed)})",
}, description = stringResource(R.string.hearing_aid_description)
backdrop = rememberLayerBackdrop(), .substringBefore("\n\n"),
modifier = Modifier.fillMaxWidth(), enabled = false,
maxScale = 0.05f, orientation = ListItemOrientation.Vertical
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
),
) )
} }
} }
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))
} }
} }
@@ -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<UpdateItem>,
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()
}
}
@@ -21,13 +21,19 @@
package me.kavishdevar.librepods.presentation.screens package me.kavishdevar.librepods.presentation.screens
import android.content.Context import android.content.Context
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer 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.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -35,13 +41,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.edit import androidx.core.content.edit
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.presentation.components.StyledInputField 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 me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -58,28 +63,32 @@ fun RenameScreen(viewModel: AirPodsViewModel) {
keyboardController?.show() keyboardController?.show()
} }
StyledScaffold( val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
title = stringResource(R.string.name), val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
) { spacerHeight -> val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(spacerHeight))
val name = sharedPreferences.getString("name", "")?: "" Column(
val textFieldState = rememberTextFieldState(initialText = name) modifier = Modifier
.fillMaxSize()
LaunchedEffect(textFieldState.text) { .background(MaterialTheme.colorScheme.surfaceContainer)
sharedPreferences.edit {putString("name", textFieldState.text as String?)} .padding(horizontal = 16.dp)
viewModel.setName(textFieldState.text.toString()) ) {
} Spacer(modifier = Modifier.height(topPadding))
StyledInputField( val name = sharedPreferences.getString("name", "")?: ""
textFieldState, val textFieldState = rememberTextFieldState(initialText = name)
focusRequester
) LaunchedEffect(textFieldState.text) {
sharedPreferences.edit {putString("name", textFieldState.text as String?)}
viewModel.setName(textFieldState.text.toString())
} }
StyledInputField(
textFieldState,
focusRequester
)
Spacer(modifier = Modifier.height(bottomPadding))
} }
} }
@@ -28,16 +28,21 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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 dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.data.TransparencySettings import me.kavishdevar.librepods.data.TransparencySettings
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
import me.kavishdevar.librepods.data.sendTransparencySettings 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.StyledSlider
import me.kavishdevar.librepods.presentation.components.StyledToggle 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.presentation.viewmodel.AirPodsViewModel
import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.io.encoding.ExperimentalEncodingApi
@@ -90,83 +93,52 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5) val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF) val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
val backdrop = rememberLayerBackdrop()
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
StyledScaffold( val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
title = stringResource(R.string.customize_transparency_mode) val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
){ topPadding, hazeState, bottomPadding -> val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
Column(
modifier = Modifier
.hazeSource(hazeState)
.layerBackdrop(backdrop)
.fillMaxSize()
.verticalScroll(verticalScrollState)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(topPadding))
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
val enabled = rememberSaveable { mutableStateOf(false) } Column(
val amplificationSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) } modifier = Modifier
val balanceSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) } .fillMaxSize()
val toneSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) } .background(MaterialTheme.colorScheme.surfaceContainer)
val ambientNoiseReductionSliderValue = rememberSaveable { mutableFloatStateOf(0.0f) } .verticalScroll(verticalScrollState)
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) } .padding(horizontal = 16.dp),
val eq = rememberSaveable( verticalArrangement = Arrangement.spacedBy(16.dp)
saver = Saver( ) {
save = { it.value.toList() }, Spacer(modifier = Modifier.height(topPadding))
restore = { mutableStateOf(it.toFloatArray()) } val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
)
) { mutableStateOf(FloatArray(8)) }
val phoneMediaEQ = rememberSaveable(
saver = Saver(
save = { it.value.toList() },
restore = { mutableStateOf(it.toFloatArray()) }
)
) { mutableStateOf(FloatArray(8) { 0.5f }) }
val initialized = rememberSaveable { mutableStateOf(false) } val 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 { val initialized = rememberSaveable { mutableStateOf(false) }
mutableStateOf(
TransparencySettings(
enabled = enabled.value,
leftEQ = eq.value,
rightEQ = eq.value,
leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2,
rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2,
leftTone = toneSliderValue.floatValue,
rightTone = toneSliderValue.floatValue,
leftConversationBoost = conversationBoostEnabled.value,
rightConversationBoost = conversationBoostEnabled.value,
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
netAmplification = amplificationSliderValue.floatValue,
balance = balanceSliderValue.floatValue
)
)
}
LaunchedEffect( val transparencySettings = remember {
enabled.value, mutableStateOf(
amplificationSliderValue.floatValue, TransparencySettings(
balanceSliderValue.floatValue,
toneSliderValue.floatValue,
conversationBoostEnabled.value,
ambientNoiseReductionSliderValue.floatValue,
eq.value
) {
if (!initialized.value) return@LaunchedEffect
transparencySettings.value = TransparencySettings(
enabled = enabled.value, enabled = enabled.value,
leftEQ = eq.value, leftEQ = eq.value,
rightEQ = eq.value, rightEQ = eq.value,
leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f, leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2,
rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f, rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2,
leftTone = toneSliderValue.floatValue, leftTone = toneSliderValue.floatValue,
rightTone = toneSliderValue.floatValue, rightTone = toneSliderValue.floatValue,
leftConversationBoost = conversationBoostEnabled.value, leftConversationBoost = conversationBoostEnabled.value,
@@ -176,193 +148,218 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
netAmplification = amplificationSliderValue.floatValue, netAmplification = amplificationSliderValue.floatValue,
balance = balanceSliderValue.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) { if (state.vendorIdHook) {
val parsedSettings = parseTransparencySettingsResponse(data = state.transparencyData) ?: return@LaunchedEffect StyledToggle(
Log.d(TAG, "Initial transparency settings: $parsedSettings") label = stringResource(R.string.transparency_mode),
enabled.value = parsedSettings.enabled checked = enabled.value,
amplificationSliderValue.floatValue = parsedSettings.netAmplification description = stringResource(R.string.customize_transparency_mode_description),
balanceSliderValue.floatValue = parsedSettings.balance onCheckedChange = { enabled.value = it }
toneSliderValue.floatValue = parsedSettings.leftTone )
ambientNoiseReductionSliderValue.floatValue = Spacer(modifier = Modifier.height(4.dp))
parsedSettings.leftAmbientNoiseReduction StyledSlider(
conversationBoostEnabled.value = parsedSettings.leftConversationBoost label = stringResource(R.string.amplification),
if (!eq.value.contentEquals(parsedSettings.leftEQ)) { valueRange = -1f..1f,
eq.value = parsedSettings.leftEQ.copyOf() value = amplificationSliderValue.floatValue,
} onValueChange = {
initialized.value = true amplificationSliderValue.floatValue = it
} },
startIcon = "􀊥",
endIcon = "􀊩",
independent = true
)
if (state.vendorIdHook) { StyledSlider(
StyledToggle( label = stringResource(R.string.balance),
label = stringResource(R.string.transparency_mode), valueRange = -1f..1f,
checked = enabled.value, value = balanceSliderValue.floatValue,
independent = true, onValueChange = {
description = stringResource(R.string.customize_transparency_mode_description), balanceSliderValue.floatValue = it
onCheckedChange = { enabled.value = it } },
) snapPoints = listOf(-1f, 0f, 1f),
Spacer(modifier = Modifier.height(4.dp)) startLabel = stringResource(R.string.left),
StyledSlider( endLabel = stringResource(R.string.right),
label = stringResource(R.string.amplification), independent = true,
valueRange = -1f..1f, )
value = amplificationSliderValue.floatValue,
onValueChange = {
amplificationSliderValue.floatValue = it
},
startIcon = "􀊥",
endIcon = "􀊩",
independent = true
)
StyledSlider( StyledSlider(
label = stringResource(R.string.balance), label = stringResource(R.string.tone),
valueRange = -1f..1f, valueRange = -1f..1f,
value = balanceSliderValue.floatValue, value = toneSliderValue.floatValue,
onValueChange = { onValueChange = {
balanceSliderValue.floatValue = it toneSliderValue.floatValue = it
}, },
snapPoints = listOf(-1f, 0f, 1f), startLabel = stringResource(R.string.darker),
startLabel = stringResource(R.string.left), endLabel = stringResource(R.string.brighter),
endLabel = stringResource(R.string.right), independent = true,
independent = true, )
)
StyledSlider( StyledSlider(
label = stringResource(R.string.tone), label = stringResource(R.string.ambient_noise_reduction),
valueRange = -1f..1f, valueRange = 0f..1f,
value = toneSliderValue.floatValue, value = ambientNoiseReductionSliderValue.floatValue,
onValueChange = { onValueChange = {
toneSliderValue.floatValue = it ambientNoiseReductionSliderValue.floatValue = it
}, },
startLabel = stringResource(R.string.darker), startLabel = stringResource(R.string.less),
endLabel = stringResource(R.string.brighter), endLabel = stringResource(R.string.more),
independent = true, independent = true,
) )
StyledSlider( StyledToggle(
label = stringResource(R.string.ambient_noise_reduction), label = stringResource(R.string.conversation_boost),
valueRange = 0f..1f, checked = conversationBoostEnabled.value,
value = ambientNoiseReductionSliderValue.floatValue, description = stringResource(R.string.conversation_boost_description),
onValueChange = { onCheckedChange = { conversationBoostEnabled.value = it }
ambientNoiseReductionSliderValue.floatValue = it )
},
startLabel = stringResource(R.string.less),
endLabel = stringResource(R.string.more),
independent = true,
)
StyledToggle( Text(
label = stringResource(R.string.conversation_boost), text = stringResource(R.string.equalizer),
checked = conversationBoostEnabled.value, style = TextStyle(
independent = true, fontSize = 14.sp,
description = stringResource(R.string.conversation_boost_description), fontWeight = FontWeight.Bold,
onCheckedChange = { conversationBoostEnabled.value = it } color = textColor.copy(alpha = 0.6f),
) fontFamily = FontFamily(Font(R.font.sf_pro))
),
modifier = Modifier.padding(16.dp, bottom = 4.dp)
)
Text( Column(
text = stringResource(R.string.equalizer), modifier = Modifier
style = TextStyle( .fillMaxWidth()
fontSize = 14.sp, .background(backgroundColor, RoundedCornerShape(28.dp))
fontWeight = FontWeight.Bold, .padding(16.dp),
color = textColor.copy(alpha = 0.6f), horizontalAlignment = Alignment.CenterHorizontally,
fontFamily = FontFamily(Font(R.font.sf_pro)) verticalArrangement = Arrangement.SpaceBetween
), ) {
modifier = Modifier.padding(16.dp, bottom = 4.dp) 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( Slider(
modifier = Modifier value = eqValue.floatValue,
.fillMaxWidth() onValueChange = { newVal ->
.background(backgroundColor, RoundedCornerShape(28.dp)) eqValue.floatValue = newVal
.padding(16.dp), val newEQ = eq.value.copyOf()
horizontalAlignment = Alignment.CenterHorizontally, newEQ[i] = eqValue.floatValue
verticalArrangement = Arrangement.SpaceBetween eq.value = newEQ
) { },
for (i in 0 until 8) { valueRange = 0f..100f,
val eqValue = remember(eq.value[i]) { mutableFloatStateOf(eq.value[i]) }
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth(0.9f)
.height(38.dp) .height(36.dp),
) { colors = SliderDefaults.colors(
Text( thumbColor = thumbColor,
text = String.format("%.2f", eqValue.floatValue), activeTrackColor = activeTrackColor,
fontSize = 12.sp, inactiveTrackColor = trackColor
color = textColor, ),
modifier = Modifier.padding(bottom = 4.dp) thumb = {
) Box(
modifier = Modifier
Slider( .size(24.dp)
value = eqValue.floatValue, .shadow(4.dp, CircleShape)
onValueChange = { newVal -> .background(thumbColor, CircleShape)
eqValue.floatValue = newVal )
val newEQ = eq.value.copyOf() },
newEQ[i] = eqValue.floatValue track = {
eq.value = newEQ Box(
}, modifier = Modifier
valueRange = 0f..100f, .fillMaxWidth()
modifier = Modifier .height(12.dp),
.fillMaxWidth(0.9f) contentAlignment = Alignment.CenterStart
.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( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(12.dp), .height(4.dp)
contentAlignment = Alignment.CenterStart .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(
text = stringResource(R.string.band_label, i + 1), text = stringResource(R.string.band_label, i + 1),
fontSize = 12.sp, fontSize = 12.sp,
color = textColor, color = textColor,
modifier = Modifier.padding(top = 4.dp) 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))
} }
} }
@@ -19,19 +19,26 @@
package me.kavishdevar.librepods.presentation.screens package me.kavishdevar.librepods.presentation.screens
import android.util.Log 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -45,135 +52,104 @@ import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign 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.dp
import androidx.compose.ui.unit.sp 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 kotlinx.coroutines.Job
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.ATTHandles
import me.kavishdevar.librepods.data.HearingAidSettings import me.kavishdevar.librepods.data.HearingAidSettings
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
import me.kavishdevar.librepods.data.sendHearingAidSettings 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.AirPodsViewModel
import me.kavishdevar.librepods.presentation.viewmodel.demoState
private const val TAG = "HearingAidAdjustments" private const val TAG = "UpdateHearingTestScreen"
@Composable @Composable
fun UpdateHearingTestScreen(viewModel: AirPodsViewModel) { fun UpdateHearingTestRoute(viewModel: AirPodsViewModel) {
val verticalScrollState = rememberScrollState()
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val backdrop = rememberLayerBackdrop() val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
StyledScaffold( val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
title = stringResource(R.string.hearing_test) val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
) { topPadding, hazeState, bottomPadding ->
Column(
modifier = Modifier
.hazeSource(hazeState)
.fillMaxSize()
.layerBackdrop(backdrop)
.verticalScroll(verticalScrollState)
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
Spacer(modifier = Modifier.height(topPadding)) Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
UpdateHearingTestScreen(
state = state,
topPadding = topPadding,
bottomPadding = bottomPadding,
setATTCharacteristicValue = viewModel::setATTCharacteristicValue
)
}
}
Text( @Composable
text = stringResource(R.string.hearing_test_value_instruction), fun UpdateHearingTestScreen(
modifier = Modifier.fillMaxWidth(), state: AirPodsUiState,
style = TextStyle( topPadding: Dp = 16.dp,
fontSize = 16.sp, bottomPadding: Dp = 16.dp,
color = textColor, setATTCharacteristicValue: (ATTHandles, ByteArray) -> Unit
fontFamily = FontFamily(Font(R.font.sf_pro)) ) {
), val verticalScrollState = rememberScrollState()
textAlign = TextAlign.Center,
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) } mutableStateOf(FloatArray(8))
val ownVoiceAmplification = rememberSaveable { mutableFloatStateOf(0.5f) } }
val leftAmplification = rememberSaveable { mutableFloatStateOf(0.5f) } val rightEQ = rememberSaveable(
val rightAmplification = rememberSaveable { mutableFloatStateOf(0.5f) } saver = Saver(
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) } save = { it.value.toList() },
val leftEQ = rememberSaveable( restore = { mutableStateOf(it.toFloatArray()) }
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<Job?>(null) } val debounceJob = remember { mutableStateOf<Job?>(null) }
val initialized = rememberSaveable { mutableStateOf(false) } val initialized = rememberSaveable { mutableStateOf(false) }
val hearingAidSettings = remember { val hearingAidSettings = remember {
mutableStateOf( mutableStateOf(
HearingAidSettings( 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(
leftEQ = leftEQ.value, leftEQ = leftEQ.value,
rightEQ = rightEQ.value, rightEQ = rightEQ.value,
leftAmplification = leftAmplification.floatValue, leftAmplification = leftAmplification.floatValue,
@@ -188,98 +164,169 @@ fun UpdateHearingTestScreen(viewModel: AirPodsViewModel) {
balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2, balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
ownVoiceAmplification = ownVoiceAmplification.floatValue 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 = LaunchedEffect(
listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz") 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( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Spacer(modifier = Modifier.width(60.dp))
Text( Text(
text = stringResource(R.string.left), text = freq,
modifier = Modifier.weight(1f), modifier = Modifier
textAlign = TextAlign.Center, .width(60.dp)
style = TextStyle( .align(Alignment.CenterVertically),
fontSize = 18.sp, textAlign = TextAlign.End,
fontFamily = FontFamily(Font(R.font.sf_pro)), style = MaterialTheme.typography.labelSmall
color = textColor
)
) )
Text( OutlinedTextField(
text = stringResource(R.string.right), value = leftEQ.value[index].toString(),
modifier = Modifier.weight(1f), onValueChange = { newValue ->
textAlign = TextAlign.Center, val parsed = newValue.toFloatOrNull()
style = TextStyle( if (parsed != null) {
fontSize = 18.sp, 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)), 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 -> @Preview(name = "Apple")
Row( @Composable
modifier = Modifier.fillMaxWidth(), fun UpdateHearingTestScreenPreviewApple() {
horizontalArrangement = Arrangement.spacedBy(8.dp) LibrePodsTheme(
) { m3eEnabled = false
Text( ) {
text = freq, Box (
modifier = Modifier modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
.width(60.dp) ) {
.align(Alignment.CenterVertically), UpdateHearingTestScreen(
textAlign = TextAlign.End, state = demoState,
style = TextStyle( setATTCharacteristicValue = { _, _ -> }
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 = "Material")
@Composable
fun UpdateHearingTestScreenPreviewMaterial() {
LibrePodsTheme(
m3eEnabled = true
) {
Box (
modifier = Modifier
.wrapContentHeight()
.background(MaterialTheme.colorScheme.surfaceContainer)
) {
UpdateHearingTestScreen(
state = demoState,
setATTCharacteristicValue = { _, _ -> }
)
}
}
}
@@ -19,162 +19,63 @@
package me.kavishdevar.librepods.presentation.screens package me.kavishdevar.librepods.presentation.screens
import androidx.compose.foundation.background 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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.statusBars
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier 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.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.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.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 import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
@Composable @Composable
fun VersionScreen(viewModel: AirPodsViewModel) { fun VersionScreen(viewModel: AirPodsViewModel) {
val state by viewModel.uiState.collectAsState() 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( Column(
title = stringResource(R.string.version) modifier = Modifier
) { spacerHeight -> .fillMaxSize()
Column( .background(MaterialTheme.colorScheme.surfaceContainer)
modifier = Modifier .padding(horizontal = 16.dp)
.fillMaxSize() ) {
.layerBackdrop(backdrop) Spacer(modifier = Modifier.height(topPadding))
.padding(horizontal = 16.dp) StyledList(title = stringResource(R.string.version)) {
) { StyledListItem(
Spacer(modifier = Modifier.height(spacerHeight)) name = stringResource(R.string.version) + " 1",
Box( description = state.version1,
modifier = Modifier enabled = false
.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( StyledListItem(
modifier = Modifier name = stringResource(R.string.version) + " 2",
.clip(RoundedCornerShape(28.dp)) description = state.version2,
.fillMaxWidth() enabled = false
.background(backgroundColor, RoundedCornerShape(28.dp)) )
.padding(top = 2.dp)
) { StyledListItem(
Row( name = stringResource(R.string.version) + " 3",
modifier = Modifier description = state.version3,
.fillMaxWidth() enabled = false
.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))
)
)
}
}
} }
Spacer(modifier = Modifier.height(bottomPadding))
} }
} }
@@ -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
)
}
}
}
@@ -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 {}
}
@@ -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),
)
}
}
}
}
}
@@ -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))
}
}
}
@@ -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
}
@@ -18,47 +18,71 @@
package me.kavishdevar.librepods.presentation.theme package me.kavishdevar.librepods.presentation.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme 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.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme( val ColorScheme.sectionHeader: Color
primary = Purple80, get() = onBackground.copy(alpha = 0.6f)
secondary = PurpleGrey80,
tertiary = Pink80 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( private val AppleLightColorScheme = lightColorScheme(
primary = Purple40, surfaceContainer = Color(0xFFF2F2F7),
secondary = PurpleGrey40, onBackground = Color(0xFF000000),
tertiary = Pink40 surface = Color(0xFFFFFFFF),
onSurface = Color(0xFF000000),
surfaceDim = Color(0x40D9D9D9),
secondaryContainer = Color(0xFF6BC0FF),
onSecondaryContainer = Color(0xFF0088FF),
primary = Color(0xFF0088FF),
onPrimary = Color(0xFFFFFFFF)
) )
@Composable @Composable
fun LibrePodsTheme( fun LibrePodsTheme(
darkTheme: Boolean = isSystemInDarkTheme(), darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true, m3eEnabled: Boolean = false,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
val colorScheme = when { val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { m3eEnabled -> {
val context = LocalContext.current val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
} }
darkTheme -> AppleDarkColorScheme
darkTheme -> DarkColorScheme else -> AppleLightColorScheme
else -> LightColorScheme
} }
MaterialTheme( CompositionLocalProvider(
colorScheme = colorScheme, LocalDesignSystem provides
typography = Typography, if (m3eEnabled) DesignSystem.Material
content = content else DesignSystem.Apple
) ) {
MaterialExpressiveTheme(
colorScheme = colorScheme,
motionScheme = MotionScheme.expressive(),
typography = if (m3eEnabled) MaterialTypography else AppleTypography,
content = content
)
}
} }
@@ -18,35 +18,400 @@
package me.kavishdevar.librepods.presentation.theme 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.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.FontFamily
import androidx.compose.ui.text.font.FontVariation
import androidx.compose.ui.text.font.FontWeight 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 androidx.compose.ui.unit.sp
import me.kavishdevar.librepods.R
// Set of Material typography styles to start with val sfProFamily = FontFamily(Font(R.font.sf_pro))
val Typography = Typography(
bodyLarge = TextStyle( val AppleTypography = Typography().run {
fontFamily = FontFamily.Default, copy(
fontWeight = FontWeight.Normal, displayLarge = displayLarge.copy(fontFamily = sfProFamily),
fontSize = 16.sp, displayMedium = displayMedium.copy(fontFamily = sfProFamily),
lineHeight = 24.sp, displaySmall = displaySmall.copy(fontFamily = sfProFamily),
letterSpacing = 0.5.sp
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, val provider = GoogleFont.Provider(
fontWeight = FontWeight.Normal, providerAuthority = "com.google.android.gms.fonts",
fontSize = 22.sp, providerPackage = "com.google.android.gms",
lineHeight = 28.sp, certificates = R.array.com_google_android_gms_fonts_certs
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
) )
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
)
}
}
}
}
@@ -24,13 +24,14 @@ import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.util.Log
import android.widget.Toast 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.core.content.edit
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -57,7 +58,7 @@ import me.kavishdevar.librepods.services.AirPodsService
@Suppress("ArrayInDataClass") @Suppress("ArrayInDataClass")
data class AirPodsUiState( data class AirPodsUiState(
val deviceName: String, val deviceName: String = "AirPods",
val isLocallyConnected: Boolean = false, val isLocallyConnected: Boolean = false,
@@ -103,24 +104,127 @@ data class AirPodsUiState(
val customEq: CustomEq = CustomEq(1, 50, 50, 50) // disabled val customEq: CustomEq = CustomEq(1, 50, 50, 50) // disabled
) )
class AirPodsViewModel( val demoInstance = AirPodsInstance(
private val service: AirPodsService, name = "AirPods Pro",
private val sharedPreferences: SharedPreferences, model = AirPodsModels.getModelByModelNumber("A3064")!!,
private val controlRepo: ControlCommandRepository, actualModelNumber = "A3064",
private val appContext: Context serialNumber = "JXF9Q94A40",
) : ViewModel() { leftSerialNumber = "L-DEMO",
private val _uiState = MutableStateFlow( rightSerialNumber = "R-DEMO",
AirPodsUiState( version1 = "90.3388000000000000.1786",
deviceName = sharedPreferences.getString( version2 = "90.3388000000000000.1786",
"name", version3 = "9441861",
"AirPods Pro" )
) ?: "AirPods Pro"
) 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<AirPodsUiState> = _uiState val uiState: StateFlow<AirPodsUiState> = _uiState
private var isDemoMode = false private var isDemoMode = false
val demoActivated = MutableSharedFlow<Unit>()
private val listeners = private val listeners =
mutableMapOf<ControlCommandIdentifiers, AACPManager.ControlCommandListener>() mutableMapOf<ControlCommandIdentifiers, AACPManager.ControlCommandListener>()
@@ -129,19 +233,19 @@ class AirPodsViewModel(
private lateinit var broadcastReceiver: BroadcastReceiver private lateinit var broadcastReceiver: BroadcastReceiver
private val _cameraAction = MutableStateFlow( // private val _cameraAction = MutableStateFlow(
sharedPreferences.getString("camera_action", null) // sharedPreferences.getString("camera_action", null)
?.let { value -> AACPManager.Companion.StemPressType.entries.find { it.name == value } }) // ?.let { value -> AACPManager.Companion.StemPressType.entries.find { it.name == value } })
//
val cameraAction: StateFlow<AACPManager.Companion.StemPressType?> = _cameraAction // val cameraAction: StateFlow<AACPManager.Companion.StemPressType?> = _cameraAction
//
fun setCameraAction(action: AACPManager.Companion.StemPressType?) { // fun setCameraAction(action: AACPManager.Companion.StemPressType?) {
sharedPreferences.edit { // sharedPreferences.edit {
if (action == null) remove("camera_action") // if (action == null) remove("camera_action")
else putString("camera_action", action.name) // else putString("camera_action", action.name)
} // }
_cameraAction.value = action // _cameraAction.value = action
} // }
fun setCustomEq(low: Int, mid: Int, high: Int) { fun setCustomEq(low: Int, mid: Int, high: Int) {
require(low in 0..100) 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() { override fun onCleared() {
listeners.forEach { (id, listener) -> listeners.forEach { (id, listener) ->
controlRepo.remove(id, listener) controlRepo.remove(id, listener)
} }
service.aacpManager.customEqCallback = null service.aacpManager.customEqCallback = null
appContext.unregisterReceiver(broadcastReceiver) appContext.unregisterReceiver(broadcastReceiver)
super.onCleared()
} }
private fun loadName() { private fun loadName() {
@@ -198,12 +285,7 @@ class AirPodsViewModel(
private fun observeBilling() { private fun observeBilling() {
if (isDemoMode) return if (isDemoMode) return
viewModelScope.launch { viewModelScope.launch {
// if (!BuildConfig.PLAY_BUILD) billingFirstCollectDone = true // FOSS doesn't send multiple events
BillingManager.provider.isPremium.collect { premium -> BillingManager.provider.isPremium.collect { premium ->
// if (!billingFirstCollectDone) {
// billingFirstCollectDone = true
// return@collect
// }
if (premium) { if (premium) {
sharedPreferences.edit { sharedPreferences.edit {
remove("premium_expiry_time") remove("premium_expiry_time")
@@ -378,12 +460,15 @@ class AirPodsViewModel(
} }
} }
fun refreshInitialData() { fun loadCurrentStatus() {
if (isDemoMode) return if (isDemoMode) return
service.let { service -> service.let { service ->
_uiState.update { _uiState.update {
it.copy( 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() { private fun loadEq() {
_uiState.update { _uiState.update {
it.copy( it.copy(
@@ -607,7 +684,6 @@ class AirPodsViewModel(
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
service.attManager.enableNotification(ATTCCCDHandles.HEARING_AID) service.attManager.enableNotification(ATTCCCDHandles.HEARING_AID)
service.attManager.enableNotification(ATTCCCDHandles.TRANSPARENCY) service.attManager.enableNotification(ATTCCCDHandles.TRANSPARENCY)
// service.attManager.enableNotification(ATTCCCDHandles.LOUD_SOUND_REDUCTION)
} }
service.attManager.setOnNotificationReceived { handle, value -> service.attManager.setOnNotificationReceived { handle, value ->
when (handle) { when (handle) {
@@ -655,40 +731,7 @@ class AirPodsViewModel(
fun activateDemoMode() { fun activateDemoMode() {
isDemoMode = true isDemoMode = true
viewModelScope.launch { _uiState.update {demoState}
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
)
}
} }
fun sendPhoneMediaEQ(eq: FloatArray, phoneByte: Byte, mediaByte: Byte) { fun sendPhoneMediaEQ(eq: FloatArray, phoneByte: Byte, mediaByte: Byte) {
@@ -725,10 +768,19 @@ class AirPodsViewModel(
} }
fun disconnect() { fun disconnect() {
service.disconnectAirPods() if (isDemoMode) {
if (appContext.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") != PackageManager.PERMISSION_GRANTED) { isDemoMode = false
Toast.makeText(appContext, "App has disconnected, disconnect from Android Settings.", _uiState.update {
Toast.LENGTH_LONG).show() 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()
}
} }
} }
} }
@@ -36,7 +36,8 @@ data class AppSettingsUiState(
val connectionSuccessful: Boolean = false, val connectionSuccessful: Boolean = false,
val showBottomSheetPopup: Boolean = true, val showBottomSheetPopup: Boolean = true,
val showIslandPopup: Boolean = true, val showIslandPopup: Boolean = true,
val timeUntilFOSSPremiumExpiry: Long = 0L val timeUntilFOSSPremiumExpiry: Long = 0L,
val m3eEnabled: Boolean = false
) )
class AppSettingsViewModel(application: Application) : AndroidViewModel(application) { class AppSettingsViewModel(application: Application) : AndroidViewModel(application) {
@@ -62,7 +63,6 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
override fun onCleared() { override fun onCleared() {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPrefListener) sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPrefListener)
super.onCleared()
} }
private fun observeBilling() { private fun observeBilling() {
@@ -71,7 +71,7 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
if (premium) { if (premium) {
sharedPreferences.edit { sharedPreferences.edit {
remove("premium_expiry_time") remove("premium_expiry_time")
remove("foss_upgraded") if (BuildConfig.PLAY_BUILD) remove("foss_upgraded")
} }
_uiState.update { it.copy(isPremium = true, timeUntilFOSSPremiumExpiry = 0L) } _uiState.update { it.copy(isPremium = true, timeUntilFOSSPremiumExpiry = 0L) }
} else { } else {
@@ -151,7 +151,8 @@ class AppSettingsViewModel(application: Application) : AndroidViewModel(applicat
vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false), vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false),
connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false), connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false),
showBottomSheetPopup = sharedPreferences.getBoolean("show_bottom_sheet_popup", true), 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) } sharedPreferences.edit { putBoolean("show_island_popup", enabled) }
_uiState.update { it.copy(showIslandPopup = enabled) } _uiState.update { it.copy(showIslandPopup = enabled) }
} }
fun setm3eEnabled(enabled: Boolean) {
sharedPreferences.edit { putBoolean("m3e_enabled", enabled) }
_uiState.update { it.copy(m3eEnabled = enabled) }
}
} }
@@ -99,7 +99,7 @@ class AirPodsQSService : TileService() {
Log.d("AirPodsQSService", "onStartListening") Log.d("AirPodsQSService", "onStartListening")
val service = ServiceManager.getService() val service = ServiceManager.getService()
isAirPodsConnected = BluetoothConnectionManager.getAACPSocket()?.isConnected == true isAirPodsConnected = BluetoothConnectionManager.aacpSocket?.isConnected == true
currentAncMode = service?.getANC() ?: (NoiseControlMode.OFF.ordinal + 1) currentAncMode = service?.getANC() ?: (NoiseControlMode.OFF.ordinal + 1)
if (currentAncMode == NoiseControlMode.OFF.ordinal + 1 && !isOffModeEnabled()) { if (currentAncMode == NoiseControlMode.OFF.ordinal + 1 && !isOffModeEnabled()) {
@@ -85,11 +85,11 @@ import me.kavishdevar.librepods.MainActivity
import me.kavishdevar.librepods.R import me.kavishdevar.librepods.R
import me.kavishdevar.librepods.bluetooth.AACPManager import me.kavishdevar.librepods.bluetooth.AACPManager
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.StemPressType 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.ATTHandles
import me.kavishdevar.librepods.bluetooth.ATTManagerv2 import me.kavishdevar.librepods.bluetooth.ATTManagerv2
import me.kavishdevar.librepods.bluetooth.BLEManager import me.kavishdevar.librepods.bluetooth.BLEManager
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
import me.kavishdevar.librepods.bluetooth.createBluetoothSocket
import me.kavishdevar.librepods.data.AirPodsInstance import me.kavishdevar.librepods.data.AirPodsInstance
import me.kavishdevar.librepods.data.AirPodsModels import me.kavishdevar.librepods.data.AirPodsModels
import me.kavishdevar.librepods.data.AirPodsNotifications import me.kavishdevar.librepods.data.AirPodsNotifications
@@ -246,7 +246,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
override fun onDeviceStatusChanged( override fun onDeviceStatusChanged(
device: BLEManager.AirPodsStatus, previousStatus: BLEManager.AirPodsStatus? 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.") Log.d(TAG, "Seems no device has taken over, we will.")
val bluetoothManager = getSystemService(BluetoothManager::class.java) val bluetoothManager = getSystemService(BluetoothManager::class.java)
val bluetoothAdapter = bluetoothManager.adapter val bluetoothAdapter = bluetoothManager.adapter
@@ -258,7 +258,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
connectToSocket(bluetoothAdapter, bluetoothDevice) connectToSocket(bluetoothAdapter, bluetoothDevice)
} }
Log.d(TAG, "Device status changed") 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 leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0
val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0 val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0
val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0 val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0
@@ -291,7 +291,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
getSharedPreferences("settings", MODE_PRIVATE).getString("name", "AirPods Pro") getSharedPreferences("settings", MODE_PRIVATE).getString("name", "AirPods Pro")
?: "AirPods" ?: "AirPods"
) )
if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) return if (BluetoothConnectionManager.aacpSocket?.isConnected == true) return
val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0 val leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0
val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0 val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0
val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0 val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0
@@ -325,7 +325,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
override fun onBatteryChanged(device: BLEManager.AirPodsStatus) { 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 leftLevel = bleManager.getMostRecentStatus()?.leftBattery ?: 0
val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0 val rightLevel = bleManager.getMostRecentStatus()?.rightBattery ?: 0
val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0 val caseLevel = bleManager.getMostRecentStatus()?.caseBattery ?: 0
@@ -697,8 +697,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
popupShown = false popupShown = false
updateNotificationContent(false) updateNotificationContent(false)
aacpManager.disconnected() aacpManager.disconnected()
attManager.disconnected() BluetoothConnectionManager.aacpSocket = null
BluetoothConnectionManager.setCurrentConnection(null, null) BluetoothConnectionManager.attSocket = null
} }
} }
} }
@@ -1747,7 +1747,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
val socketFailureChannel = NotificationChannel( val socketFailureChannel = NotificationChannel(
"socket_connection_failure", "socket_connection_failure",
"AirPods BluetoothConnectionManager.getAACPSocket()? Connection Issues", "AirPods BluetoothConnectionManager.aacpSocket? Connection Issues",
NotificationManager.IMPORTANCE_HIGH NotificationManager.IMPORTANCE_HIGH
).apply { ).apply {
description = "Notifications about problems connecting to AirPods protocol" description = "Notifications about problems connecting to AirPods protocol"
@@ -1793,7 +1793,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
if (BuildConfig.FLAVOR != "xposed") { if (BuildConfig.FLAVOR != "xposed") {
Log.w( Log.w(
TAG, 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 return
} }
@@ -2048,10 +2048,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
if (BluetoothConnectionManager.getAACPSocket() == null) { if (BluetoothConnectionManager.aacpSocket == null) {
return return
} }
if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) { if (BluetoothConnectionManager.aacpSocket?.isConnected == true) {
val updatedNotificationBuilder = val updatedNotificationBuilder =
NotificationCompat.Builder(this, "airpods_connection_status") NotificationCompat.Builder(this, "airpods_connection_status")
.setSmallIcon(R.drawable.airpods) .setSmallIcon(R.drawable.airpods)
@@ -2099,8 +2099,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
notificationManager.cancel(1) notificationManager.cancel(1)
} else if (!connected) { } else if (!connected) {
notificationManager.cancel(2) notificationManager.cancel(2)
} else if (!config.bleOnlyMode && BluetoothConnectionManager.getAACPSocket()?.isConnected != true) { } else if (!config.bleOnlyMode && BluetoothConnectionManager.aacpSocket?.isConnected != true) {
showSocketConnectionFailureNotification("BluetoothConnectionManager.getAACPSocket()? created, but not connected. Check logs") showSocketConnectionFailureNotification("BluetoothConnectionManager.aacpSocket? created, but not connected. Check logs")
} }
} }
@@ -2379,7 +2379,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} else { } else {
Log.w( Log.w(
TAG, 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( Log.d(
TAG, "owns connection: $ownsConnection" TAG, "owns connection: $ownsConnection"
) )
if (BluetoothConnectionManager.getAACPSocket()?.isConnected == true) { if (BluetoothConnectionManager.aacpSocket?.isConnected == true) {
if (!XposedRemotePrefProvider.create().getBoolean("vendor_id_hook", false) || ownsConnection == 0) { if (!XposedRemotePrefProvider.create().getBoolean("vendor_id_hook", false) || ownsConnection == 0) {
Log.d(TAG, "not taking over, vendorid is probably not set to apple") Log.d(TAG, "not taking over, vendorid is probably not set to apple")
return return
@@ -2633,58 +2633,11 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
// CrossDevice.isAvailable = false // 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") @SuppressLint("MissingPermission", "UnspecifiedRegisterReceiverFlag")
fun connectToSocket( fun connectToSocket(
adapter: BluetoothAdapter, device: BluetoothDevice, manual: Boolean = false 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, "<LogCollector:Start> Connecting to socket") Log.d(TAG, "<LogCollector:Start> Connecting to socket")
val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a") val uuid: ParcelUuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
// if (!isConnectedLocally) { // if (!isConnectedLocally) {
@@ -2701,7 +2654,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
withTimeout(5000.milliseconds) { withTimeout(5000.milliseconds) {
try { try {
socket.connect() socket.connect()
// isConnectedLocally = true
this@AirPodsService.device = device this@AirPodsService.device = device
val xposedRemotePref = XposedRemotePrefProvider.create() val xposedRemotePref = XposedRemotePrefProvider.create()
val attSocket = if (xposedRemotePref.getBoolean("vendor_id_hook", false)) { val attSocket = if (xposedRemotePref.getBoolean("vendor_id_hook", false)) {
@@ -2713,17 +2665,17 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
) )
} else null } else null
attSocket?.connect() attSocket?.connect()
BluetoothConnectionManager.setCurrentConnection(socket, attSocket)
if (attSocket != null) { if (attSocket != null) {
attManager.startReader() attManager.startReader()
attManager.readCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) attManager.readCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION)
attManager.readCharacteristic(ATTHandles.TRANSPARENCY) attManager.readCharacteristic(ATTHandles.TRANSPARENCY)
attManager.readCharacteristic(ATTHandles.HEARING_AID) 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 // Create AirPodsInstance from stored config if available
if (airpodsInstance == null && config.airpodsModelNumber.isNotEmpty()) { if (airpodsInstance == null && config.airpodsModelNumber.isNotEmpty()) {
val model = val model =
@@ -2787,7 +2739,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
return return
} }
this@AirPodsService.device = device this@AirPodsService.device = device
BluetoothConnectionManager.getAACPSocket()?.let { BluetoothConnectionManager.aacpSocket?.let {
aacpManager.sendPacket(aacpManager.createHandshakePacket()) aacpManager.sendPacket(aacpManager.createHandshakePacket())
aacpManager.sendSetFeatureFlagsPacket() aacpManager.sendSetFeatureFlagsPacket()
aacpManager.sendNotificationRequest() aacpManager.sendNotificationRequest()
@@ -2878,19 +2830,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() 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}") showSocketConnectionFailureNotification("Failed to establish connection: ${e.localizedMessage}")
// isConnectedLocally = false // isConnectedLocally = false
this@AirPodsService.device = device this@AirPodsService.device = device
updateNotificationContent(false) updateNotificationContent(false)
} }
// } else { // } 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() { fun disconnectForCD() {
BluetoothConnectionManager.getAACPSocket()?.close() BluetoothConnectionManager.aacpSocket?.close()
MediaController.pausedWhileTakingOver = false MediaController.pausedWhileTakingOver = false
Log.d(TAG, "Disconnected from AirPods, showing island.") Log.d(TAG, "Disconnected from AirPods, showing island.")
showIsland( showIsland(
@@ -2921,16 +2873,18 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
} }
fun disconnectAirPods() { fun disconnectAirPods() {
if (BluetoothConnectionManager.getAACPSocket() == null) return if (BluetoothConnectionManager.aacpSocket == null) return
try { try {
BluetoothConnectionManager.getAACPSocket()?.close() BluetoothConnectionManager.aacpSocket?.close()
} catch(e: Exception) { } catch(e: Exception) {
Log.e(TAG, "error closing aacp socket ${e.message}") Log.e(TAG, "error closing aacp socket ${e.message}")
} }
// isConnectedLocally = false // isConnectedLocally = false
aacpManager.disconnected() aacpManager.disconnected()
attManager.disconnected()
BluetoothConnectionManager.setCurrentConnection(null, null) BluetoothConnectionManager.aacpSocket = null
BluetoothConnectionManager.attSocket = null
updateNotificationContent(false) updateNotificationContent(false)
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply { sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
setPackage(packageName) setPackage(packageName)
@@ -3222,6 +3176,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
aacpManager.sendStopHeadTracking() aacpManager.sendStopHeadTracking()
} }
isHeadTrackingActive = false isHeadTrackingActive = false
gestureDetector?.stopDetection()
} }
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
@@ -20,13 +20,17 @@ package me.kavishdevar.librepods.utils
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Build import android.os.Build
import androidx.core.content.edit
fun isSupported(sharedPreferences: SharedPreferences): Boolean { 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) val isBypassFlagActive = sharedPreferences.getBoolean("bypass_device_check.v2", false)
if (isBypassFlagActive) return true if (isBypassFlagActive) return true
val isPixel = Build.MANUFACTURER.lowercase() == "google" val isPixel = Build.MANUFACTURER.lowercase() == "google"
val isOppoFamily = Build.MANUFACTURER.lowercase() in listOf("oneplus", "oppo", "realme") val isOppoFamily = Build.MANUFACTURER.lowercase() in listOf("oneplus", "oppo", "realme")
if (isPixel && Build.VERSION.SDK_INT == 36) { if (isPixel && Build.VERSION.SDK_INT == 36) {
return Build.ID.startsWith("CP1A") return Build.ID.startsWith("CP1A")
} else if (isOppoFamily) { } else if (isOppoFamily) {
@@ -34,3 +38,7 @@ fun isSupported(sharedPreferences: SharedPreferences): Boolean {
} }
return false return false
} }
fun bypassDeviceCheck(sharedPreferences: SharedPreferences) {
sharedPreferences.edit{ putBoolean("bypass_device_check.v2", true) }
}
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools">
<array name="com_google_android_gms_fonts_certs">
<item tools:ignore="PrivateResource">@array/com_google_android_gms_fonts_certs_dev</item>
<item tools:ignore="PrivateResource">@array/com_google_android_gms_fonts_certs_prod</item>
</array>
<string-array name="com_google_android_gms_fonts_certs_dev" tools:ignore="PrivateResource">
<item>
MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpFeVyxW0qMBujb8X8ETrWy550NaFtI6t9+u7hZeTfHwqNvacKhp1RbE6dBRGWynwMVX8XW8N1+UjFaq6GCJukT4qmpN2afb8sCjUigq0GuMwYXrFVee74bQgLHWGJwPmvmLHC69EH6kWr22ijx4OKXlSIx2xT1AsSHee70w5iDBiK4aph27yH3TxkXy9V89TDdexAcKk/cVHYNnDBapcavl7y0RiQ4biu8ymM8Ga/nmzhRKya6G0cGw8CAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs=
</item>
</string-array>
<string-array name="com_google_android_gms_fonts_certs_prod" tools:ignore="PrivateResource">
<item>
MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAQOjgdkwgdYwHQYDVR0OBBYEFMd9jMIhF1Ylmn/Tgt9r45jk14alMIGmBgNVHSMEgZ4wgZuAFMd9jMIhF1Ylmn/Tgt9r45jk14aloXikdjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEUMBIGA1UEChMLR29vZ2xlIEluYy4xEDAOBgNVBAsTB0FuZHJvaWQxEDAOBgNVBAMTB0FuZHJvaWSCCQDC4IdGZEowjTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBAUAA4IBAQBt0lLO74UwLDYKqs6Tm8/yzKkEu116FmH4rkaymUIE0P9KaMftGlMexFlaYjzmB2OxZyl6euNXEsQH8gjwyxCUKRJNexBiGcCEyj6z+a1fuHHvkiaai+KL8W1EyNmgjmyy8AW7P+LLlkR+ho5zEHatRbM/YAnqGcFh5iZBqpknHf1SKMXFh4dd239FJ1jWYfbMDMy3NS5CTMQ2XFI1MvcyUTdZPErjQfTbQe3aDQsQcafEQPD+nqActifKZ0Np0IS9L9kR/wbNvyz6ENwPiTrjV2KRkEjH78ZMcUQXg0L3BYHJ3lc69Vs5Ddf9uUGGMYldX3WfMBEmh/9iFBDAaTCK
</item>
</string-array>
</resources>
+12 -1
View File
@@ -275,8 +275,19 @@
<string name="describe_your_issue">Describe your issue</string> <string name="describe_your_issue">Describe your issue</string>
<string name="optimized_charging">Optimized Charge Limit</string> <string name="optimized_charging">Optimized Charge Limit</string>
<string name="optimized_charging_description">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.</string> <string name="optimized_charging_description">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.</string>
<string name="enable_app_in_xposed_or_update_device">Enable LibrePods in Xposed or update your device to proceed.</string> <string name="enable_app_in_xposed_or_update_device">Update your device or enable LibrePods in Xposed and reopen the app to proceed.</string>
<string name="play_foss_premium_banner">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.</string> <string name="play_foss_premium_banner">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.</string>
<string name="custom">Custom</string> <string name="custom">Custom</string>
<string name="recommended">Recommended</string> <string name="recommended">Recommended</string>
<string name="appearance">Appearance</string>
<string name="use_material3e">Use Material 3 Expressive</string>
<string name="material3e">Material 3 Expressive</string>
<string name="update_m3e_description">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.</string>
<string name="update_equalizer_description">Available only on the latest AirPods Beta firmware. An Apple device running OS version 27 is required to install the beta firmware.</string>
<string name="what_s_new">What\'s New</string>
<string name="reconnecting">Reconnecting</string>
<string name="tap_to_reconnect">Tap to reconnect</string>
<string name="permissions">Permissions</string>
<string name="privacy_policy">Privacy Policy</string>
<string name="i_agree">I agree</string>
</resources> </resources>
+13 -1
View File
@@ -20,6 +20,12 @@ hilt = "2.59.2"
xposed = "101.0.0" xposed = "101.0.0"
lifecycleProcess = "2.10.0" lifecycleProcess = "2.10.0"
play = "2.0.2" 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] [libraries]
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" } 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 = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } 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" } annotations = { group = "org.jetbrains", name = "annotations", version.ref = "annotations" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } 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" } 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 = { group = "com.google.android.play", name="review", version.ref = "play" }
play-review-ktx = { group = "com.google.android.play", name="review-ktx", 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }