mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-09-02 09:19:12 +02:00
Compare commits
33 Commits
29a914c2ff
...
v1.0.0-rc2
| Author | SHA1 | Date | |
|---|---|---|---|
| 53679cc902 | |||
| b4cacb89f1 | |||
| cf6d226fca | |||
| 4c7a3cb203 | |||
| 1783bb742e | |||
| 3cc4d92d62 | |||
| 95ecc0eb3d | |||
| a7537f2361 | |||
| 21babd819f | |||
| b5a3eaee8f | |||
| c3f537b0e9 | |||
| e6ebbed674 | |||
| e34474f765 | |||
| 4f910136dc | |||
| 721819e792 | |||
| bf3920718c | |||
| cb0c46dc33 | |||
| cd40975a1f | |||
| 790e396345 | |||
| 633d036dd7 | |||
| 7341e41837 | |||
| bffb5c8b3e | |||
| aca4373ec4 | |||
| 8804197760 | |||
| 57d692c4ae | |||
| 0477674810 | |||
| c1093fbe24 | |||
| 0f50eab788 | |||
| 1381022b2e | |||
| af4261485a | |||
| 571db0ebde | |||
| 3c3c0edffd | |||
| f86d7b9aca |
@@ -1,7 +1,7 @@
|
|||||||
>[!IMPORTANT]
|
> [!WARNING]
|
||||||
Development paused due to lack of time until June 2026 (JEE Advanced). PRs and issues might not be responded to until then.
|
> librepods.org is not an official website of the LibrePods project. It inaccurately claims to be the official website of the project by claiming copyrights and using the LibrePods logo in the footer. And at the same time, they say that the project is not affiliated with the LibrePods project or its developers.
|
||||||
|
>
|
||||||
---
|
> Please report any other such websites to [me@kavish.xyz](mailto:me@kavish.xyz)
|
||||||
|
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="./imgs/banner-dark.png" />
|
<source media="(prefers-color-scheme: dark)" srcset="./imgs/banner-dark.png" />
|
||||||
@@ -224,11 +224,11 @@ A huge thank you to everyone supporting the project!
|
|||||||
|
|
||||||
# Star History
|
# Star History
|
||||||
|
|
||||||
<a href="https://www.star-history.com/#kavishdevar/librepods&type=date&legend=top-left">
|
<a href="https://www.star-history.com/?repos=librepods-org%2Flibrepods&type=date&legend=top-left">
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=kavishdevar/librepods&type=date&theme=dark&legend=top-left" />
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=librepods-org/librepods&type=date&theme=dark&legend=top-left&sealed_token=CS9QF2Wcx-_EN6M7lBhwvTa2WBd5eB8FbQa5FWt3SV14v1OcQdy2aCGsXxHoytVaoylP_pTxj1h2U-odoXDV_EwTqMxM-MQwG_WpvV_1g_yH-Jh3ux2BuWCmS98LxLqfox2ibLOlFDHu_-geTLFsTmqTc1SjX6-NYxVHXF5DaqnxFOAyjLPL5_WtXVew" />
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=kavishdevar/librepods&type=date&legend=top-left" />
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=librepods-org/librepods&type=date&legend=top-left&sealed_token=CS9QF2Wcx-_EN6M7lBhwvTa2WBd5eB8FbQa5FWt3SV14v1OcQdy2aCGsXxHoytVaoylP_pTxj1h2U-odoXDV_EwTqMxM-MQwG_WpvV_1g_yH-Jh3ux2BuWCmS98LxLqfox2ibLOlFDHu_-geTLFsTmqTc1SjX6-NYxVHXF5DaqnxFOAyjLPL5_WtXVew" />
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=kavishdevar/librepods&type=date&legend=top-left" />
|
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=librepods-org/librepods&type=date&legend=top-left&sealed_token=CS9QF2Wcx-_EN6M7lBhwvTa2WBd5eB8FbQa5FWt3SV14v1OcQdy2aCGsXxHoytVaoylP_pTxj1h2U-odoXDV_EwTqMxM-MQwG_WpvV_1g_yH-Jh3ux2BuWCmS98LxLqfox2ibLOlFDHu_-geTLFsTmqTc1SjX6-NYxVHXF5DaqnxFOAyjLPL5_WtXVew" />
|
||||||
</picture>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -250,4 +250,12 @@ GNU General Public License for more details.
|
|||||||
You should have received a copy of the GNU General Public License
|
You should have received a copy of the GNU General Public License
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
All trademarks, logos, and brand names are the property of their respective owners. Use of them does not imply any affiliation with or endorsement by them. All AirPods images, symbols, and the SF Pro font are the property of Apple Inc.
|
# Trademark Notice
|
||||||
|
|
||||||
|
The GPL does not grant any rights to use the LibrePods name, logo, or branding. The LibrePods name and logo may not be used for software, websites, domains, products, services, or other projects in a manner that suggests affiliation with, endorsement by, or association with the official LibrePods project without prior permission.
|
||||||
|
|
||||||
|
If you see any misuse of the LibrePods name or logo, please report it to [me@kavish.xyz](mailto:me@kavish.xyz).
|
||||||
|
|
||||||
|
The SF Pro font used in the Android app is the property of Apple Inc.. This will be removed in future versions of the app and replaced with an open alternative soon.
|
||||||
|
|
||||||
|
AirPods, AirPods Pro, AirPods Max, and the AirPods logo are trademarks of Apple Inc. The LibrePods project is not affiliated with or endorsed by Apple Inc. in any way.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
|
|
||||||
val appVersionName = "0.2.9"
|
val appVersionName = "1.0.0-rc2"
|
||||||
|
|
||||||
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 = 52
|
versionCode = 63
|
||||||
versionName = appVersionName
|
versionName = appVersionName
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -56,7 +64,6 @@ android {
|
|||||||
arguments += "-DCMAKE_BUILD_TYPE=Release"
|
arguments += "-DCMAKE_BUILD_TYPE=Release"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
buildConfigField("Boolean", "PLAY_BUILD", "false")
|
|
||||||
if (releaseSigningAvailable) {
|
if (releaseSigningAvailable) {
|
||||||
signingConfig = signingConfigs.getByName("release")
|
signingConfig = signingConfigs.getByName("release")
|
||||||
}
|
}
|
||||||
@@ -65,7 +72,6 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
debug {
|
debug {
|
||||||
buildConfigField("Boolean", "PLAY_BUILD", "false")
|
|
||||||
if (releaseSigningAvailable) {
|
if (releaseSigningAvailable) {
|
||||||
signingConfig = signingConfigs.getByName("release")
|
signingConfig = signingConfigs.getByName("release")
|
||||||
}
|
}
|
||||||
@@ -119,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)
|
||||||
@@ -147,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,123 +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.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.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
|
||||||
@@ -171,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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,146 +149,10 @@ 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) {
|
|
||||||
canDrawOverlays = Settings.canDrawOverlays(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (permissionState.allPermissionsGranted && (canDrawOverlays || overlaySkipped.value)) {
|
|
||||||
|
|
||||||
val navController = rememberNavController()
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
if (BuildConfig.PLAY_BUILD) {
|
if (BuildConfig.PLAY_BUILD) {
|
||||||
@@ -384,143 +179,24 @@ fun Main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
val onboardingComplete = sharedPreferences.getBoolean("onboarding_complete", false)
|
||||||
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()
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val showBackButton = remember { mutableStateOf(false) }
|
val releaseNotesShownPrefKey = "release_notes_shown_${BuildConfig.VERSION_NAME.removeSuffix("-debug").removeSuffix("-play")}"
|
||||||
|
val releaseNotesShown = sharedPreferences.getBoolean(releaseNotesShownPrefKey, 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 {
|
||||||
serviceConnection = remember {
|
|
||||||
object : ServiceConnection {
|
|
||||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||||
val binder = service as AirPodsService.LocalBinder
|
val binder = service as AirPodsService.LocalBinder
|
||||||
airPodsService.value = binder.getService()
|
val service = binder.getService()
|
||||||
|
airPodsService.value = service
|
||||||
|
airPodsViewModel.init(
|
||||||
|
service = service,
|
||||||
|
controlRepo = ControlCommandRepository(service.aacpManager),
|
||||||
|
sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE),
|
||||||
|
appContext = context.applicationContext
|
||||||
|
)
|
||||||
|
|
||||||
if (!sharedPreferences.contains("first_connection_successful_time")) {
|
if (!sharedPreferences.contains("first_connection_successful_time")) {
|
||||||
sharedPreferences.edit {
|
sharedPreferences.edit {
|
||||||
@@ -533,23 +209,28 @@ fun Main() {
|
|||||||
airPodsService.value = null
|
airPodsService.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
context.bindService(
|
context.bindService(
|
||||||
Intent(context, AirPodsService::class.java),
|
Intent(context, AirPodsService::class.java),
|
||||||
serviceConnection,
|
serviceConnection,
|
||||||
Context.BIND_AUTO_CREATE
|
Context.BIND_AUTO_CREATE
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (airPodsService.value?.isConnected() == true) {
|
if (onboardingComplete) {
|
||||||
isConnected.value = true
|
bindService()
|
||||||
}
|
|
||||||
} else {
|
|
||||||
PermissionsScreen(
|
|
||||||
permissionState = permissionState,
|
|
||||||
canDrawOverlays = canDrawOverlays,
|
|
||||||
onOverlaySettingsReturn = { canDrawOverlays = Settings.canDrawOverlays(context) })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
@@ -562,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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
package me.kavishdevar.librepods.bluetooth
|
package me.kavishdevar.librepods.bluetooth
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import me.kavishdevar.librepods.data.Capability
|
||||||
|
import me.kavishdevar.librepods.data.CustomEq
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
@@ -31,9 +33,8 @@ import kotlin.io.encoding.ExperimentalEncodingApi
|
|||||||
* constructing and parsing packets for communication with AirPods.
|
* constructing and parsing packets for communication with AirPods.
|
||||||
*/
|
*/
|
||||||
class AACPManager {
|
class AACPManager {
|
||||||
|
private val TAG = "AACPManager[${System.identityHashCode(this)}]"
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "AACPManager"
|
|
||||||
|
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
object Opcodes {
|
object Opcodes {
|
||||||
const val SET_FEATURE_FLAGS: Byte = 0x4D
|
const val SET_FEATURE_FLAGS: Byte = 0x4D
|
||||||
@@ -48,7 +49,7 @@ class AACPManager {
|
|||||||
const val PROXIMITY_KEYS_REQ: Byte = 0x30
|
const val PROXIMITY_KEYS_REQ: Byte = 0x30
|
||||||
const val PROXIMITY_KEYS_RSP: Byte = 0x31
|
const val PROXIMITY_KEYS_RSP: Byte = 0x31
|
||||||
const val STEM_PRESS: Byte = 0x19
|
const val STEM_PRESS: Byte = 0x19
|
||||||
const val EQ_DATA: Byte = 0x53
|
const val HEADPHONE_ACCOMMODATION: Byte = 0x53
|
||||||
const val CONNECTED_DEVICES: Byte = 0x2E // TiPi 1
|
const val CONNECTED_DEVICES: Byte = 0x2E // TiPi 1
|
||||||
const val AUDIO_SOURCE: Byte = 0x0E // TiPi 2
|
const val AUDIO_SOURCE: Byte = 0x0E // TiPi 2
|
||||||
const val SMART_ROUTING: Byte = 0x10
|
const val SMART_ROUTING: Byte = 0x10
|
||||||
@@ -56,6 +57,7 @@ class AACPManager {
|
|||||||
const val SMART_ROUTING_RESP: Byte = 0x11
|
const val SMART_ROUTING_RESP: Byte = 0x11
|
||||||
const val SEND_CONNECTED_MAC: Byte = 0x14
|
const val SEND_CONNECTED_MAC: Byte = 0x14
|
||||||
const val AUDIO_SOURCE_2: Byte = 0x0C // seems redundant?
|
const val AUDIO_SOURCE_2: Byte = 0x0C // seems redundant?
|
||||||
|
const val CUSTOM_EQ: Byte = 0x63
|
||||||
}
|
}
|
||||||
|
|
||||||
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
|
private val HEADER_BYTES = byteArrayOf(0x04, 0x00, 0x04, 0x00)
|
||||||
@@ -200,6 +202,11 @@ class AACPManager {
|
|||||||
var eqOnMedia: Boolean = false
|
var eqOnMedia: Boolean = false
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
var customEq: CustomEq = CustomEq(state = 1, low = 50, mid = 50, high = 50)
|
||||||
|
private set
|
||||||
|
|
||||||
|
var customEqCallback: ((CustomEq) -> Unit)? = null
|
||||||
|
|
||||||
fun getControlCommandStatus(identifier: ControlCommandIdentifiers): ControlCommandStatus? {
|
fun getControlCommandStatus(identifier: ControlCommandIdentifiers): ControlCommandStatus? {
|
||||||
return controlCommandStatusList.find { it.identifier == identifier }
|
return controlCommandStatusList.find { it.identifier == identifier }
|
||||||
}
|
}
|
||||||
@@ -236,7 +243,9 @@ class AACPManager {
|
|||||||
fun onConnectedDevicesReceived(connectedDevices: List<ConnectedDevice>)
|
fun onConnectedDevicesReceived(connectedDevices: List<ConnectedDevice>)
|
||||||
fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean)
|
fun onOwnershipToFalseRequest(sender: String, reasonReverseTapped: Boolean)
|
||||||
fun onShowNearbyUI(sender: String)
|
fun onShowNearbyUI(sender: String)
|
||||||
fun onEQPacketReceived(eqData: FloatArray)
|
fun onHeadphoneAccommodationReceived(eqData: FloatArray)
|
||||||
|
fun onCustomEqReceived(customEq: CustomEq)
|
||||||
|
fun onCapabilitiesReceived(capabilities: List<Capability>)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
|
fun parseStemPressResponse(data: ByteArray): Pair<StemPressType, StemPressBudType> {
|
||||||
@@ -549,18 +558,18 @@ class AACPManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Opcodes.EQ_DATA -> {
|
Opcodes.HEADPHONE_ACCOMMODATION -> {
|
||||||
if (packet.size != 140) {
|
if (packet.size != 140) {
|
||||||
Log.w(
|
Log.w(
|
||||||
TAG,
|
TAG,
|
||||||
"Received EQ_DATA packet of unexpected size: ${packet.size}, expected 140"
|
"Received HEADPHONE_ACCOMMODATION packet of unexpected size: ${packet.size}, expected 140"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (packet[6] != 0x84.toByte()) {
|
if (packet[6] != 0x84.toByte()) {
|
||||||
Log.w(
|
Log.w(
|
||||||
TAG,
|
TAG,
|
||||||
"Received EQ_DATA packet with unexpected identifier: ${packet[6].toHexString()}, expected 0x84"
|
"Received HEADPHONE_ACCOMMODATION packet with unexpected identifier: ${packet[6].toHexString()}, expected 0x84"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -583,7 +592,7 @@ class AACPManager {
|
|||||||
"EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia"
|
"EQ Data set to: ${eqData.toList()}, eqOnPhone: $eqOnPhone, eqOnMedia: $eqOnMedia"
|
||||||
)
|
)
|
||||||
|
|
||||||
callback?.onEQPacketReceived(eqData)
|
callback?.onHeadphoneAccommodationReceived(eqData)
|
||||||
}
|
}
|
||||||
|
|
||||||
Opcodes.INFORMATION -> {
|
Opcodes.INFORMATION -> {
|
||||||
@@ -592,6 +601,13 @@ class AACPManager {
|
|||||||
callback?.onDeviceInformationReceived(information)
|
callback?.onDeviceInformationReceived(information)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Opcodes.CUSTOM_EQ -> {
|
||||||
|
Log.d(TAG, "Parsing CUSTOM_EQ: ${packet.toHexString()}")
|
||||||
|
customEq = parseCustomEqPacket(packet)
|
||||||
|
customEqCallback?.invoke(customEq)
|
||||||
|
callback?.onCustomEqReceived(customEq)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
Log.d(TAG, "Unhandled opcode received: ${opcode.toHexString()}")
|
Log.d(TAG, "Unhandled opcode received: ${opcode.toHexString()}")
|
||||||
callback?.onUnknownPacketReceived(packet)
|
callback?.onUnknownPacketReceived(packet)
|
||||||
@@ -1143,7 +1159,7 @@ class AACPManager {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val socket = BluetoothConnectionManager.getCurrentSocket() ?: return false
|
val socket = BluetoothConnectionManager.aacpSocket ?: return false
|
||||||
|
|
||||||
if (socket.isConnected) {
|
if (socket.isConnected) {
|
||||||
socket.outputStream?.write(packet)
|
socket.outputStream?.write(packet)
|
||||||
@@ -1297,4 +1313,38 @@ class AACPManager {
|
|||||||
version3 = strings.getOrNull(10) ?: "",
|
version3 = strings.getOrNull(10) ?: "",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun sendCustomEqPacket(customEq: CustomEq): Boolean {
|
||||||
|
return sendDataPacket(customEq.toPacket())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseCustomEqPacket(packet: ByteArray): CustomEq {
|
||||||
|
val data = packet.sliceArray(6 until packet.size)
|
||||||
|
|
||||||
|
if (data.size < 7) {
|
||||||
|
Log.e(TAG, "custom EQ packet length less than 7, returning default")
|
||||||
|
return CustomEq(1, 50, 50, 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
val lengthLow = data[0].toInt() and 0xFF
|
||||||
|
val lengthHigh = data[1].toInt() and 0xFF
|
||||||
|
|
||||||
|
val length = (lengthHigh shl 8) or lengthLow
|
||||||
|
|
||||||
|
if (length != 5) {
|
||||||
|
Log.w(TAG, "parseCustomEqPacket: unexpected length ($length). parsing normally")
|
||||||
|
}
|
||||||
|
|
||||||
|
val state = data[3].toInt()
|
||||||
|
val low = data[4].toInt()
|
||||||
|
val mid = data[5].toInt()
|
||||||
|
val high = data[6].toInt()
|
||||||
|
|
||||||
|
return CustomEq(
|
||||||
|
state,
|
||||||
|
low,
|
||||||
|
mid,
|
||||||
|
high
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,234 +16,196 @@
|
|||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* This is a very basic ATT (Attribute Protocol) implementation. I have only implemented
|
|
||||||
* what is necessary for LibrePods to function, i.e. reading and writing characteristics,
|
|
||||||
* and receiving notifications. It is not a complete implementation of the ATT protocol.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package me.kavishdevar.librepods.bluetooth
|
package me.kavishdevar.librepods.bluetooth
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.bluetooth.BluetoothAdapter
|
|
||||||
import android.bluetooth.BluetoothDevice
|
|
||||||
import android.bluetooth.BluetoothSocket
|
|
||||||
import android.os.ParcelUuid
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.io.OutputStream
|
|
||||||
import java.util.concurrent.LinkedBlockingQueue
|
import java.util.concurrent.LinkedBlockingQueue
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
private const val TAG = "ATTManager"
|
||||||
|
|
||||||
enum class ATTHandles(val value: Int) {
|
enum class ATTHandles(val value: Int) {
|
||||||
TRANSPARENCY(0x18),
|
TRANSPARENCY(0x18),
|
||||||
LOUD_SOUND_REDUCTION(0x1B),
|
LOUD_SOUND_REDUCTION(0x1B),
|
||||||
HEARING_AID(0x2A),
|
HEARING_AID(0x2A)
|
||||||
}
|
}
|
||||||
|
|
||||||
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),
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
class ATTManager(private val adapter: BluetoothAdapter, private val device: BluetoothDevice) {
|
class ATTManagerv2 {
|
||||||
companion object {
|
val characteristicList = mutableMapOf<ATTHandles, ByteArray>()
|
||||||
private const val TAG = "ATTManager"
|
|
||||||
|
|
||||||
private const val OPCODE_READ_REQUEST: Byte = 0x0A
|
private val responseQueues = ConcurrentHashMap<Byte, LinkedBlockingQueue<ByteArray>>()
|
||||||
private const val OPCODE_WRITE_REQUEST: Byte = 0x12
|
|
||||||
private const val OPCODE_HANDLE_VALUE_NTF: Byte = 0x1B
|
private val readerRunning = AtomicBoolean(false)
|
||||||
|
private var readerThread: Thread? = null
|
||||||
|
|
||||||
|
private var onNotificationReceived: ((handle: Byte, value: ByteArray) -> Unit)? = null
|
||||||
|
|
||||||
|
fun startReader() {
|
||||||
|
if (readerRunning.getAndSet(true)) return
|
||||||
|
|
||||||
|
readerThread = Thread {
|
||||||
|
try {
|
||||||
|
runReaderLoop()
|
||||||
|
} catch (t: Throwable) {
|
||||||
|
Log.e(TAG, "reader thread crashed: ${t.message}", t)
|
||||||
|
} finally {
|
||||||
|
readerRunning.set(false)
|
||||||
|
Log.d(TAG, "reader thread stopped")
|
||||||
|
}
|
||||||
|
}.also { it.name = "ATT-Reader"; it.isDaemon = true; it.start() }
|
||||||
|
Log.d(TAG, "reader started")
|
||||||
}
|
}
|
||||||
|
|
||||||
var socket: BluetoothSocket? = null
|
fun stopReader() {
|
||||||
private var input: InputStream? = null
|
readerRunning.set(false)
|
||||||
private var output: OutputStream? = null
|
readerThread?.interrupt()
|
||||||
private val listeners = mutableMapOf<Int, MutableList<(ByteArray) -> Unit>>()
|
readerThread = null
|
||||||
private var notificationJob: Job? = null
|
|
||||||
|
|
||||||
// queue for non-notification PDUs (responses to requests)
|
|
||||||
private val responses = LinkedBlockingQueue<ByteArray>()
|
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
|
||||||
fun connect() {
|
|
||||||
val uuid = ParcelUuid.fromString("00000000-0000-0000-0000-000000000000")
|
|
||||||
|
|
||||||
try {
|
|
||||||
socket = createBluetoothSocket(adapter, device, uuid)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Failed to create socket")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setOnNotificationReceived(listener: ((handle: Byte, value: ByteArray) -> Unit)?) {
|
||||||
|
onNotificationReceived = listener
|
||||||
|
}
|
||||||
|
|
||||||
|
fun enableNotification(handle: ATTCCCDHandles) {
|
||||||
|
writeCharacteristic(handle.value.toByte(), byteArrayOf(0x01))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCharacteristic(handle: ATTHandles): ByteArray? {
|
||||||
|
val storedValue = characteristicList[handle]
|
||||||
|
return if (storedValue?.isNotEmpty() != true) {
|
||||||
|
readCharacteristic(handle)
|
||||||
|
} else storedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readCharacteristic(handle: ATTHandles, timeoutMillis: Long = 2000): ByteArray? {
|
||||||
|
val socket = BluetoothConnectionManager.attSocket ?: return null
|
||||||
try {
|
try {
|
||||||
socket!!.connect()
|
val output = socket.outputStream
|
||||||
|
val pdu = byteArrayOf(0x0A, handle.value.toByte(), 0x00)
|
||||||
|
synchronized(output) {
|
||||||
|
output.write(pdu)
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
Log.d(TAG, "sending read request: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
|
||||||
|
|
||||||
|
val resp = waitForResponse(0x0B, timeoutMillis) ?: run {
|
||||||
|
Log.e(TAG, "Timeout waiting for Read Response (0x0B) for handle ${handle.value}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "read response: ${resp.joinToString(" ") { String.format("%02X", it) }}")
|
||||||
|
val value = resp.copyOfRange(1, resp.size)
|
||||||
|
characteristicList[handle] = value
|
||||||
|
return value
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "ATT socket failed to connect")
|
Log.e(TAG, "error reading characteristic: ${e.message}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeCharacteristic(handle: ATTHandles, data: ByteArray, timeoutMillis: Long = 2000) {
|
||||||
|
characteristicList[handle] = data
|
||||||
|
writeCharacteristic(handle.value.toByte(), data, timeoutMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeCharacteristic(handle: Byte, data: ByteArray, timeoutMillis: Long = 2000) {
|
||||||
|
val socket = BluetoothConnectionManager.attSocket ?: return
|
||||||
|
try {
|
||||||
|
val output = socket.outputStream
|
||||||
|
val pdu = byteArrayOf(0x12, handle, 0x00) + data // 0x00 for LE
|
||||||
|
synchronized(output) {
|
||||||
|
output.write(pdu)
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
Log.d(TAG, "sending write request: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
|
||||||
|
|
||||||
|
val resp = waitForResponse(0x13, timeoutMillis) ?: run {
|
||||||
|
Log.e(TAG, "timeout waiting for response (0x13) for handle ${String.format("%02X", handle)}")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
input = socket!!.inputStream
|
|
||||||
output = socket!!.outputStream
|
|
||||||
Log.d(TAG, "Connected to ATT")
|
|
||||||
|
|
||||||
notificationJob = CoroutineScope(Dispatchers.IO).launch {
|
Log.d(TAG, "write respose: ${resp.joinToString(" ") { String.format("%02X", it) }}")
|
||||||
while (socket?.isConnected == true) {
|
|
||||||
try {
|
|
||||||
val pdu = readPDU()
|
|
||||||
if (pdu.isNotEmpty() && pdu[0] == OPCODE_HANDLE_VALUE_NTF) {
|
|
||||||
// notification -> dispatch to listeners
|
|
||||||
val handle = (pdu[1].toInt() and 0xFF) or ((pdu[2].toInt() and 0xFF) shl 8)
|
|
||||||
val value = pdu.copyOfRange(3, pdu.size)
|
|
||||||
listeners[handle]?.forEach { listener ->
|
|
||||||
try {
|
|
||||||
listener(value)
|
|
||||||
Log.d(TAG, "Dispatched notification for handle $handle to listener, with value ${value.joinToString(" ") { String.format("%02X", it) }}")
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Error in listener for handle $handle: ${e.message}")
|
Log.e(TAG, "error writing characteristic: ${e.message}")
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// not a notification -> treat as a response for pending request(s)
|
|
||||||
responses.put(pdu)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Error reading notification/response: ${e.message}")
|
|
||||||
if (socket?.isConnected != true) break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun disconnect() {
|
fun disconnected() {
|
||||||
|
characteristicList.clear()
|
||||||
|
stopReader()
|
||||||
|
val socket = BluetoothConnectionManager.attSocket?: return
|
||||||
try {
|
try {
|
||||||
notificationJob?.cancel()
|
socket.close()
|
||||||
socket?.close()
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "Error closing socket: ${e.message}")
|
Log.w(TAG, "error closing socket: ${e.message}")
|
||||||
}
|
}
|
||||||
|
Log.d(TAG, "ATT disconnected")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun registerListener(handle: ATTHandles, listener: (ByteArray) -> Unit) {
|
private fun runReaderLoop() {
|
||||||
listeners.getOrPut(handle.value) { mutableListOf() }.add(listener)
|
val socket = BluetoothConnectionManager.attSocket ?: run {
|
||||||
|
Log.w(TAG, "ATT socket not available. stopping reader")
|
||||||
|
readerRunning.set(false)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fun unregisterListener(handle: ATTHandles, listener: (ByteArray) -> Unit) {
|
val input = socket.inputStream
|
||||||
listeners[handle.value]?.remove(listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun enableNotifications(handle: ATTHandles) {
|
|
||||||
write(ATTCCCDHandles.valueOf(handle.name), byteArrayOf(0x01, 0x00))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun read(handle: ATTHandles): ByteArray {
|
|
||||||
val lsb = (handle.value and 0xFF).toByte()
|
|
||||||
val msb = ((handle.value shr 8) and 0xFF).toByte()
|
|
||||||
val pdu = byteArrayOf(OPCODE_READ_REQUEST, lsb, msb)
|
|
||||||
writeRaw(pdu)
|
|
||||||
// wait for response placed into responses queue by the reader coroutine
|
|
||||||
return readResponse()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun write(handle: ATTHandles, value: ByteArray) {
|
|
||||||
val lsb = (handle.value and 0xFF).toByte()
|
|
||||||
val msb = ((handle.value shr 8) and 0xFF).toByte()
|
|
||||||
val pdu = byteArrayOf(OPCODE_WRITE_REQUEST, lsb, msb) + value
|
|
||||||
writeRaw(pdu)
|
|
||||||
// usually a Write Response (0x13) will arrive; wait for it (but discard return)
|
|
||||||
try {
|
|
||||||
readResponse()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "No write response received: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun write(handle: ATTCCCDHandles, value: ByteArray) {
|
|
||||||
val lsb = (handle.value and 0xFF).toByte()
|
|
||||||
val msb = ((handle.value shr 8) and 0xFF).toByte()
|
|
||||||
val pdu = byteArrayOf(OPCODE_WRITE_REQUEST, lsb, msb) + value
|
|
||||||
writeRaw(pdu)
|
|
||||||
// usually a Write Response (0x13) will arrive; wait for it (but discard return)
|
|
||||||
try {
|
|
||||||
readResponse()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "No write response received: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun writeRaw(pdu: ByteArray) {
|
|
||||||
if (output == null) return
|
|
||||||
output?.write(pdu)
|
|
||||||
output?.flush()
|
|
||||||
Log.d(TAG, "writeRaw: ${pdu.joinToString(" ") { String.format("%02X", it) }}")
|
|
||||||
}
|
|
||||||
|
|
||||||
// rename / specialize: read raw PDU directly from input stream (blocking)
|
|
||||||
private fun readPDU(): ByteArray {
|
|
||||||
val inp = input ?: throw IllegalStateException("Not connected")
|
|
||||||
val buffer = ByteArray(512)
|
val buffer = ByteArray(512)
|
||||||
val len = inp.read(buffer)
|
|
||||||
|
while (readerRunning.get()) {
|
||||||
|
try {
|
||||||
|
val len = input.read(buffer)
|
||||||
if (len == -1) {
|
if (len == -1) {
|
||||||
disconnect()
|
Log.w(TAG, "ATT input stream ended")
|
||||||
throw IllegalStateException("End of stream reached")
|
break
|
||||||
}
|
}
|
||||||
val data = buffer.copyOfRange(0, len)
|
val data = buffer.copyOfRange(0, len)
|
||||||
Log.d(TAG, "readPDU: ${data.joinToString(" ") { String.format("%02X", it) }}")
|
if (data.isEmpty()) continue
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
// wait for a response PDU produced by the background reader
|
val opcode = data[0]
|
||||||
private fun readResponse(timeoutMs: Long = 2000): ByteArray {
|
Log.d(TAG, "pdu received ${data.joinToString(" ") { String.format("%02X", it) }}")
|
||||||
|
|
||||||
|
val queue = responseQueues.computeIfAbsent(opcode) { LinkedBlockingQueue() }
|
||||||
|
queue.offer(data)
|
||||||
|
|
||||||
|
if (opcode == 0x1B.toByte()) {
|
||||||
|
if (data.size >= 3) {
|
||||||
|
val handle = data[1]
|
||||||
|
val value = if (data.size > 3) data.copyOfRange(3, data.size) else ByteArray(0)
|
||||||
|
Log.d(TAG, "notification/indication handle=0x${String.format("%02X", handle)} value=${value.toHexString()}")
|
||||||
try {
|
try {
|
||||||
val resp = responses.poll(timeoutMs, TimeUnit.MILLISECONDS)
|
onNotificationReceived?.invoke(handle, value)
|
||||||
?: throw IllegalStateException("No response read from ATT socket within $timeoutMs ms")
|
} catch (t: Throwable) {
|
||||||
Log.d(TAG, "readResponse: ${resp.joinToString(" ") { String.format("%02X", it) }}")
|
Log.e(TAG, "onNotificationReceived threw: ${t.message}", t)
|
||||||
return resp.copyOfRange(1, resp.size)
|
}
|
||||||
} catch (e: InterruptedException) {
|
} else {
|
||||||
Thread.currentThread().interrupt()
|
Log.w(TAG, "notification PDU too short: ${data.joinToString(" ") { String.format("%02X", it) }}")
|
||||||
throw IllegalStateException("Interrupted while waiting for ATT response", e)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createBluetoothSocket(adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid): BluetoothSocket {
|
|
||||||
val type = 3 // L2CAP
|
|
||||||
val constructorSpecs = listOf(
|
|
||||||
arrayOf(adapter, device, type, true, true, 31, uuid),
|
|
||||||
arrayOf(device, type, true, true, 31, uuid),
|
|
||||||
arrayOf(device, type, 1, true, true, 31, uuid),
|
|
||||||
arrayOf(type, 1, true, true, device, 31, uuid),
|
|
||||||
arrayOf(type, true, true, device, 31, uuid)
|
|
||||||
)
|
|
||||||
|
|
||||||
val constructors = BluetoothSocket::class.java.declaredConstructors
|
|
||||||
Log.d("ATTManager", "BluetoothSocket has ${constructors.size} constructors:")
|
|
||||||
|
|
||||||
constructors.forEachIndexed { index, constructor ->
|
|
||||||
val params = constructor.parameterTypes.joinToString(", ") { it.simpleName }
|
|
||||||
Log.d("ATTManager", "Constructor $index: ($params)")
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastException: Exception? = null
|
|
||||||
var attemptedConstructors = 0
|
|
||||||
|
|
||||||
for ((index, params) in constructorSpecs.withIndex()) {
|
|
||||||
try {
|
|
||||||
Log.d("ATTManager", "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) {
|
} catch (e: Exception) {
|
||||||
Log.e("ATTManager", "Constructor signature #${index + 1} failed: ${e.message}")
|
Log.e(TAG, "error in reader loop: ${e.message}", e)
|
||||||
lastException = e
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val errorMessage = "Failed to create BluetoothSocket after trying $attemptedConstructors constructor signatures"
|
readerRunning.set(false)
|
||||||
Log.e("ATTManager", errorMessage)
|
}
|
||||||
throw lastException ?: IllegalStateException(errorMessage)
|
|
||||||
|
private fun waitForResponse(opcode: Byte, timeoutMillis: Long): ByteArray? {
|
||||||
|
val queue = responseQueues.computeIfAbsent(opcode) { LinkedBlockingQueue() }
|
||||||
|
return try {
|
||||||
|
queue.poll(timeoutMillis, TimeUnit.MILLISECONDS)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-14
@@ -18,23 +18,59 @@
|
|||||||
|
|
||||||
package me.kavishdevar.librepods.bluetooth
|
package me.kavishdevar.librepods.bluetooth
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothAdapter
|
||||||
import android.bluetooth.BluetoothDevice
|
import android.bluetooth.BluetoothDevice
|
||||||
import android.bluetooth.BluetoothSocket
|
import android.bluetooth.BluetoothSocket
|
||||||
|
import android.os.ParcelUuid
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
|
||||||
object BluetoothConnectionManager {
|
object BluetoothConnectionManager {
|
||||||
private const val TAG = "BluetoothConnectionManager"
|
var aacpSocket: BluetoothSocket? = null
|
||||||
|
var attSocket: BluetoothSocket? = null
|
||||||
private var currentSocket: BluetoothSocket? = null
|
}
|
||||||
private var currentDevice: BluetoothDevice? = null
|
|
||||||
|
fun createBluetoothSocket(
|
||||||
fun setCurrentConnection(socket: BluetoothSocket, device: BluetoothDevice) {
|
adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid, psm: Int
|
||||||
currentSocket = socket
|
): BluetoothSocket {
|
||||||
currentDevice = device
|
val type = 3 // L2CAP
|
||||||
Log.d(TAG, "Current connection set to device: ${device.address}")
|
val constructorSpecs = listOf(
|
||||||
}
|
arrayOf(adapter, device, type, true, true, psm, uuid), // A16QPR3
|
||||||
|
arrayOf(device, type, true, true, psm, uuid),
|
||||||
fun getCurrentSocket(): BluetoothSocket? {
|
arrayOf(device, type, 1, true, true, psm, uuid),
|
||||||
return currentSocket
|
arrayOf(type, 1, true, true, device, psm, uuid),
|
||||||
}
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package me.kavishdevar.librepods.data
|
||||||
|
|
||||||
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
|
|
||||||
|
enum class CustomEqBand { LOW, MID, HIGH }
|
||||||
|
|
||||||
|
data class CustomEq(val state: Int, val low: Int, val mid: Int, val high: Int) {
|
||||||
|
|
||||||
|
fun isEnabled(): Boolean {
|
||||||
|
return state == 2
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toPacket(): ByteArray {
|
||||||
|
return byteArrayOf(
|
||||||
|
AACPManager.Companion.Opcodes.CUSTOM_EQ, 0x00,
|
||||||
|
0x05, 0x00, // length (LE)
|
||||||
|
0x01, state.toByte(),
|
||||||
|
low.toByte(), mid.toByte(), high.toByte()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
require(low in 0..100) { "low must be between 0 and 100, was $low" }
|
||||||
|
require(mid in 0..100) { "mid must be between 0 and 100, was $mid" }
|
||||||
|
require(high in 0..100) { "high must be between 0 and 100, was $high" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,10 +26,10 @@ import kotlinx.coroutines.Job
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||||
import me.kavishdevar.librepods.bluetooth.ATTManager
|
|
||||||
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"
|
||||||
|
|
||||||
@@ -138,15 +138,15 @@ fun parseHearingAidSettingsResponse(data: ByteArray): HearingAidSettings? {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun sendHearingAidSettings(
|
fun sendHearingAidSettings(
|
||||||
attManager: ATTManager,
|
currentData: ByteArray,
|
||||||
hearingAidSettings: HearingAidSettings,
|
hearingAidSettings: HearingAidSettings,
|
||||||
debounceJob: MutableState<Job?>
|
debounceJob: MutableState<Job?>,
|
||||||
|
sender: (ATTHandles, ByteArray) -> Unit
|
||||||
) {
|
) {
|
||||||
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 {
|
||||||
val currentData = attManager.read(ATTHandles.HEARING_AID)
|
|
||||||
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) {
|
||||||
Log.w(TAG, "Current data size ${currentData.size} too small, cannot send settings")
|
Log.w(TAG, "Current data size ${currentData.size} too small, cannot send settings")
|
||||||
@@ -184,7 +184,7 @@ fun sendHearingAidSettings(
|
|||||||
|
|
||||||
Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
|
Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
|
||||||
|
|
||||||
attManager.write(ATTHandles.HEARING_AID, currentData)
|
sender(ATTHandles.HEARING_AID, currentData)
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ import android.os.Parcelable
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
// TODO: Remove everything but Battery-related stuff
|
||||||
|
|
||||||
enum class Enums(val value: ByteArray) {
|
enum class Enums(val value: ByteArray) {
|
||||||
NOISE_CANCELLATION(Capabilities.NOISE_CANCELLATION),
|
NOISE_CANCELLATION(byteArrayOf(0x0d)),
|
||||||
PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)),
|
PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)),
|
||||||
SETTINGS(byteArrayOf(0x09, 0x00)),
|
SETTINGS(byteArrayOf(0x09, 0x00)),
|
||||||
NOISE_CANCELLATION_PREFIX(PREFIX.value + SETTINGS.value + NOISE_CANCELLATION.value),
|
NOISE_CANCELLATION_PREFIX(PREFIX.value + SETTINGS.value + NOISE_CANCELLATION.value),
|
||||||
@@ -81,12 +83,12 @@ class AirPodsNotifications {
|
|||||||
const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED"
|
const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED"
|
||||||
const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED"
|
const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED"
|
||||||
const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS"
|
const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS"
|
||||||
const val EQ_DATA = "me.kavishdevar.librepods.EQ_DATA"
|
const val EQ_DATA = "me.kavishdevar.librepods.HEADPHONE_ACCOMMODATION"
|
||||||
const val AIRPODS_INFORMATION_UPDATED = "me.kavishdevar.librepods.AIRPODS_INFORMATION_UPDATED"
|
const val AIRPODS_INFORMATION_UPDATED = "me.kavishdevar.librepods.AIRPODS_INFORMATION_UPDATED"
|
||||||
}
|
}
|
||||||
|
|
||||||
class EarDetection {
|
class EarDetection {
|
||||||
private val notificationBit = Capabilities.EAR_DETECTION
|
private val notificationBit = 6.toByte()
|
||||||
private val notificationPrefix = Enums.PREFIX.value + notificationBit
|
private val notificationPrefix = Enums.PREFIX.value + notificationBit
|
||||||
|
|
||||||
var status: List<Byte> = listOf(0x01, 0x01)
|
var status: List<Byte> = listOf(0x01, 0x01)
|
||||||
@@ -243,13 +245,6 @@ class AirPodsNotifications {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class Capabilities {
|
|
||||||
companion object {
|
|
||||||
val NOISE_CANCELLATION = byteArrayOf(0x0d)
|
|
||||||
val EAR_DETECTION = byteArrayOf(0x06)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isHeadTrackingData(data: ByteArray): Boolean {
|
fun isHeadTrackingData(data: ByteArray): Boolean {
|
||||||
if (data.size <= 60) return false
|
if (data.size <= 60) return false
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,8 @@ data class TransparencySettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings {
|
fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings? {
|
||||||
|
if (data.size < 50) return null // 50 is arbitrary, too lazy to count
|
||||||
val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
|
val buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
|
||||||
val enabled = buffer.float
|
val enabled = buffer.float
|
||||||
|
|||||||
@@ -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 update1_0_0 = 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 = update1_0_0
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+32
-159
@@ -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]}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
DesignSystem.Material -> listOf(
|
||||||
|
serialNumbers[0],
|
||||||
|
stringResource(R.string.left) + " " + serialNumbers[1],
|
||||||
|
stringResource(R.string.right) + " " + serialNumbers[2],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val serialNumber = remember { mutableIntStateOf(0) }
|
val serialNumber = remember { mutableIntStateOf(0) }
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
StyledList (title = stringResource(R.string.about)) {
|
||||||
.fillMaxWidth()
|
StyledListItem(
|
||||||
.padding(16.dp),
|
name = stringResource(R.string.model_name),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
description = modelName
|
||||||
) {
|
|
||||||
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],
|
StyledListItem(
|
||||||
style = TextStyle(
|
name = stringResource(R.string.model_number),
|
||||||
fontSize = 16.sp,
|
description = actualModel
|
||||||
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(
|
StyledListItem (
|
||||||
thickness = 1.dp,
|
name = stringResource(R.string.serial_number),
|
||||||
color = Color(0x40888888),
|
description = serialNumbers[serialNumber.intValue],
|
||||||
modifier = Modifier
|
onClick = { serialNumber.intValue = (serialNumber.intValue + 1) % serialNumbers.size }
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
)
|
)
|
||||||
NavigationButton(
|
|
||||||
to = "version_info",
|
StyledListItem(
|
||||||
navController = navController,
|
|
||||||
name = stringResource(R.string.version),
|
name = stringResource(R.string.version),
|
||||||
currentState = version,
|
description = version,
|
||||||
independent = false,
|
onClick = navigateToVersion,
|
||||||
height = rowHeight.value + 32.dp
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-160
@@ -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))
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp)
|
|
||||||
.onGloballyPositioned { coordinates ->
|
|
||||||
rowHeight.value = with(density) { coordinates.size.height.toDp() }
|
|
||||||
},
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.version), style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
StyledListItem(
|
||||||
|
name = stringResource(R.string.build_type),
|
||||||
|
description = BuildConfig.BUILD_TYPE,
|
||||||
)
|
)
|
||||||
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))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-103
@@ -20,39 +20,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.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,
|
||||||
adaptiveAudioCapability: Boolean,
|
adaptiveAudioCapability: Boolean,
|
||||||
|
customEqCapability: Boolean,
|
||||||
|
|
||||||
adaptiveVolumeChecked: Boolean,
|
adaptiveVolumeChecked: Boolean,
|
||||||
onAdaptiveVolumeCheckedChange: (Boolean) -> Unit,
|
onAdaptiveVolumeCheckedChange: (Boolean) -> Unit,
|
||||||
@@ -63,56 +42,21 @@ 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 && !conversationalAwarenessCapability && !loudSoundReductionCapability && !adaptiveAudioCapability) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
|
||||||
){
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.audio),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(28.dp))
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
|
||||||
.padding(top = 2.dp)
|
|
||||||
) {
|
|
||||||
|
|
||||||
if (adaptiveVolumeCapability) {
|
if (adaptiveVolumeCapability) {
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.personalized_volume),
|
label = stringResource(R.string.personalized_volume),
|
||||||
description = stringResource(R.string.personalized_volume_description),
|
description = stringResource(R.string.personalized_volume_description),
|
||||||
independent = false,
|
|
||||||
checked = adaptiveVolumeChecked,
|
checked = adaptiveVolumeChecked,
|
||||||
onCheckedChange = onAdaptiveVolumeCheckedChange,
|
onCheckedChange = onAdaptiveVolumeCheckedChange,
|
||||||
enabled = isPremium
|
enabled = isPremium,
|
||||||
)
|
|
||||||
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,63 +64,35 @@ fun AudioSettings(
|
|||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.conversational_awareness),
|
label = stringResource(R.string.conversational_awareness),
|
||||||
description = stringResource(R.string.conversational_awareness_description),
|
description = stringResource(R.string.conversational_awareness_description),
|
||||||
independent = false,
|
|
||||||
checked = conversationalAwarenessChecked,
|
checked = conversationalAwarenessChecked,
|
||||||
onCheckedChange = onConversationalAwarenessCheckedChange,
|
onCheckedChange = onConversationalAwarenessCheckedChange,
|
||||||
enabled = isPremium
|
enabled = isPremium,
|
||||||
)
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loudSoundReductionCapability && vendorIdHook){
|
if (loudSoundReductionCapability && vendorIdHook) {
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.loud_sound_reduction),
|
label = stringResource(R.string.loud_sound_reduction),
|
||||||
description = stringResource(R.string.loud_sound_reduction_description),
|
description = stringResource(R.string.loud_sound_reduction_description),
|
||||||
independent = false,
|
|
||||||
checked = loudSoundReductionChecked,
|
checked = loudSoundReductionChecked,
|
||||||
onCheckedChange = onLoudSoundReductionCheckedChange,
|
onCheckedChange = onLoudSoundReductionCheckedChange,
|
||||||
enabled = isPremium
|
enabled = isPremium,
|
||||||
)
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (adaptiveAudioCapability) {
|
if (adaptiveAudioCapability) {
|
||||||
NavigationButton(
|
StyledListItem(
|
||||||
to = "adaptive_strength",
|
|
||||||
name = stringResource(R.string.adaptive_audio),
|
name = stringResource(R.string.adaptive_audio),
|
||||||
navController = navController,
|
onClick = navigateToAdaptiveStrength,
|
||||||
independent = false
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
if (customEqCapability) {
|
||||||
@Composable
|
StyledListItem(
|
||||||
fun AudioSettingsPreview() {
|
name = stringResource(R.string.equalizer),
|
||||||
AudioSettings(
|
onClick = navigateToEqualizer,
|
||||||
navController = rememberNavController(),
|
|
||||||
adaptiveVolumeCapability = true,
|
|
||||||
conversationalAwarenessCapability = true,
|
|
||||||
loudSoundReductionCapability = true,
|
|
||||||
adaptiveAudioCapability = true,
|
|
||||||
adaptiveVolumeChecked = true,
|
|
||||||
onAdaptiveVolumeCheckedChange = { },
|
|
||||||
conversationalAwarenessChecked = true,
|
|
||||||
onConversationalAwarenessCheckedChange = { },
|
|
||||||
loudSoundReductionChecked = true,
|
|
||||||
onLoudSoundReductionCheckedChange = { },
|
|
||||||
vendorIdHook = true,
|
|
||||||
isPremium = true
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-6
@@ -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,
|
||||||
|
|||||||
-3
@@ -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(
|
||||||
|
|||||||
+37
-380
@@ -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 textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
|
||||||
){
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.call_controls),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
|
||||||
.padding(top = 2.dp)
|
|
||||||
) {
|
|
||||||
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val haptics = LocalHapticFeedback.current
|
|
||||||
|
|
||||||
val pressOnceText = stringResource(R.string.press_once)
|
val pressOnceText = stringResource(R.string.press_once)
|
||||||
val pressTwiceText = stringResource(R.string.press_twice)
|
val pressTwiceText = stringResource(R.string.press_twice)
|
||||||
|
|
||||||
var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) }
|
var singlePressAction by remember { mutableStateOf(if (flipped) pressTwiceText else pressOnceText) }
|
||||||
var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) }
|
var doublePressAction by remember { mutableStateOf(if (flipped) pressOnceText else pressTwiceText) }
|
||||||
|
|
||||||
var showSinglePressDropdown by remember { mutableStateOf(false) }
|
val muteUnmuteText = stringResource(R.string.mute_unmute)
|
||||||
var touchOffsetSingle by remember { mutableStateOf<Offset?>(null) }
|
val hangUpText = stringResource(R.string.hang_up)
|
||||||
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) }
|
|
||||||
var previousIdxSingle by remember { mutableStateOf<Int?>(null) }
|
|
||||||
|
|
||||||
var showDoublePressDropdown by remember { mutableStateOf(false) }
|
StyledList(title = stringResource(R.string.call_controls)) {
|
||||||
var touchOffsetDouble by remember { mutableStateOf<Offset?>(null) }
|
StyledListItem(
|
||||||
var boxPositionDouble by remember { mutableStateOf(Offset.Zero) }
|
name = stringResource(R.string.answer_call),
|
||||||
var lastDismissTimeDouble by remember { mutableLongStateOf(0L) }
|
description = stringResource(R.string.press_once),
|
||||||
var parentHoveredIndexDouble by remember { mutableStateOf<Int?>(null) }
|
enabled = 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(
|
StyledListItem(
|
||||||
modifier = Modifier
|
name = muteUnmuteText,
|
||||||
.fillMaxWidth()
|
description = singlePressAction,
|
||||||
.padding(horizontal = 16.dp)
|
onClick = { navigateToCallControlScreen(muteUnmuteText) } ,
|
||||||
.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(
|
StyledListItem(
|
||||||
modifier = Modifier
|
name = hangUpText,
|
||||||
.fillMaxWidth()
|
description = doublePressAction,
|
||||||
.padding(horizontal = 16.dp)
|
onClick = { navigateToCallControlScreen(hangUpText) }
|
||||||
.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(
|
// StyledListItem(
|
||||||
expanded = showDoublePressDropdown,
|
// name = pressOnceText,
|
||||||
onDismissRequest = {
|
// selected = doublePressAction == pressOnceText,
|
||||||
showDoublePressDropdown = false
|
// onClick = {
|
||||||
lastDismissTimeDouble = System.currentTimeMillis()
|
// doublePressAction = pressOnceText
|
||||||
},
|
// singlePressAction = pressTwiceText
|
||||||
options = listOf(pressOnceText, pressTwiceText),
|
//
|
||||||
selectedOption = doublePressAction,
|
// onCallControlValueChanged(true)
|
||||||
touchOffset = touchOffsetDouble,
|
// }
|
||||||
boxPosition = boxPositionDouble,
|
// )
|
||||||
externalHoveredIndex = parentHoveredIndexDouble,
|
//
|
||||||
externalDragActive = parentDragActiveDouble,
|
// StyledListItem(
|
||||||
onOptionSelected = { option ->
|
// name = pressTwiceText,
|
||||||
doublePressAction = option
|
// selected = doublePressAction == pressTwiceText,
|
||||||
singlePressAction =
|
// onClick = {
|
||||||
if (option == pressOnceText) pressTwiceText else pressOnceText
|
// doublePressAction = pressTwiceText
|
||||||
showDoublePressDropdown = false
|
// singlePressAction = pressOnceText
|
||||||
val flipped = option == pressOnceText
|
//
|
||||||
onCallControlValueChanged(flipped)
|
// onCallControlValueChanged(false)
|
||||||
},
|
// }
|
||||||
hazeState = hazeState
|
// )
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-30
@@ -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,15 +80,54 @@ 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()
|
||||||
) {
|
) {
|
||||||
|
when (LocalDesignSystem.current) {
|
||||||
|
DesignSystem.Material -> {
|
||||||
|
BasicAlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
properties = DialogProperties(
|
||||||
|
dismissOnBackPress = true,
|
||||||
|
dismissOnClickOutside = false
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleMediumEmphasized
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Row(modifier = Modifier.align(Alignment.End)) {
|
||||||
|
TextButton(
|
||||||
|
onClick = onDismiss
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = dismissText,
|
||||||
|
style = MaterialTheme.typography.labelMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = onConfirm
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = confirmText,
|
||||||
|
style = MaterialTheme.typography.labelMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DesignSystem.Apple -> {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
@@ -93,6 +140,7 @@ fun ConfirmationDialog(
|
|||||||
.clickable(enabled = false, onClick = {}),
|
.clickable(enabled = false, onClick = {}),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.requiredWidthIn(min = 200.dp, max = 360.dp)
|
.requiredWidthIn(min = 200.dp, max = 360.dp)
|
||||||
@@ -108,30 +156,24 @@ fun ConfirmationDialog(
|
|||||||
},
|
},
|
||||||
onDrawSurface = {
|
onDrawSurface = {
|
||||||
drawRect(
|
drawRect(
|
||||||
if (isDarkTheme) Color(0xFF1F1F1F).copy(alpha = 0.35f) else Color(0xFFE0E0E0).copy(alpha = 0.7f)
|
if (isDarkTheme) Color(0xFF1F1F1F).copy(alpha = 0.35f) else Color(
|
||||||
|
0xFFE0E0E0
|
||||||
|
).copy(alpha = 0.7f)
|
||||||
)
|
)
|
||||||
})) {
|
})
|
||||||
|
) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style = TextStyle(
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp)
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
message,
|
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,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp)
|
modifier = Modifier.padding(horizontal = 16.dp)
|
||||||
)
|
)
|
||||||
@@ -144,29 +186,24 @@ fun ConfirmationDialog(
|
|||||||
onClick = onDismiss,
|
onClick = onDismiss,
|
||||||
backdrop = innerBackdrop,
|
backdrop = innerBackdrop,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
|
materialButtonStyle = MaterialButtonStyle.Outlined,
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = dismissText, style = TextStyle(
|
text = dismissText,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
style = MaterialTheme.typography.bodyMedium
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
StyledButton(
|
StyledButton(
|
||||||
onClick = onConfirm,
|
onClick = onConfirm,
|
||||||
backdrop = innerBackdrop,
|
backdrop = innerBackdrop,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
surfaceColor = accentColor
|
materialButtonStyle = MaterialButtonStyle.Filled,
|
||||||
|
surfaceColor = MaterialTheme.colorScheme.primary
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = confirmText, style = TextStyle(
|
text = confirmText,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontWeight = FontWeight.Medium,
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
fontSize = 14.sp,
|
|
||||||
color = Color.White
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,4 +213,6 @@ fun ConfirmationDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-27
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
+39
-218
@@ -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(
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
name = stringResource(R.string.manufacturer),
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
description = Build.MANUFACTURER,
|
||||||
|
enabled = false
|
||||||
val rowHeight = remember { mutableStateOf(0.dp) }
|
|
||||||
val density = LocalDensity.current
|
|
||||||
|
|
||||||
Column (
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
|
|
||||||
.padding(start = 16.dp, top = 24.dp, end = 4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.device_info), style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
StyledListItem(
|
||||||
|
name = stringResource(R.string.model_number),
|
||||||
|
description = Build.MODEL,
|
||||||
|
enabled = false
|
||||||
)
|
)
|
||||||
}
|
|
||||||
Column(
|
StyledListItem(
|
||||||
modifier = Modifier
|
name = stringResource(R.string.build_id),
|
||||||
.clip(RoundedCornerShape(28.dp))
|
description = Build.DISPLAY,
|
||||||
.fillMaxWidth()
|
enabled = false
|
||||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
)
|
||||||
) {
|
|
||||||
Row(
|
StyledListItem(
|
||||||
modifier = Modifier
|
name = stringResource(R.string.version),
|
||||||
.fillMaxWidth()
|
description = "${Build.ID} (${Build.VERSION.SDK_INT_FULL})",
|
||||||
.padding(16.dp)
|
enabled = false
|
||||||
.onGloballyPositioned { coordinates ->
|
)
|
||||||
rowHeight.value = with(density) { coordinates.size.height.toDp() }
|
|
||||||
|
StyledListItem(
|
||||||
|
name = stringResource(R.string.xposed_available),
|
||||||
|
description = if (XposedState.isAvailable) {
|
||||||
|
stringResource(R.string.yes)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.no)
|
||||||
},
|
},
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
enabled = false
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.manufacturer), style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
StyledListItem(
|
||||||
|
name = stringResource(R.string.app_enabled_in_xposed),
|
||||||
|
description = if (XposedState.bluetoothScopeEnabled) {
|
||||||
|
stringResource(R.string.yes)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.no)
|
||||||
|
},
|
||||||
|
enabled = false
|
||||||
)
|
)
|
||||||
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))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-69
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-263
@@ -1,263 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-179
@@ -1,179 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <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))
|
|
||||||
}
|
|
||||||
+141
-17
@@ -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,14 +90,109 @@ fun NoiseControlSettings(
|
|||||||
noiseControlModeValue: Int,
|
noiseControlModeValue: Int,
|
||||||
onNoiseControlModeChanged: (Int) -> Unit
|
onNoiseControlModeChanged: (Int) -> Unit
|
||||||
) {
|
) {
|
||||||
|
when (LocalDesignSystem.current) {
|
||||||
|
DesignSystem.Material -> {
|
||||||
|
val options = buildList {
|
||||||
|
if (showOffListeningMode) {
|
||||||
|
add(
|
||||||
|
Triple(
|
||||||
|
NoiseControlMode.OFF,
|
||||||
|
R.string.off,
|
||||||
|
R.drawable.noise_cancellation
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
add(
|
||||||
|
Triple(
|
||||||
|
NoiseControlMode.TRANSPARENCY,
|
||||||
|
R.string.transparency,
|
||||||
|
R.drawable.transparency
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
Triple(
|
||||||
|
NoiseControlMode.ADAPTIVE,
|
||||||
|
R.string.adaptive,
|
||||||
|
R.drawable.adaptive
|
||||||
|
)
|
||||||
|
)
|
||||||
|
add(
|
||||||
|
Triple(
|
||||||
|
NoiseControlMode.NOISE_CANCELLATION,
|
||||||
|
R.string.noise_cancellation,
|
||||||
|
R.drawable.noise_cancellation
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val selectedMode = NoiseControlMode.entries[(noiseControlModeValue - 1).coerceIn(0, NoiseControlMode.entries.lastIndex)]
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(top = 4.dp, bottom = 12.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.noise_control),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
style = MaterialTheme.typography.labelSmallEmphasized
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
|
||||||
|
) {
|
||||||
|
options.forEachIndexed { index, (mode, labelRes, iconRes) ->
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
) {
|
||||||
|
ToggleButton(
|
||||||
|
checked = selectedMode == mode,
|
||||||
|
onCheckedChange = {
|
||||||
|
if (it) {
|
||||||
|
onNoiseControlModeChanged(mode.ordinal + 1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shapes = when (index) {
|
||||||
|
0 -> ButtonGroupDefaults.connectedLeadingButtonShapes()
|
||||||
|
options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
|
||||||
|
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
|
||||||
|
},
|
||||||
|
colors = ToggleButtonDefaults.toggleButtonColors()
|
||||||
|
.copy(containerColor = MaterialTheme.colorScheme.surface),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
bitmap = ImageBitmap.imageResource(iconRes),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(42.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(labelRes),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
maxLines = 2,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DesignSystem.Apple -> {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
val textColorSelected = 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 selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) }
|
val noiseControlMode = remember { mutableStateOf(NoiseControlMode.OFF) }
|
||||||
|
|
||||||
val d1a = remember { mutableFloatStateOf(0f) }
|
val d1a = remember { mutableFloatStateOf(0f) }
|
||||||
@@ -140,20 +246,16 @@ fun NoiseControlSettings(
|
|||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
.padding(horizontal = 16.dp)
|
||||||
){
|
.padding(top = 4.dp, bottom = 4.dp)
|
||||||
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.noise_control),
|
text = stringResource(R.string.noise_control),
|
||||||
style = TextStyle(
|
color = MaterialTheme.colorScheme.sectionHeader,
|
||||||
fontSize = 14.sp,
|
style = MaterialTheme.typography.labelSmallEmphasized
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
BoxWithConstraints(
|
BoxWithConstraints(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -280,9 +382,10 @@ fun NoiseControlSettings(
|
|||||||
onDragStarted = { isDragging.value = true },
|
onDragStarted = { isDragging.value = true },
|
||||||
onDragStopped = {
|
onDragStopped = {
|
||||||
isDragging.value = false
|
isDragging.value = false
|
||||||
val position = dragOffset / with(density) { buttonWidth.toPx() }
|
val position =
|
||||||
|
dragOffset / with(density) { buttonWidth.toPx() }
|
||||||
val newIndex = position.roundToInt()
|
val newIndex = position.roundToInt()
|
||||||
val newMode = when(newIndex) {
|
val newMode = when (newIndex) {
|
||||||
0 -> if (showOffListeningMode) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY
|
0 -> if (showOffListeningMode) NoiseControlMode.OFF else NoiseControlMode.TRANSPARENCY
|
||||||
1 -> if (showOffListeningMode) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE
|
1 -> if (showOffListeningMode) NoiseControlMode.TRANSPARENCY else NoiseControlMode.ADAPTIVE
|
||||||
2 -> if (showOffListeningMode) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION
|
2 -> if (showOffListeningMode) NoiseControlMode.ADAPTIVE else NoiseControlMode.NOISE_CANCELLATION
|
||||||
@@ -394,4 +497,25 @@ fun NoiseControlSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun NoiseControlSettingsPreview() {
|
||||||
|
LibrePodsTheme(
|
||||||
|
m3eEnabled = true
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
) {
|
||||||
|
NoiseControlSettings(
|
||||||
|
showOffListeningMode = false,
|
||||||
|
noiseControlModeValue = 2,
|
||||||
|
onNoiseControlModeChanged = { }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-61
@@ -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,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+81
-6
@@ -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,8 +92,46 @@ 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,
|
||||||
) {
|
) {
|
||||||
|
when (LocalDesignSystem.current) {
|
||||||
|
DesignSystem.Material -> {
|
||||||
|
when (materialButtonStyle) {
|
||||||
|
MaterialButtonStyle.Filled -> {
|
||||||
|
Button(
|
||||||
|
modifier = modifier.height(48.dp),
|
||||||
|
onClick = onClick,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
MaterialButtonStyle.Tonal -> {
|
||||||
|
FilledTonalButton(
|
||||||
|
modifier = modifier.height(48.dp),
|
||||||
|
onClick = onClick,
|
||||||
|
content = content,
|
||||||
|
colors = ButtonDefaults.filledTonalButtonColors(containerColor = surfaceColor)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialButtonStyle.Outlined -> {
|
||||||
|
OutlinedButton(
|
||||||
|
modifier = modifier.height(48.dp),
|
||||||
|
onClick = onClick,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialButtonStyle.Normal -> {
|
||||||
|
TextButton(
|
||||||
|
modifier = modifier.height(48.dp),
|
||||||
|
onClick = onClick,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DesignSystem.Apple -> {
|
||||||
val isInteractive = enabled && isInteractive
|
val isInteractive = enabled && isInteractive
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val haptics = LocalHapticFeedback.current
|
val haptics = LocalHapticFeedback.current
|
||||||
@@ -140,7 +192,7 @@ half4 main(float2 coord) {
|
|||||||
}
|
}
|
||||||
drawRect(color)
|
drawRect(color)
|
||||||
} else {
|
} else {
|
||||||
if (isPressed) {
|
if (isPressed && enabled) {
|
||||||
drawRect(Color.Black.copy(alpha = 0.4f))
|
drawRect(Color.Black.copy(alpha = 0.4f))
|
||||||
drawRect(Color.White.copy(alpha = 0.2f))
|
drawRect(Color.White.copy(alpha = 0.2f))
|
||||||
}
|
}
|
||||||
@@ -219,7 +271,8 @@ half4 main(float2 coord) {
|
|||||||
blendMode = BlendMode.Plus
|
blendMode = BlendMode.Plus
|
||||||
)
|
)
|
||||||
interactiveHighlightShader.apply {
|
interactiveHighlightShader.apply {
|
||||||
val offset = pressStartPosition + offsetAnimation.value
|
val offset =
|
||||||
|
pressStartPosition + offsetAnimation.value
|
||||||
setFloatUniform("size", size.width, size.height)
|
setFloatUniform("size", size.width, size.height)
|
||||||
setColorUniform(
|
setColorUniform(
|
||||||
"color",
|
"color",
|
||||||
@@ -262,11 +315,22 @@ half4 main(float2 coord) {
|
|||||||
if (isInteractive) {
|
if (isInteractive) {
|
||||||
Modifier.pointerInput(scope) {
|
Modifier.pointerInput(scope) {
|
||||||
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
||||||
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
|
val offsetAnimationSpec =
|
||||||
|
spring(1f, 300f, Offset.VisibilityThreshold)
|
||||||
val onDragStop: () -> Unit = {
|
val onDragStop: () -> Unit = {
|
||||||
|
if (enabled) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
|
launch {
|
||||||
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
haptics.performHapticFeedback(
|
||||||
|
HapticFeedbackType.Reject
|
||||||
|
)
|
||||||
|
}
|
||||||
|
launch {
|
||||||
|
progressAnimation.animateTo(
|
||||||
|
0f,
|
||||||
|
progressAnimationSpec
|
||||||
|
)
|
||||||
|
}
|
||||||
launch {
|
launch {
|
||||||
offsetAnimation.animateTo(
|
offsetAnimation.animateTo(
|
||||||
Offset.Zero,
|
Offset.Zero,
|
||||||
@@ -275,11 +339,17 @@ half4 main(float2 coord) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
inspectDragGestures(
|
inspectDragGestures(
|
||||||
onDragStart = { down ->
|
onDragStart = { down ->
|
||||||
pressStartPosition = down.position
|
pressStartPosition = down.position
|
||||||
|
if (enabled) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
|
launch {
|
||||||
|
haptics.performHapticFeedback(
|
||||||
|
HapticFeedbackType.SegmentFrequentTick
|
||||||
|
)
|
||||||
|
}
|
||||||
launch {
|
launch {
|
||||||
progressAnimation.animateTo(
|
progressAnimation.animateTo(
|
||||||
1f,
|
1f,
|
||||||
@@ -288,12 +358,14 @@ half4 main(float2 coord) {
|
|||||||
}
|
}
|
||||||
launch { offsetAnimation.snapTo(Offset.Zero) }
|
launch { offsetAnimation.snapTo(Offset.Zero) }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onDragEnd = {
|
onDragEnd = {
|
||||||
onDragStop()
|
onDragStop()
|
||||||
},
|
},
|
||||||
onDragCancel = onDragStop
|
onDragCancel = onDragStop
|
||||||
) { _, dragAmount ->
|
) { _, dragAmount ->
|
||||||
|
if (enabled) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
|
if (dragAmount.getDistanceSquared() > 350) haptics.performHapticFeedback(
|
||||||
HapticFeedbackType.SegmentFrequentTick
|
HapticFeedbackType.SegmentFrequentTick
|
||||||
@@ -302,6 +374,7 @@ half4 main(float2 coord) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Modifier.pointerInput(Unit) {
|
Modifier.pointerInput(Unit) {
|
||||||
detectTapGestures(
|
detectTapGestures(
|
||||||
@@ -326,4 +399,6 @@ half4 main(float2 coord) {
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
content = content
|
content = content
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-259
@@ -1,259 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+142
-11
@@ -18,16 +18,26 @@
|
|||||||
|
|
||||||
package me.kavishdevar.librepods.presentation.components
|
package me.kavishdevar.librepods.presentation.components
|
||||||
|
|
||||||
|
import android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||||
|
import android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||||
import android.graphics.RuntimeShader
|
import android.graphics.RuntimeShader
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.animation.core.Animatable
|
import androidx.compose.animation.core.Animatable
|
||||||
import androidx.compose.animation.core.VectorConverter
|
import androidx.compose.animation.core.VectorConverter
|
||||||
import androidx.compose.animation.core.VisibilityThreshold
|
import androidx.compose.animation.core.VisibilityThreshold
|
||||||
import androidx.compose.animation.core.spring
|
import androidx.compose.animation.core.spring
|
||||||
|
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.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.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
|
||||||
@@ -36,6 +46,7 @@ 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.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.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.BlendMode
|
import androidx.compose.ui.graphics.BlendMode
|
||||||
@@ -44,6 +55,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.ShaderBrush
|
import androidx.compose.ui.graphics.ShaderBrush
|
||||||
import androidx.compose.ui.graphics.TileMode
|
import androidx.compose.ui.graphics.TileMode
|
||||||
import androidx.compose.ui.graphics.drawOutline
|
import androidx.compose.ui.graphics.drawOutline
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
import androidx.compose.ui.graphics.drawscope.translate
|
import androidx.compose.ui.graphics.drawscope.translate
|
||||||
import androidx.compose.ui.graphics.isSpecified
|
import androidx.compose.ui.graphics.isSpecified
|
||||||
import androidx.compose.ui.graphics.layer.CompositingStrategy
|
import androidx.compose.ui.graphics.layer.CompositingStrategy
|
||||||
@@ -58,6 +70,8 @@ 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.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.DpOffset
|
||||||
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.util.fastCoerceAtMost
|
import androidx.compose.ui.util.fastCoerceAtMost
|
||||||
@@ -68,9 +82,11 @@ import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
|||||||
import com.kyant.backdrop.drawBackdrop
|
import com.kyant.backdrop.drawBackdrop
|
||||||
import com.kyant.backdrop.effects.lens
|
import com.kyant.backdrop.effects.lens
|
||||||
import com.kyant.backdrop.highlight.Highlight
|
import com.kyant.backdrop.highlight.Highlight
|
||||||
import com.kyant.backdrop.shadow.Shadow
|
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
|
||||||
@@ -86,8 +102,75 @@ 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
|
||||||
) {
|
) {
|
||||||
|
when (LocalDesignSystem.current) {
|
||||||
|
DesignSystem.Material -> {
|
||||||
|
when (materialButtonStyle) {
|
||||||
|
MaterialButtonStyle.Tonal -> {
|
||||||
|
FilledTonalIconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = Modifier.size(52.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = icon,
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MaterialButtonStyle.Filled -> {
|
||||||
|
FilledIconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = Modifier.size(52.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = icon,
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MaterialButtonStyle.Outlined -> {
|
||||||
|
OutlinedIconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = Modifier.size(52.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = icon,
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MaterialButtonStyle.Normal -> {
|
||||||
|
IconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = Modifier.size(52.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = icon,
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 20.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DesignSystem.Apple -> {
|
||||||
val haptics = LocalHapticFeedback.current
|
val haptics = LocalHapticFeedback.current
|
||||||
val darkMode = isSystemInDarkTheme()
|
val darkMode = isSystemInDarkTheme()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -136,11 +219,14 @@ half4 main(float2 coord) {
|
|||||||
backdrop = backdrop,
|
backdrop = backdrop,
|
||||||
shape = { RoundedCornerShape(56.dp) },
|
shape = { RoundedCornerShape(56.dp) },
|
||||||
highlight = { Highlight.Ambient.copy(alpha = if (isDarkTheme) 1f else 0f) },
|
highlight = { Highlight.Ambient.copy(alpha = if (isDarkTheme) 1f else 0f) },
|
||||||
shadow = {
|
innerShadow = {
|
||||||
Shadow(
|
if (isDarkTheme) {
|
||||||
radius = 12f.dp,
|
InnerShadow(
|
||||||
color = Color.Black.copy(if (isDarkTheme) 0.08f else 0.2f)
|
radius = 0.5.dp,
|
||||||
|
offset = DpOffset(1.dp, 1.dp),
|
||||||
|
color = Color.White.copy(0.6f),
|
||||||
)
|
)
|
||||||
|
} else InnerShadow()
|
||||||
},
|
},
|
||||||
layerBlock = {
|
layerBlock = {
|
||||||
if (!enabled) return@drawBackdrop
|
if (!enabled) return@drawBackdrop
|
||||||
@@ -153,8 +239,10 @@ half4 main(float2 coord) {
|
|||||||
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 = maxOffset * tanh(initialDerivative * offset.x / maxOffset)
|
translationX =
|
||||||
translationY = maxOffset * tanh(initialDerivative * offset.y / maxOffset)
|
maxOffset * tanh(initialDerivative * offset.x / maxOffset)
|
||||||
|
translationY =
|
||||||
|
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)
|
||||||
@@ -204,6 +292,14 @@ half4 main(float2 coord) {
|
|||||||
drawRect(surfaceColor)
|
drawRect(surfaceColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isDarkTheme) {
|
||||||
|
drawOutline(
|
||||||
|
outline = outline,
|
||||||
|
color = Color.Black.copy(0.4f),
|
||||||
|
style = Stroke(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
drawRect(
|
drawRect(
|
||||||
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(
|
(if (isDarkTheme) Color(0xFFAFAFAF) else Color.White).copy(
|
||||||
progress.coerceIn(
|
progress.coerceIn(
|
||||||
@@ -262,8 +358,18 @@ half4 main(float2 coord) {
|
|||||||
if (enabled) {
|
if (enabled) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
|
launch { haptics.performHapticFeedback(HapticFeedbackType.Reject) }
|
||||||
launch { progressAnimation.animateTo(0f, progressAnimationSpec) }
|
launch {
|
||||||
launch { offsetAnimation.animateTo(Offset.Zero, offsetAnimationSpec) }
|
progressAnimation.animateTo(
|
||||||
|
0f,
|
||||||
|
progressAnimationSpec
|
||||||
|
)
|
||||||
|
}
|
||||||
|
launch {
|
||||||
|
offsetAnimation.animateTo(
|
||||||
|
Offset.Zero,
|
||||||
|
offsetAnimationSpec
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -273,7 +379,12 @@ half4 main(float2 coord) {
|
|||||||
pressStartPosition = down.position
|
pressStartPosition = down.position
|
||||||
scope.launch {
|
scope.launch {
|
||||||
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
|
launch { haptics.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick) }
|
||||||
launch { progressAnimation.animateTo(1f, progressAnimationSpec) }
|
launch {
|
||||||
|
progressAnimation.animateTo(
|
||||||
|
1f,
|
||||||
|
progressAnimationSpec
|
||||||
|
)
|
||||||
|
}
|
||||||
launch { offsetAnimation.snapTo(Offset.Zero) }
|
launch { offsetAnimation.snapTo(Offset.Zero) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,4 +414,24 @@ half4 main(float2 coord) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(uiMode = UI_MODE_NIGHT_NO, name = "Light")
|
||||||
|
@Preview(uiMode = UI_MODE_NIGHT_YES, name = "Dark")
|
||||||
|
@Composable
|
||||||
|
fun StyledIconButtonPreview() {
|
||||||
|
Box(modifier = Modifier
|
||||||
|
.height(120.dp)
|
||||||
|
.width(200.dp)
|
||||||
|
.background(
|
||||||
|
if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7),
|
||||||
|
RoundedCornerShape(28.dp)
|
||||||
|
), contentAlignment = Alignment.Center) {
|
||||||
|
StyledIconButton(
|
||||||
|
icon = "",
|
||||||
|
onClick = { }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-4
@@ -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,8 +49,28 @@ fun StyledInputField(
|
|||||||
inputState: TextFieldState,
|
inputState: TextFieldState,
|
||||||
focusRequester: FocusRequester,
|
focusRequester: FocusRequester,
|
||||||
placeholder: String = "",
|
placeholder: String = "",
|
||||||
singleLine: Boolean = true
|
singleLine: Boolean = true,
|
||||||
){
|
forceApple: Boolean = false
|
||||||
|
) {
|
||||||
|
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material && !forceApple
|
||||||
|
|
||||||
|
if(m3eEnabled) {
|
||||||
|
TextField(
|
||||||
|
state = inputState,
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
text = placeholder,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
lineLimits = if (singleLine) TextFieldLineLimits.SingleLine else TextFieldLineLimits.Default,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = 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
|
||||||
@@ -62,7 +85,7 @@ fun StyledInputField(
|
|||||||
|
|
||||||
val transition = updateTransition(hasText, label = "floating")
|
val transition = updateTransition(hasText, label = "floating")
|
||||||
val yOffset by transition.animateDp(label = "y") {
|
val yOffset by transition.animateDp(label = "y") {
|
||||||
if (it) with (density) { (-48).sp.toDp() } else 0.dp
|
if (it) with(density) { (-48).sp.toDp() } else 0.dp
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(spacerHeight))
|
||||||
@@ -151,4 +174,5 @@ fun StyledInputField(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+132
@@ -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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+409
@@ -0,0 +1,409 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+178
-46
@@ -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,39 +66,116 @@ 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.HazeProgressive
|
|
||||||
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,
|
||||||
|
showBackButton: Boolean = false,
|
||||||
|
onNavigateBack: () -> Unit = {},
|
||||||
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
|
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
|
||||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||||
content: @Composable (spacerValue: Dp, hazeState: HazeState, bottomPadding: Dp) -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val hazeState = rememberHazeState(blurEnabled = true)
|
val hazeState = rememberHazeState(blurEnabled = true)
|
||||||
|
|
||||||
|
when (LocalDesignSystem.current) {
|
||||||
|
DesignSystem.Material -> {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7),
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
|
topBar = {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
enter = fadeIn() + slideInVertically(initialOffsetY = { -it }),
|
||||||
|
exit = fadeOut() + slideOutVertically(targetOffsetY = { -it })
|
||||||
|
) {
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
if (showBackButton) {
|
||||||
|
Row {
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
FilledTonalIconButton(
|
||||||
|
onClick = onNavigateBack,
|
||||||
|
modifier = Modifier
|
||||||
|
.minimumInteractiveComponentSize()
|
||||||
|
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Narrow)),
|
||||||
|
shape = IconButtonDefaults.mediumRoundShape
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Default.ArrowBack,
|
||||||
|
contentDescription = "",
|
||||||
|
modifier = Modifier.size(IconButtonDefaults.mediumIconSize),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Crossfade(targetState = title) {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(start = if (showBackButton) 8.dp else 12.dp, end = 12.dp),
|
||||||
|
style = MaterialTheme.typography.titleSmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
actionButtons.forEach { actionButton ->
|
||||||
|
actionButton(rememberLayerBackdrop())
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { paddingValues ->
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.then(if (visible) Modifier.padding(paddingValues) else Modifier)
|
||||||
|
.fillMaxSize()
|
||||||
|
.hazeSource(hazeState)
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DesignSystem.Apple -> {
|
||||||
|
Scaffold(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.then(if (!isDarkTheme) Modifier.shadow(elevation = 36.dp, shape = RoundedCornerShape(52.dp), ambientColor = Color.Black, spotColor = Color.Black) else 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))
|
.clip(RoundedCornerShape(52.dp))
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
val topPadding = paddingValues.calculateTopPadding()
|
val topPadding = paddingValues.calculateTopPadding()
|
||||||
val bottomPadding = paddingValues.calculateBottomPadding() + 16.dp
|
|
||||||
val startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current)
|
val startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current)
|
||||||
val endPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current)
|
val endPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current)
|
||||||
|
|
||||||
@@ -87,24 +185,69 @@ fun StyledScaffold(
|
|||||||
.padding(start = startPadding, end = endPadding)
|
.padding(start = startPadding, end = endPadding)
|
||||||
) {
|
) {
|
||||||
val backdrop = rememberLayerBackdrop()
|
val backdrop = rememberLayerBackdrop()
|
||||||
Box(
|
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
|
modifier = Modifier
|
||||||
.zIndex(2f)
|
.zIndex(2f)
|
||||||
.height(64.dp + topPadding)
|
.height(64.dp + topPadding)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.layerBackdrop(backdrop)
|
.layerBackdrop(backdrop)
|
||||||
.hazeEffect(state = hazeState) {
|
){
|
||||||
tints = listOf(HazeTint(color = if (isDarkTheme) Color.Black else Color.White))
|
Box(
|
||||||
progressive = HazeProgressive.verticalGradient(startIntensity = 1f, endIntensity = 0f)
|
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()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
Spacer(modifier = Modifier.height(topPadding + 12.dp))
|
Spacer(modifier = Modifier.height(topPadding + 12.dp))
|
||||||
|
Crossfade(targetState = title) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = it,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 20.sp,
|
fontSize = 20.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = if (isDarkTheme) Color.White else Color.Black,
|
color = if (isDarkTheme) Color.White else Color.Black,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
),
|
),
|
||||||
@@ -113,51 +256,40 @@ fun StyledScaffold(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible && actionButtons.isNotEmpty(),
|
||||||
|
enter = fadeIn() + scaleIn(
|
||||||
|
initialScale = 0f,
|
||||||
|
animationSpec = tween()
|
||||||
|
),
|
||||||
|
exit = fadeOut() + scaleOut(
|
||||||
|
targetScale = 0.5f,
|
||||||
|
animationSpec = tween(100)
|
||||||
|
),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.zIndex(3f)
|
.zIndex(3f)
|
||||||
.padding(top = topPadding, end = 8.dp)
|
.padding(top = topPadding, end = 8.dp)
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopEnd)
|
||||||
) {
|
) {
|
||||||
|
Row{
|
||||||
actionButtons.forEach { actionButton ->
|
actionButtons.forEach { actionButton ->
|
||||||
actionButton(backdrop)
|
actionButton(backdrop)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
content(topPadding + 64.dp, hazeState, bottomPadding + 12.dp)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
Box(
|
||||||
@Composable
|
modifier = modifier
|
||||||
fun StyledScaffold(
|
.hazeSource(hazeState)
|
||||||
title: String,
|
.fillMaxSize()
|
||||||
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
|
) {
|
||||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
|
||||||
content: @Composable () -> Unit
|
|
||||||
) {
|
|
||||||
StyledScaffold(
|
|
||||||
title = title,
|
|
||||||
actionButtons = actionButtons,
|
|
||||||
snackbarHostState = snackbarHostState,
|
|
||||||
) { _, _, _->
|
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
@Composable
|
}
|
||||||
fun StyledScaffold(
|
|
||||||
title: String,
|
|
||||||
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
|
|
||||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
|
||||||
content: @Composable (spacerValue: Dp) -> Unit
|
|
||||||
) {
|
|
||||||
StyledScaffold(
|
|
||||||
title = title,
|
|
||||||
actionButtons = actionButtons,
|
|
||||||
snackbarHostState = snackbarHostState,
|
|
||||||
) { spacerValue, _, _ ->
|
|
||||||
content(spacerValue)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-188
@@ -1,188 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+219
-42
@@ -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,21 +229,152 @@ 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 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 {
|
||||||
|
label?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
style = MaterialTheme.typography.labelSmallEmphasized,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(top = 4.dp, bottom = 12.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SegmentedListItem(
|
||||||
|
shapes = ListItemDefaults.shapes().copy(
|
||||||
|
shape = defaultShape,
|
||||||
|
pressedShape = RoundedCornerShape(24.dp),
|
||||||
|
selectedShape = RoundedCornerShape(24.dp),
|
||||||
|
hoveredShape = RoundedCornerShape(24.dp),
|
||||||
|
),
|
||||||
|
onClick = {},
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = Modifier.heightIn(min = 58.dp),
|
||||||
|
content = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
description?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startLabel != null || endLabel != null) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
startLabel?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.labelSmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
|
||||||
|
endLabel?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.labelSmall
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Column {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
|
||||||
|
startIcon?.let {
|
||||||
|
Text(it, fontFamily = FontFamily(Font(R.font.sf_pro)))
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
value = value,
|
||||||
|
onValueChange = { newValue ->
|
||||||
|
val snapped =
|
||||||
|
if (snapPoints.isNotEmpty()) {
|
||||||
|
snapIfClose(
|
||||||
|
newValue,
|
||||||
|
snapPoints,
|
||||||
|
snapThreshold
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
newValue
|
||||||
|
}
|
||||||
|
|
||||||
|
onValueChange(snapped)
|
||||||
|
},
|
||||||
|
valueRange = valueRange,
|
||||||
|
enabled = enabled
|
||||||
|
)
|
||||||
|
|
||||||
|
endIcon?.let {
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Text(it, fontFamily = FontFamily(Font(R.font.sf_pro)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (index + 1 != count) {
|
||||||
|
Spacer(
|
||||||
|
modifier = Modifier.height(2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DesignSystem.Apple -> {
|
||||||
|
val backgroundColor =
|
||||||
|
if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val trackColor =
|
val trackColor =
|
||||||
if (isLightTheme) Color(0xFF787878).copy(0.2f)
|
if (isDarkTheme) Color(0xFF787880).copy(0.36f)
|
||||||
else Color(0xFF787880).copy(0.36f)
|
else Color(0xFF787878).copy(0.2f)
|
||||||
val accentColor =
|
val accentColor =
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
if (isLightTheme) Color(0xFF0088FF)
|
if (isDarkTheme) Color(0xFF0091FF)
|
||||||
else Color(0xFF0091FF)
|
else Color(0xFF0088FF)
|
||||||
} else {
|
} else {
|
||||||
trackColor
|
trackColor
|
||||||
}
|
}
|
||||||
val labelTextColor = if (isLightTheme) Color.Black else Color.White
|
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||||
|
|
||||||
val fraction by derivedStateOf {
|
val fraction by derivedStateOf {
|
||||||
((value - valueRange.start) / (valueRange.endInclusive - valueRange.start))
|
((value - valueRange.start) / (valueRange.endInclusive - valueRange.start))
|
||||||
@@ -302,7 +441,11 @@ fun StyledSlider(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 4.dp)
|
.padding(vertical = 4.dp)
|
||||||
.then(if (startIcon == null && endIcon == null) Modifier.padding(horizontal = 8.dp) else Modifier),
|
.then(
|
||||||
|
if (startIcon == null && endIcon == null) Modifier.padding(
|
||||||
|
horizontal = 8.dp
|
||||||
|
) else Modifier
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -320,14 +463,17 @@ fun StyledSlider(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 12.dp)
|
.padding(horizontal = 12.dp)
|
||||||
.onGloballyPositioned {
|
.onGloballyPositioned {
|
||||||
startIconWidthState.floatValue = it.size.width.toFloat()
|
startIconWidthState.floatValue =
|
||||||
|
it.size.width.toFloat()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.onSizeChanged { trackWidthState.floatValue = it.width.toFloat() }
|
.onSizeChanged {
|
||||||
|
trackWidthState.floatValue = it.width.toFloat()
|
||||||
|
}
|
||||||
.onGloballyPositioned {
|
.onGloballyPositioned {
|
||||||
trackPositionState.floatValue =
|
trackPositionState.floatValue =
|
||||||
it.positionInParent().y + it.size.height / 2f
|
it.positionInParent().y + it.size.height / 2f
|
||||||
@@ -369,19 +515,27 @@ fun StyledSlider(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 12.dp)
|
.padding(horizontal = 12.dp)
|
||||||
.onGloballyPositioned {
|
.onGloballyPositioned {
|
||||||
endIconWidthState.floatValue = it.size.width.toFloat()
|
endIconWidthState.floatValue =
|
||||||
|
it.size.width.toFloat()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (snapPoints.isNotEmpty() && startLabel != null && endLabel != null) Spacer(modifier = Modifier.height(4.dp))
|
if (snapPoints.isNotEmpty() && startLabel != null && endLabel != null) Spacer(
|
||||||
|
modifier = Modifier.height(4.dp)
|
||||||
|
)
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.Center
|
horizontalArrangement = Arrangement.Center
|
||||||
) {
|
) {
|
||||||
if (snapPoints.isNotEmpty()) {
|
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 trackWidth =
|
||||||
|
if (startIcon != null && endIcon != null) trackWidthState.floatValue - with(
|
||||||
|
density
|
||||||
|
) { 6.dp.toPx() } * 2 else trackWidthState.floatValue - with(
|
||||||
|
density
|
||||||
|
) { 22.dp.toPx() }
|
||||||
val startOffset =
|
val startOffset =
|
||||||
if (startIcon != null) startIconWidthState.floatValue + with(
|
if (startIcon != null) startIconWidthState.floatValue + with(
|
||||||
density
|
density
|
||||||
@@ -417,10 +571,6 @@ fun StyledSlider(
|
|||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
// val startOffset =
|
|
||||||
// if (startIcon != null) startIconWidthState.floatValue + with(density) { 24.dp.toPx() } else with(density) { 12.dp.toPx() }
|
|
||||||
// translationX =
|
|
||||||
// startOffset + fraction * trackWidthState.floatValue - size.width / 2f
|
|
||||||
val startOffset =
|
val startOffset =
|
||||||
if (startIcon != null)
|
if (startIcon != null)
|
||||||
startIconWidthState.floatValue + with(density) { 24.dp.toPx() }
|
startIconWidthState.floatValue + with(density) { 24.dp.toPx() }
|
||||||
@@ -433,7 +583,12 @@ fun StyledSlider(
|
|||||||
startOffset - size.width / 4f,
|
startOffset - size.width / 4f,
|
||||||
startOffset + trackWidthState.floatValue - size.width * 3f / 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() }
|
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(
|
.then(
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
@@ -442,7 +597,8 @@ fun StyledSlider(
|
|||||||
rememberDraggableState { delta ->
|
rememberDraggableState { delta ->
|
||||||
val trackWidth = trackWidthState.floatValue
|
val trackWidth = trackWidthState.floatValue
|
||||||
if (trackWidth > 0f) {
|
if (trackWidth > 0f) {
|
||||||
val targetFraction = fraction + delta / trackWidth
|
val targetFraction =
|
||||||
|
fraction + delta / trackWidth
|
||||||
val targetValue =
|
val targetValue =
|
||||||
lerp(
|
lerp(
|
||||||
valueRange.start,
|
valueRange.start,
|
||||||
@@ -455,12 +611,16 @@ fun StyledSlider(
|
|||||||
)
|
)
|
||||||
snapPoints.forEach { snap ->
|
snapPoints.forEach { snap ->
|
||||||
if ((lastDragValue < snap && targetValue >= snap) ||
|
if ((lastDragValue < snap && targetValue >= snap) ||
|
||||||
(snap in targetValue..<lastDragValue)) {
|
(snap in targetValue..<lastDragValue)
|
||||||
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
) {
|
||||||
|
haptics.performHapticFeedback(
|
||||||
|
HapticFeedbackType.SegmentTick
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lastDragValue = targetValue
|
lastDragValue = targetValue
|
||||||
val snappedValue = if (snapPoints.isNotEmpty()) snapIfClose(
|
val snappedValue =
|
||||||
|
if (snapPoints.isNotEmpty()) snapIfClose(
|
||||||
targetValue,
|
targetValue,
|
||||||
snapPoints,
|
snapPoints,
|
||||||
snapThreshold,
|
snapThreshold,
|
||||||
@@ -502,8 +662,14 @@ fun StyledSlider(
|
|||||||
scaleX = momentumAnimation.scaleX
|
scaleX = momentumAnimation.scaleX
|
||||||
scaleY = momentumAnimation.scaleY
|
scaleY = momentumAnimation.scaleY
|
||||||
val velocity = momentumAnimation.velocity / 5000f
|
val velocity = momentumAnimation.velocity / 5000f
|
||||||
scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(-0.15f, 0.15f)
|
scaleX /= 1f - (velocity * 0.75f).fastCoerceIn(
|
||||||
scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(-0.15f, 0.15f)
|
-0.15f,
|
||||||
|
0.15f
|
||||||
|
)
|
||||||
|
scaleY *= 1f - (velocity * 0.25f).fastCoerceIn(
|
||||||
|
-0.15f,
|
||||||
|
0.15f
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onDrawSurface = {
|
onDrawSurface = {
|
||||||
val progress = momentumAnimation.progress
|
val progress = momentumAnimation.progress
|
||||||
@@ -530,8 +696,7 @@ fun StyledSlider(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (independent) {
|
if (independent) {
|
||||||
|
Column(
|
||||||
Column (
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
@@ -566,7 +731,9 @@ fun StyledSlider(
|
|||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
fontWeight = FontWeight.Light,
|
fontWeight = FontWeight.Light,
|
||||||
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
|
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
|
||||||
|
alpha = 0.6f
|
||||||
|
),
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
),
|
),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -575,10 +742,18 @@ fun StyledSlider(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (label != null) Log.w("StyledSlider", "Label is ignored when independent is false")
|
if (label != null) Log.w(
|
||||||
if (description != null) Log.w("StyledSlider", "Description is ignored when independent is false")
|
"StyledSlider",
|
||||||
|
"Label is ignored when independent is false"
|
||||||
|
)
|
||||||
|
if (description != null) Log.w(
|
||||||
|
"StyledSlider",
|
||||||
|
"Description is ignored when independent is false"
|
||||||
|
)
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun snapIfClose(value: Float, points: List<Float>, threshold: Float = 0.05f): Float {
|
private fun snapIfClose(value: Float, points: List<Float>, threshold: Float = 0.05f): Float {
|
||||||
@@ -586,21 +761,21 @@ 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",
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
)
|
) {
|
||||||
{
|
Spacer(modifier = Modifier.height(72.dp))
|
||||||
StyledSlider(
|
StyledSlider(
|
||||||
value = a.floatValue,
|
value = a.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
@@ -614,6 +789,8 @@ fun StyledSliderPreview() {
|
|||||||
endIcon = "B",
|
endIcon = "B",
|
||||||
)
|
)
|
||||||
StyledSlider(
|
StyledSlider(
|
||||||
|
label = "Small label",
|
||||||
|
description = "This is a somewhat long descriptionRes",
|
||||||
value = a.floatValue,
|
value = a.floatValue,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
a.floatValue = it
|
a.floatValue = it
|
||||||
@@ -624,8 +801,8 @@ fun StyledSliderPreview() {
|
|||||||
independent = true,
|
independent = true,
|
||||||
startIcon = "A",
|
startIcon = "A",
|
||||||
endIcon = "B",
|
endIcon = "B",
|
||||||
enabled = false
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+230
-116
@@ -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,118 +179,82 @@ 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)
|
if (m3eEnabled) {
|
||||||
|
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 {
|
||||||
val animatedBackgroundColor by animateColorAsState(
|
SegmentedListItem(
|
||||||
targetValue = backgroundColor,
|
shapes = ListItemDefaults.shapes().copy(
|
||||||
animationSpec = tween(durationMillis = 500)
|
shape = defaultShape,
|
||||||
)
|
pressedShape = RoundedCornerShape(24.dp),
|
||||||
|
selectedShape = RoundedCornerShape(24.dp),
|
||||||
if (independent) {
|
hoveredShape = RoundedCornerShape(24.dp),
|
||||||
Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
|
||||||
if (title != null) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
),
|
||||||
modifier = Modifier.padding(
|
onClick = { onCheckedChange(!currentChecked) },
|
||||||
start = 16.dp,
|
trailingContent = {
|
||||||
end = 16.dp,
|
Switch(
|
||||||
top = 8.dp,
|
checked = currentChecked,
|
||||||
bottom = 4.dp
|
onCheckedChange = onCheckedChange,
|
||||||
|
modifier = Modifier.padding(end = if (header) 8.dp else 0.dp),
|
||||||
|
enabled = enabled
|
||||||
)
|
)
|
||||||
)
|
},
|
||||||
}
|
supportingContent = description?.let {
|
||||||
|
{
|
||||||
Box(
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.background(animatedBackgroundColor, RoundedCornerShape(28.dp))
|
.padding(top = if (header) 2.dp else 4.dp, bottom = if (header) 8.dp else 4.dp)
|
||||||
.padding(4.dp)
|
.padding(horizontal = if (header) 8.dp else 0.dp),
|
||||||
.pointerInput(Unit) {
|
color = if (header && enabled) MaterialTheme.colorScheme.onPrimaryContainer else Color.Unspecified
|
||||||
detectTapGestures(
|
)
|
||||||
onPress = {
|
|
||||||
if (enabled) {
|
|
||||||
backgroundColor =
|
|
||||||
if (isDarkTheme) Color(0x40888888) else Color(0x40D9D9D9)
|
|
||||||
tryAwaitRelease()
|
|
||||||
backgroundColor =
|
|
||||||
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onTap = {
|
content = {
|
||||||
if (enabled) {
|
|
||||||
scope.launch { haptics.performHapticFeedback(if (!currentChecked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
|
|
||||||
onCheckedChange(!currentChecked)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
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(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledSwitch(
|
|
||||||
checked = checked,
|
|
||||||
enabled = enabled,
|
|
||||||
onCheckedChange = {
|
|
||||||
if (enabled) {
|
|
||||||
scope.launch { haptics.performHapticFeedback(if (it) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff) }
|
|
||||||
onCheckedChange(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (description != null) {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 16.dp)
|
.padding(
|
||||||
.background(
|
top = if (header) 8.dp else 4.dp,
|
||||||
if (isDarkTheme) Color(0xFF000000)
|
bottom = if (header) 2.dp else 4.dp
|
||||||
else Color(0xFFF2F2F7)
|
|
||||||
)
|
)
|
||||||
) {
|
.padding(horizontal = if (header) 8.dp else 0.dp),
|
||||||
Text(
|
color = if (header && enabled) MaterialTheme.colorScheme.onPrimaryContainer else Color.Unspecified
|
||||||
text = description,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = FontWeight.Light,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
enabled = enabled,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.heightIn(min = 64.dp),
|
||||||
|
colors = if (header) ListItemDefaults.segmentedColors(containerColor = MaterialTheme.colorScheme.primaryContainer) else ListItemDefaults.segmentedColors()
|
||||||
)
|
)
|
||||||
}
|
if (index+1 != count) {
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val isPressed = remember { mutableStateOf(false) }
|
val isPressed = remember { mutableStateOf(false) }
|
||||||
|
Column {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -225,24 +290,16 @@ fun StyledToggle(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = label,
|
||||||
style = TextStyle(
|
style = MaterialTheme.typography.labelMedium,
|
||||||
fontSize = 16.sp,
|
color = textColor,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
color = textColor
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
|
|
||||||
if (description != null) {
|
if (description != null) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = description,
|
text = description,
|
||||||
style = TextStyle(
|
style = MaterialTheme.typography.bodySmall,
|
||||||
fontSize = 12.sp,
|
color = textColor.copy(0.8f)
|
||||||
color = textColor.copy(0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,17 +314,74 @@ fun StyledToggle(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (index+1 != count) {
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = Color(0x40888888),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(start = 12.dp,end = 12.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview(name = "List", group = "Apple")
|
||||||
@Composable
|
@Composable
|
||||||
fun StyledTogglePreview() {
|
fun StyledToggleAppleListPreview() {
|
||||||
val checked = remember { mutableStateOf(false) }
|
val checked = remember { mutableStateOf(false) }
|
||||||
|
LibrePodsTheme(m3eEnabled = false) {
|
||||||
|
StyledList {
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = "Example Toggle",
|
label = "Apple Styled List",
|
||||||
description = "This is an example description for the styled toggle.",
|
description = "This is an example description for the styled toggle.",
|
||||||
checked = checked.value,
|
checked = checked.value,
|
||||||
onCheckedChange = { checked.value = !checked.value }
|
onCheckedChange = { checked.value = !checked.value }
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "Normal", group = "Apple")
|
||||||
|
@Composable
|
||||||
|
fun StyledToggleApplePreview() {
|
||||||
|
val checked = remember { mutableStateOf(false) }
|
||||||
|
LibrePodsTheme(m3eEnabled = false) {
|
||||||
|
StyledToggle(
|
||||||
|
label = "Apple",
|
||||||
|
description = "This is an example description for the styled toggle.",
|
||||||
|
checked = checked.value,
|
||||||
|
onCheckedChange = { checked.value = !checked.value }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "List", group = "Apple")
|
||||||
|
@Composable
|
||||||
|
fun StyledToggleM3EListPreview() {
|
||||||
|
val checked = remember { mutableStateOf(false) }
|
||||||
|
LibrePodsTheme(m3eEnabled = true) {
|
||||||
|
StyledList {
|
||||||
|
StyledToggle(
|
||||||
|
label = "Apple Styled List",
|
||||||
|
// description = "This is an example description for the styled toggle.",
|
||||||
|
checked = checked.value,
|
||||||
|
onCheckedChange = { checked.value = !checked.value }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "Normal", group = "Material")
|
||||||
|
@Composable
|
||||||
|
fun StyledToggleM3EPreview() {
|
||||||
|
val checked = remember { mutableStateOf(false) }
|
||||||
|
LibrePodsTheme(m3eEnabled = true) {
|
||||||
|
StyledToggle(
|
||||||
|
label = "Material",
|
||||||
|
description = "This is an example description for the styled toggle.",
|
||||||
|
checked = checked.value,
|
||||||
|
onCheckedChange = { checked.value = !checked.value }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+324
@@ -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 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+158
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -33,6 +33,7 @@ import android.content.IntentFilter
|
|||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.graphics.PixelFormat
|
import android.graphics.PixelFormat
|
||||||
import android.graphics.drawable.GradientDrawable
|
import android.graphics.drawable.GradientDrawable
|
||||||
|
import android.media.AudioManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
@@ -374,6 +375,7 @@ class IslandWindow(private val context: Context) {
|
|||||||
|
|
||||||
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
||||||
val videoUri = "android.resource://me.kavishdevar.librepods/${R.raw.island}".toUri()
|
val videoUri = "android.resource://me.kavishdevar.librepods/${R.raw.island}".toUri()
|
||||||
|
videoView.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE)
|
||||||
videoView.setVideoURI(videoUri)
|
videoView.setVideoURI(videoUri)
|
||||||
videoView.setOnPreparedListener { mediaPlayer ->
|
videoView.setOnPreparedListener { mediaPlayer ->
|
||||||
mediaPlayer.isLooping = true
|
mediaPlayer.isLooping = true
|
||||||
|
|||||||
+2
@@ -29,6 +29,7 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.graphics.PixelFormat
|
import android.graphics.PixelFormat
|
||||||
|
import android.media.AudioManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
@@ -137,6 +138,7 @@ class PopupWindow(
|
|||||||
updateBatteryStatus(batteryNotification)
|
updateBatteryStatus(batteryNotification)
|
||||||
|
|
||||||
val vid = mView.findViewById<VideoView>(R.id.video)
|
val vid = mView.findViewById<VideoView>(R.id.video)
|
||||||
|
vid.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE)
|
||||||
vid.setVideoPath("android.resource://me.kavishdevar.librepods/" + R.raw.connected)
|
vid.setVideoPath("android.resource://me.kavishdevar.librepods/" + R.raw.connected)
|
||||||
vid.resolveAdjustedSize(vid.width, vid.height)
|
vid.resolveAdjustedSize(vid.width, vid.height)
|
||||||
vid.start()
|
vid.start()
|
||||||
|
|||||||
+92
-351
@@ -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,42 +91,36 @@ 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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.hazeSource(hazeState)
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
.layerBackdrop(backdrop)
|
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(topPadding))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
if (!state.isPremium) {
|
if (!state.isPremium) {
|
||||||
StyledButton(
|
StyledButton(
|
||||||
onClick = {
|
onClick = navigateToPurchase,
|
||||||
navController.navigate("purchase_screen")
|
|
||||||
},
|
|
||||||
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 phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
|
// val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
|
||||||
@@ -211,7 +183,7 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
|
|||||||
LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
|
LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
|
||||||
phoneMediaDebounceJob?.cancel()
|
phoneMediaDebounceJob?.cancel()
|
||||||
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
|
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
|
||||||
delay(150)
|
delay(150.milliseconds)
|
||||||
try {
|
try {
|
||||||
val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
|
val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
|
||||||
val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
|
val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
|
||||||
@@ -228,73 +200,51 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box(
|
|
||||||
modifier = Modifier.then(
|
StyledList(
|
||||||
if (!state.isPremium) {
|
title = stringResource(R.string.press_speed),
|
||||||
Modifier.pointerInput(Unit) {
|
description = stringResource(R.string.press_speed_description)
|
||||||
awaitPointerEventScope {
|
) {
|
||||||
while (true) {
|
pressSpeedOptions.forEach { (value, label) ->
|
||||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
StyledListItem(
|
||||||
event.changes.forEach { it.consume() }
|
name = label,
|
||||||
}
|
selected = selectedPressSpeed == label,
|
||||||
}
|
onClick = {
|
||||||
}
|
selectedPressSpeed = label
|
||||||
} else Modifier)) {
|
|
||||||
DropdownMenuComponent(
|
|
||||||
label = stringResource(R.string.press_speed),
|
|
||||||
description = stringResource(R.string.press_speed_description),
|
|
||||||
options = pressSpeedOptions.values.toList(),
|
|
||||||
selectedOption = selectedPressSpeed ?: stringResource(R.string.default_option),
|
|
||||||
onOptionSelected = { newValue ->
|
|
||||||
selectedPressSpeed = newValue
|
|
||||||
viewModel.setControlCommandByte(
|
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(
|
StyledToggle(
|
||||||
title = stringResource(R.string.noise_control),
|
title = stringResource(R.string.noise_control),
|
||||||
label = stringResource(R.string.noise_cancellation_single_airpod),
|
label = stringResource(R.string.noise_cancellation_single_airpod),
|
||||||
description = stringResource(R.string.noise_cancellation_single_airpod_description),
|
description = stringResource(R.string.noise_cancellation_single_airpod_description),
|
||||||
independent = true,
|
|
||||||
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull(
|
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull(
|
||||||
0
|
0
|
||||||
) == 0x01.toByte(),
|
) == 0x01.toByte(),
|
||||||
@@ -322,27 +272,34 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hearingAidEnabled && state.vendorIdHook) {
|
if (!hearingAidEnabled && state.vendorIdHook) {
|
||||||
NavigationButton(
|
StyledListItem(
|
||||||
to = "transparency_customization",
|
|
||||||
name = stringResource(R.string.customize_transparency_mode),
|
name = stringResource(R.string.customize_transparency_mode),
|
||||||
navController = navController,
|
onClick = navigateToTransparencyCustomization,
|
||||||
enabled = state.isPremium
|
enabled = state.isPremium
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val toneVolumeValue =
|
val toneVolumeValue = remember { mutableFloatStateOf(state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(0)?.toFloat() ?: 75f) }
|
||||||
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(
|
|
||||||
0
|
LaunchedEffect(toneVolumeValue) {
|
||||||
)?.toFloat() ?: 75f
|
snapshotFlow {
|
||||||
StyledSlider(
|
toneVolumeValue.floatValue
|
||||||
label = stringResource(R.string.tone_volume),
|
}
|
||||||
description = stringResource(R.string.tone_volume_description),
|
.debounce(100.milliseconds)
|
||||||
value = toneVolumeValue,
|
.collect {
|
||||||
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,
|
valueRange = 0f..100f,
|
||||||
snapPoints = listOf(75f),
|
snapPoints = listOf(75f),
|
||||||
@@ -369,38 +326,26 @@ fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navController: NavC
|
|||||||
enabled = state.isPremium
|
enabled = state.isPremium
|
||||||
)
|
)
|
||||||
|
|
||||||
Box(
|
StyledList(
|
||||||
modifier = Modifier.then(
|
title = stringResource(R.string.volume_swipe_speed),
|
||||||
if (!state.isPremium) {
|
description = stringResource(R.string.volume_swipe_speed_description)
|
||||||
Modifier.pointerInput(Unit) {
|
) {
|
||||||
awaitPointerEventScope {
|
volumeSwipeSpeedOptions.forEach { (value, label) ->
|
||||||
while (true) {
|
StyledListItem(
|
||||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
name = label,
|
||||||
event.changes.forEach { it.consume() }
|
selected = selectedVolumeSwipeSpeed == label,
|
||||||
}
|
onClick = {
|
||||||
}
|
selectedVolumeSwipeSpeed = label
|
||||||
}
|
|
||||||
} 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(
|
||||||
@@ -637,208 +582,4 @@ 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))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-25
@@ -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,46 +51,44 @@ 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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.layerBackdrop(backdrop)
|
.layerBackdrop(backdrop)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
if (!state.isPremium) {
|
if (!state.isPremium) {
|
||||||
StyledButton(
|
StyledButton(
|
||||||
onClick = {
|
onClick = navigateToPurchase,
|
||||||
navController.navigate("purchase_screen")
|
|
||||||
},
|
|
||||||
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 {
|
val sliderValue = remember {
|
||||||
mutableFloatStateOf(100f - (state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH]?.getOrNull(0)?.toFloat() ?: 50f))
|
mutableFloatStateOf(100f - (state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH]?.getOrNull(0)?.toFloat() ?: 50f))
|
||||||
@@ -121,6 +117,6 @@ fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navController: NavContro
|
|||||||
description = stringResource(R.string.adaptive_audio_description),
|
description = stringResource(R.string.adaptive_audio_description),
|
||||||
enabled = state.isPremium
|
enabled = state.isPremium
|
||||||
)
|
)
|
||||||
}
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+651
-183
File diff suppressed because it is too large
Load Diff
+96
-226
@@ -24,16 +24,21 @@ import android.net.Uri
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
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.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.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
@@ -42,7 +47,6 @@ import androidx.compose.foundation.text.input.clearText
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
@@ -73,29 +77,35 @@ import androidx.compose.ui.unit.lerp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
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.BuildConfig
|
import me.kavishdevar.librepods.BuildConfig
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.presentation.components.AppInfoCard
|
import me.kavishdevar.librepods.presentation.components.AppInfoCard
|
||||||
import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
|
import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
|
||||||
import me.kavishdevar.librepods.presentation.components.NavigationButton
|
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledBottomSheet
|
import me.kavishdevar.librepods.presentation.components.StyledBottomSheet
|
||||||
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.StyledIconButton
|
||||||
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.components.StyledList
|
||||||
|
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.theme.MaterialTypography
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
|
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
|
||||||
import me.kavishdevar.librepods.utils.XposedState
|
import me.kavishdevar.librepods.utils.XposedState
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun AppSettingsScreen(
|
fun AppSettingsScreen(
|
||||||
navController: NavController, viewModel: AppSettingsViewModel = viewModel()
|
viewModel: AppSettingsViewModel = viewModel(),
|
||||||
|
navigateToPurchase: () -> Unit,
|
||||||
|
navigateToTroubleshooting: () -> Unit,
|
||||||
|
navigateToOpenSourceLicenses: () -> Unit,
|
||||||
|
navigateToReleaseNotesScreen: () -> Unit
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
@@ -109,45 +119,80 @@ fun AppSettingsScreen(
|
|||||||
val subjectFocusRequester = remember { FocusRequester() }
|
val subjectFocusRequester = remember { FocusRequester() }
|
||||||
val descriptionFocusRequester = remember { FocusRequester() }
|
val descriptionFocusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
StyledScaffold(
|
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||||
title = stringResource(R.string.settings)
|
val topPadding = if (m3eEnabled) 16.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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
.layerBackdrop(backdrop)
|
.layerBackdrop(backdrop)
|
||||||
.hazeSource(state = hazeState)
|
|
||||||
.verticalScroll(scrollState)
|
.verticalScroll(scrollState)
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(topPadding))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
val isDarkTheme = isSystemInDarkTheme()
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
|
|
||||||
if (!state.isPremium && state.connectionSuccessful) {
|
if (!state.isPremium && state.connectionSuccessful) {
|
||||||
StyledButton(
|
StyledButton(
|
||||||
onClick = {
|
onClick = navigateToPurchase,
|
||||||
navController.navigate("purchase_screen")
|
|
||||||
},
|
|
||||||
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 = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
if (state.timeUntilFOSSPremiumExpiry > 0L) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(Color(0xFF32829B), RoundedCornerShape(28.dp))
|
||||||
|
.clip(RoundedCornerShape(28.dp))
|
||||||
|
.clickable {
|
||||||
|
val emailIntent = Intent(Intent.ACTION_SENDTO).apply {
|
||||||
|
data = "mailto:".toUri()
|
||||||
|
putExtra(Intent.EXTRA_EMAIL, arrayOf("billing@kavish.xyz"))
|
||||||
|
putExtra(Intent.EXTRA_SUBJECT, "LibrePods Play billing error")
|
||||||
|
putExtra(
|
||||||
|
Intent.EXTRA_TEXT,
|
||||||
|
"Please enter your GitHub username to restore your premium access:\n\nGitHub username: "
|
||||||
|
)
|
||||||
|
}
|
||||||
|
context.startActivity(emailIntent)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.play_foss_premium_banner, maxOf(1, TimeUnit.MILLISECONDS.toDays(state.timeUntilFOSSPremiumExpiry).toInt())
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Bold,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
color = Color.White,
|
||||||
color = Color.White
|
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
StyledToggle(
|
||||||
|
title = stringResource(R.string.appearance),
|
||||||
|
label = stringResource(R.string.use_material3e),
|
||||||
|
checked = state.m3eEnabled,
|
||||||
|
onCheckedChange = viewModel::setm3eEnabled,
|
||||||
|
enabled = state.isPremium
|
||||||
|
)
|
||||||
|
|
||||||
if (state.connectionSuccessful) {
|
if (state.connectionSuccessful) {
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
title = stringResource(R.string.widget),
|
title = stringResource(R.string.widget),
|
||||||
@@ -158,37 +203,12 @@ fun AppSettingsScreen(
|
|||||||
enabled = state.isPremium
|
enabled = state.isPremium
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
StyledList(title = stringResource(R.string.popup_animations)) {
|
||||||
text = stringResource(R.string.popup_animations), style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
backgroundColor, RoundedCornerShape(28.dp)
|
|
||||||
)
|
|
||||||
.padding(vertical = 4.dp)
|
|
||||||
) {
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.show_bottom_sheet_popup),
|
label = stringResource(R.string.show_bottom_sheet_popup),
|
||||||
description = stringResource(R.string.show_bottom_sheet_popup_description),
|
description = stringResource(R.string.show_bottom_sheet_popup_description),
|
||||||
checked = state.showBottomSheetPopup,
|
checked = state.showBottomSheetPopup,
|
||||||
onCheckedChange = viewModel::setShowBottomSheetPopup,
|
onCheckedChange = viewModel::setShowBottomSheetPopup,
|
||||||
independent = false
|
|
||||||
)
|
|
||||||
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier.padding(horizontal = 12.dp)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
@@ -196,50 +216,25 @@ fun AppSettingsScreen(
|
|||||||
description = stringResource(R.string.show_island_popup_description),
|
description = stringResource(R.string.show_island_popup_description),
|
||||||
checked = state.showIslandPopup,
|
checked = state.showIslandPopup,
|
||||||
onCheckedChange = viewModel::setShowIslandPopup,
|
onCheckedChange = viewModel::setShowIslandPopup,
|
||||||
independent = false
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
text = stringResource(R.string.conversational_awareness), style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
StyledList (title = stringResource(R.string.conversational_awareness)) {
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
backgroundColor, RoundedCornerShape(28.dp)
|
|
||||||
)
|
|
||||||
.padding(vertical = 4.dp)
|
|
||||||
) {
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.conversational_awareness_pause_music),
|
label = stringResource(R.string.conversational_awareness_pause_music),
|
||||||
description = stringResource(R.string.conversational_awareness_pause_music_description),
|
description = stringResource(R.string.conversational_awareness_pause_music_description),
|
||||||
checked = state.conversationalAwarenessPauseMusicEnabled,
|
checked = state.conversationalAwarenessPauseMusicEnabled,
|
||||||
onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled,
|
onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled,
|
||||||
independent = false,
|
|
||||||
enabled = state.isPremium
|
enabled = state.isPremium
|
||||||
)
|
)
|
||||||
|
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier.padding(horizontal = 12.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.relative_conversational_awareness_volume),
|
label = stringResource(R.string.relative_conversational_awareness_volume),
|
||||||
description = stringResource(R.string.relative_conversational_awareness_volume_description),
|
description = stringResource(R.string.relative_conversational_awareness_volume_description),
|
||||||
checked = state.relativeConversationalAwarenessVolumeEnabled,
|
checked = state.relativeConversationalAwarenessVolumeEnabled,
|
||||||
onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled,
|
onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled,
|
||||||
independent = false,
|
|
||||||
enabled = state.isPremium,
|
enabled = state.isPremium,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -270,16 +265,16 @@ fun AppSettingsScreen(
|
|||||||
// if (!BuildConfig.PLAY_BUILD) {
|
// if (!BuildConfig.PLAY_BUILD) {
|
||||||
// Spacer(modifier = Modifier.height(16.dp))
|
// Spacer(modifier = Modifier.height(16.dp))
|
||||||
//
|
//
|
||||||
// NavigationButton(
|
// StyledListItem(
|
||||||
// to = "",
|
// to = "",
|
||||||
// title = stringResource(R.string.camera_control),
|
// titleRes = stringResource(R.string.camera_control),
|
||||||
// name = stringResource(R.string.set_custom_camera_package),
|
// name = stringResource(R.string.set_custom_camera_package),
|
||||||
// navController = navController,
|
// navController = navController,
|
||||||
// onClick = {
|
// onClick = {
|
||||||
// if (state.isPremium) viewModel.setShowCameraDialog(true)
|
// if (state.isPremium) viewModel.setShowCameraDialog(true)
|
||||||
// },
|
// },
|
||||||
// independent = true,
|
// independent = true,
|
||||||
// description = stringResource(R.string.camera_control_app_description)
|
// descriptionRes = stringResource(R.string.camera_control_app_description)
|
||||||
// )
|
// )
|
||||||
// }
|
// }
|
||||||
|
|
||||||
@@ -295,163 +290,84 @@ fun AppSettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
StyledList(title = stringResource(R.string.takeover_airpods_state)) {
|
||||||
text = stringResource(R.string.takeover_airpods_state), style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
backgroundColor, RoundedCornerShape(28.dp)
|
|
||||||
)
|
|
||||||
.padding(vertical = 4.dp)
|
|
||||||
) {
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.takeover_disconnected),
|
label = stringResource(R.string.takeover_disconnected),
|
||||||
description = stringResource(R.string.takeover_disconnected_desc),
|
description = stringResource(R.string.takeover_disconnected_desc),
|
||||||
checked = state.takeoverWhenDisconnected,
|
checked = state.takeoverWhenDisconnected,
|
||||||
onCheckedChange = viewModel::setTakeoverWhenDisconnected,
|
onCheckedChange = viewModel::setTakeoverWhenDisconnected,
|
||||||
independent = false,
|
|
||||||
enabled = state.isPremium
|
enabled = state.isPremium
|
||||||
)
|
)
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier.padding(horizontal = 12.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.takeover_idle),
|
label = stringResource(R.string.takeover_idle),
|
||||||
description = stringResource(R.string.takeover_idle_desc),
|
description = stringResource(R.string.takeover_idle_desc),
|
||||||
checked = state.takeoverWhenIdle,
|
checked = state.takeoverWhenIdle,
|
||||||
onCheckedChange = viewModel::setTakeoverWhenIdle,
|
onCheckedChange = viewModel::setTakeoverWhenIdle,
|
||||||
independent = false,
|
|
||||||
enabled = state.isPremium
|
enabled = state.isPremium
|
||||||
)
|
)
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier.padding(horizontal = 12.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.takeover_music),
|
label = stringResource(R.string.takeover_music),
|
||||||
description = stringResource(R.string.takeover_music_desc),
|
description = stringResource(R.string.takeover_music_desc),
|
||||||
checked = state.takeoverWhenMusic,
|
checked = state.takeoverWhenMusic,
|
||||||
onCheckedChange = viewModel::setTakeoverWhenMusic,
|
onCheckedChange = viewModel::setTakeoverWhenMusic,
|
||||||
independent = false,
|
|
||||||
enabled = state.isPremium
|
enabled = state.isPremium
|
||||||
)
|
)
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier.padding(horizontal = 12.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.takeover_call),
|
label = stringResource(R.string.takeover_call),
|
||||||
description = stringResource(R.string.takeover_call_desc),
|
description = stringResource(R.string.takeover_call_desc),
|
||||||
checked = state.takeoverWhenCall,
|
checked = state.takeoverWhenCall,
|
||||||
onCheckedChange = viewModel::setTakeoverWhenCall,
|
onCheckedChange = viewModel::setTakeoverWhenCall,
|
||||||
independent = false,
|
|
||||||
enabled = state.isPremium
|
enabled = state.isPremium
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
Text(
|
StyledList(title = stringResource(R.string.takeover_phone_state)) {
|
||||||
text = stringResource(R.string.takeover_phone_state), style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
), modifier = Modifier.padding(horizontal = 16.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
backgroundColor, RoundedCornerShape(28.dp)
|
|
||||||
)
|
|
||||||
.padding(vertical = 4.dp)
|
|
||||||
) {
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.takeover_ringing_call),
|
label = stringResource(R.string.takeover_ringing_call),
|
||||||
description = stringResource(R.string.takeover_ringing_call_desc),
|
description = stringResource(R.string.takeover_ringing_call_desc),
|
||||||
checked = state.takeoverWhenRingingCall,
|
checked = state.takeoverWhenRingingCall,
|
||||||
onCheckedChange = viewModel::setTakeoverWhenRingingCall,
|
onCheckedChange = viewModel::setTakeoverWhenRingingCall,
|
||||||
independent = false,
|
|
||||||
enabled = state.isPremium
|
enabled = state.isPremium
|
||||||
)
|
)
|
||||||
HorizontalDivider(
|
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier.padding(horizontal = 12.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.takeover_media_start),
|
label = stringResource(R.string.takeover_media_start),
|
||||||
description = stringResource(R.string.takeover_media_start_desc),
|
description = stringResource(R.string.takeover_media_start_desc),
|
||||||
checked = state.takeoverWhenMediaStart,
|
checked = state.takeoverWhenMediaStart,
|
||||||
onCheckedChange = viewModel::setTakeoverWhenMediaStart,
|
onCheckedChange = viewModel::setTakeoverWhenMediaStart,
|
||||||
independent = false,
|
|
||||||
enabled = state.isPremium
|
enabled = state.isPremium
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.advanced_options), style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
), modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 24.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
|
title = stringResource(R.string.advanced_options), // shouldn't be here, but okay
|
||||||
label = stringResource(R.string.use_alternate_head_tracking_packets),
|
label = stringResource(R.string.use_alternate_head_tracking_packets),
|
||||||
description = stringResource(R.string.use_alternate_head_tracking_packets_description),
|
description = stringResource(R.string.use_alternate_head_tracking_packets_description),
|
||||||
checked = state.useAlternateHeadTrackingPackets,
|
checked = state.useAlternateHeadTrackingPackets,
|
||||||
onCheckedChange = viewModel::setUseAlternateHeadTrackingPackets,
|
onCheckedChange = viewModel::setUseAlternateHeadTrackingPackets,
|
||||||
independent = true,
|
|
||||||
enabled = state.isPremium
|
enabled = state.isPremium
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
} else {
|
} else {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
.padding(top = 16.dp, bottom = 2.dp)
|
.padding(top = 16.dp, bottom = 2.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.customizations_unavailable),
|
text = stringResource(R.string.customizations_unavailable),
|
||||||
style = TextStyle(
|
style = MaterialTypography.bodyMedium,
|
||||||
fontSize = 16.sp,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
),
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
|
if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
|
||||||
val restartBluetoothText =
|
val restartBluetoothText = stringResource(R.string.found_offset_restart_bluetooth)
|
||||||
stringResource(R.string.found_offset_restart_bluetooth)
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.act_as_an_apple_device) + " (${
|
label = stringResource(R.string.act_as_an_apple_device) + " (${
|
||||||
stringResource(
|
stringResource(
|
||||||
@@ -463,87 +379,42 @@ fun AppSettingsScreen(
|
|||||||
onCheckedChange = { enabled ->
|
onCheckedChange = { enabled ->
|
||||||
Toast.makeText(context, restartBluetoothText, Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, restartBluetoothText, Toast.LENGTH_SHORT).show()
|
||||||
viewModel.setVendorIdHook(enabled)
|
viewModel.setVendorIdHook(enabled)
|
||||||
},
|
}
|
||||||
independent = true,
|
|
||||||
enabled = state.isPremium
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!BuildConfig.PLAY_BUILD) {
|
if (!BuildConfig.PLAY_BUILD) {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
NavigationButton(
|
StyledList {
|
||||||
to = "troubleshooting",
|
StyledListItem(
|
||||||
name = stringResource(R.string.troubleshooting),
|
name = stringResource(R.string.troubleshooting),
|
||||||
navController = navController,
|
onClick = navigateToTroubleshooting,
|
||||||
independent = true,
|
|
||||||
description = stringResource(R.string.troubleshooting_description)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
Box(
|
StyledList(title = stringResource(R.string.contact)) {
|
||||||
modifier = Modifier
|
StyledListItem(
|
||||||
.background(if (isDarkTheme) Color.Black else Color(0xFFF2F2F7))
|
|
||||||
.padding(start = 16.dp, bottom = 2.dp, top = 24.dp, end = 4.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.contact), style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
backgroundColor, RoundedCornerShape(28.dp)
|
|
||||||
)
|
|
||||||
.clip(RoundedCornerShape(28.dp))
|
|
||||||
) {
|
|
||||||
NavigationButton(
|
|
||||||
to = "",
|
|
||||||
name = stringResource(R.string.email),
|
name = stringResource(R.string.email),
|
||||||
navController = navController,
|
|
||||||
onClick = { contactBottomSheet.value = true },
|
onClick = { contactBottomSheet.value = true },
|
||||||
independent = false
|
|
||||||
)
|
)
|
||||||
|
|
||||||
HorizontalDivider(
|
StyledListItem(
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier.padding(horizontal = 12.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
NavigationButton(
|
|
||||||
to = "",
|
|
||||||
name = stringResource(R.string.discord),
|
name = stringResource(R.string.discord),
|
||||||
navController = navController,
|
|
||||||
onClick = {
|
onClick = {
|
||||||
val intent =
|
val intent =
|
||||||
Intent(Intent.ACTION_VIEW, "https://discord.gg/Ts4wupXcmc".toUri())
|
Intent(Intent.ACTION_VIEW, "https://discord.gg/Ts4wupXcmc".toUri())
|
||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
},
|
},
|
||||||
independent = false
|
|
||||||
)
|
)
|
||||||
|
|
||||||
HorizontalDivider(
|
StyledListItem(
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier.padding(horizontal = 12.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
NavigationButton(
|
|
||||||
to = "",
|
|
||||||
name = stringResource(R.string.github_issues),
|
name = stringResource(R.string.github_issues),
|
||||||
navController = navController,
|
|
||||||
onClick = {
|
onClick = {
|
||||||
val appVersion = Uri.encode("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
|
val appVersion =
|
||||||
|
Uri.encode("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
|
||||||
val device = Uri.encode("${Build.MANUFACTURER} ${Build.MODEL}")
|
val device = Uri.encode("${Build.MANUFACTURER} ${Build.MODEL}")
|
||||||
val androidVersion = Uri.encode("${Build.ID} (${Build.DISPLAY})")
|
val androidVersion = Uri.encode("${Build.ID} (${Build.DISPLAY})")
|
||||||
val appSource = Uri.encode(
|
val appSource = Uri.encode(
|
||||||
@@ -562,22 +433,19 @@ fun AppSettingsScreen(
|
|||||||
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||||
context.startActivity(intent)
|
context.startActivity(intent)
|
||||||
},
|
},
|
||||||
independent = false
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
DeviceInfoCard()
|
DeviceInfoCard()
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
AppInfoCard()
|
AppInfoCard(navigateToReleaseNotesScreen)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
NavigationButton(
|
StyledListItem(
|
||||||
to = "open_source_licenses",
|
|
||||||
name = stringResource(R.string.open_source_licenses),
|
name = stringResource(R.string.open_source_licenses),
|
||||||
navController = navController,
|
onClick = navigateToOpenSourceLicenses,
|
||||||
independent = true
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(bottomPadding))
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
@@ -650,6 +518,7 @@ fun AppSettingsScreen(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
StyledBottomSheet(
|
StyledBottomSheet(
|
||||||
visible = contactBottomSheet.value,
|
visible = contactBottomSheet.value,
|
||||||
onDismiss = { contactBottomSheet.value = false },
|
onDismiss = { contactBottomSheet.value = false },
|
||||||
@@ -727,6 +596,7 @@ fun AppSettingsScreen(
|
|||||||
inputState = subjectState,
|
inputState = subjectState,
|
||||||
focusRequester = subjectFocusRequester,
|
focusRequester = subjectFocusRequester,
|
||||||
placeholder = stringResource(R.string.subject),
|
placeholder = stringResource(R.string.subject),
|
||||||
|
forceApple = true
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
@@ -735,9 +605,9 @@ fun AppSettingsScreen(
|
|||||||
inputState = descriptionState,
|
inputState = descriptionState,
|
||||||
focusRequester = descriptionFocusRequester,
|
focusRequester = descriptionFocusRequester,
|
||||||
placeholder = stringResource(R.string.describe_your_issue),
|
placeholder = stringResource(R.string.describe_your_issue),
|
||||||
singleLine = false
|
singleLine = false,
|
||||||
|
forceApple = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+97
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
-91
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
-523
@@ -1,523 +0,0 @@
|
|||||||
/*
|
|
||||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
|
||||||
Copyright (C) 2025 LibrePods contributors
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program. If not, see <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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+762
@@ -0,0 +1,762 @@
|
|||||||
|
/*
|
||||||
|
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||||
|
Copyright (C) 2025 LibrePods contributors
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package me.kavishdevar.librepods.presentation.screens
|
||||||
|
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.gestures.Orientation
|
||||||
|
import androidx.compose.foundation.gestures.draggable
|
||||||
|
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.asPaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBars
|
||||||
|
import androidx.compose.foundation.layout.visible
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.util.lerp
|
||||||
|
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||||
|
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||||
|
import com.kyant.backdrop.drawBackdrop
|
||||||
|
import com.kyant.backdrop.effects.lens
|
||||||
|
import com.kyant.backdrop.highlight.Highlight
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledList
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||||
|
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||||
|
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||||
|
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsUiState
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.demoState
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EqualizerRoute(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
|
||||||
|
|
||||||
|
Box (
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
) {
|
||||||
|
EqualizerScreen(
|
||||||
|
state = state,
|
||||||
|
topPadding = topPadding,
|
||||||
|
bottomPadding = bottomPadding,
|
||||||
|
setCustomEqEnabled = viewModel::setCustomEqEnabled,
|
||||||
|
setCustomEq = viewModel::setCustomEq
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
@Composable
|
||||||
|
fun EqualizerScreen(
|
||||||
|
state: AirPodsUiState,
|
||||||
|
topPadding: Dp = 16.dp,
|
||||||
|
bottomPadding: Dp = 16.dp,
|
||||||
|
setCustomEqEnabled: (Boolean) -> Unit,
|
||||||
|
setCustomEq: (Int, Int, Int) -> Unit
|
||||||
|
) {
|
||||||
|
val customEq = state.customEq
|
||||||
|
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.verticalScroll(scrollState),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
val height = 200.dp
|
||||||
|
val maxOffset = with(LocalDensity.current) { height.toPx() } / 2
|
||||||
|
|
||||||
|
val offsets = remember(state.customEq) {
|
||||||
|
listOf(
|
||||||
|
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.low.toFloat() / 100)),
|
||||||
|
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.mid.toFloat() / 100)),
|
||||||
|
mutableFloatStateOf(lerp(maxOffset, -maxOffset, customEq.high.toFloat() / 100))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(offsets) {
|
||||||
|
snapshotFlow {
|
||||||
|
Triple(
|
||||||
|
offsets[0].floatValue,
|
||||||
|
offsets[1].floatValue,
|
||||||
|
offsets[2].floatValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.debounce(100.milliseconds) // cool, should've been using this since the very beginning
|
||||||
|
.collect { (lowF, midF, highF) ->
|
||||||
|
val low =
|
||||||
|
100 - ((lowF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
|
||||||
|
val mid =
|
||||||
|
100 - ((midF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
|
||||||
|
val high =
|
||||||
|
100 - ((highF / (2 * maxOffset) + 0.5f) * 100).roundToInt()
|
||||||
|
|
||||||
|
setCustomEq(low, mid, high)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
|
val enabled = customEq.isEnabled()
|
||||||
|
|
||||||
|
StyledList {
|
||||||
|
StyledListItem(
|
||||||
|
name = stringResource(R.string.recommended),
|
||||||
|
selected = !enabled,
|
||||||
|
onClick = { setCustomEqEnabled(false) }
|
||||||
|
)
|
||||||
|
|
||||||
|
StyledListItem(
|
||||||
|
name = stringResource(R.string.custom),
|
||||||
|
selected = enabled,
|
||||||
|
onClick = { setCustomEqEnabled(true) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Crossfade (
|
||||||
|
customEq.isEnabled()
|
||||||
|
) { visible ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.visible(visible),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
|
||||||
|
EqualizerCard(
|
||||||
|
lowOffset = offsets[0],
|
||||||
|
midOffset = offsets[1],
|
||||||
|
highOffset = offsets[2]
|
||||||
|
)
|
||||||
|
|
||||||
|
val resetButtonEnabled = remember { derivedStateOf { !offsets.all { it.floatValue == 0f } } }
|
||||||
|
|
||||||
|
StyledButton(
|
||||||
|
onClick = {
|
||||||
|
offsets[0].floatValue = 0f
|
||||||
|
offsets[1].floatValue = 0f
|
||||||
|
offsets[2].floatValue = 0f
|
||||||
|
},
|
||||||
|
backdrop = rememberLayerBackdrop(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
isInteractive = false,
|
||||||
|
enabled = resetButtonEnabled.value
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.reset),
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EqualizerCard(
|
||||||
|
lowOffset: MutableState<Float>,
|
||||||
|
midOffset: MutableState<Float>,
|
||||||
|
highOffset: MutableState<Float>
|
||||||
|
) {
|
||||||
|
val height = 200.dp
|
||||||
|
val maxOffset = with(LocalDensity.current) { height.toPx() } / 2
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(28.dp))
|
||||||
|
) {
|
||||||
|
val dashColor = if (isSystemInDarkTheme()) Color(0x80AAAAAA) else Color(0x809D9D9D)
|
||||||
|
|
||||||
|
val backdrop = rememberLayerBackdrop()
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surface, RoundedCornerShape(28.dp))
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(42.dp))
|
||||||
|
// Row(
|
||||||
|
// modifier = Modifier
|
||||||
|
// .fillMaxWidth()
|
||||||
|
// .padding(18.dp),
|
||||||
|
// verticalAlignment = Alignment.CenterVertically,
|
||||||
|
// horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
// ) {
|
||||||
|
// Box(
|
||||||
|
// modifier = Modifier
|
||||||
|
// .size(64.dp)
|
||||||
|
// .background(if (isSystemInDarkTheme()) Color.DarkGray else Color.LightGray, RoundedCornerShape(12.dp))
|
||||||
|
// )
|
||||||
|
// Column(
|
||||||
|
// modifier = Modifier
|
||||||
|
// .weight(1f),
|
||||||
|
// verticalArrangement = Arrangement.Center
|
||||||
|
// ) {
|
||||||
|
// Text(
|
||||||
|
// text = "Written into Changes",
|
||||||
|
// style = TextStyle(
|
||||||
|
// fontSize = 16.sp,
|
||||||
|
// fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
// fontWeight = FontWeight.Bold,
|
||||||
|
// color = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||||
|
// )
|
||||||
|
// )
|
||||||
|
// Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
// Text(
|
||||||
|
// text = "Avalon Emerson",
|
||||||
|
// style = TextStyle(
|
||||||
|
// fontSize = 14.sp,
|
||||||
|
// fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
// fontWeight = FontWeight.Normal,
|
||||||
|
// color = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||||
|
// )
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// val paused = remember { mutableStateOf(false) }
|
||||||
|
// Box(
|
||||||
|
// modifier = Modifier
|
||||||
|
// .size(48.dp)
|
||||||
|
// .background(Color(0x600091FF), CircleShape)
|
||||||
|
// .clickable(
|
||||||
|
// interactionSource = remember { MutableInteractionSource() },
|
||||||
|
// indication = null,
|
||||||
|
// ) {
|
||||||
|
// paused.value = !paused.value
|
||||||
|
// },
|
||||||
|
// contentAlignment = Alignment.Center
|
||||||
|
// ) {
|
||||||
|
// Crossfade(
|
||||||
|
// targetState = paused.value,
|
||||||
|
// label = "media_icon"
|
||||||
|
// ) { p ->
|
||||||
|
// Text(
|
||||||
|
// text = if (p) "" else "",
|
||||||
|
// style = TextStyle(
|
||||||
|
// fontSize = 24.sp,
|
||||||
|
// fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
// fontWeight = FontWeight.Normal,
|
||||||
|
// color = Color(0xFF0091FF),
|
||||||
|
// textAlign = TextAlign.Center
|
||||||
|
// )
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// HorizontalDivider(
|
||||||
|
// thickness = 1.dp,
|
||||||
|
// color = Color(0x40888888),
|
||||||
|
// modifier = Modifier
|
||||||
|
// .padding(horizontal = 20.dp)
|
||||||
|
// .padding(bottom = 16.dp)
|
||||||
|
// )
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
fun colorFromY(y: Float): Color {
|
||||||
|
val f = ((y + maxOffset) / (2f * maxOffset)).coerceIn(0f, 1f)
|
||||||
|
val stops = listOf(
|
||||||
|
0.0f to Color(0xFFFFA300),
|
||||||
|
0.25f to Color(0xFFFCE600),
|
||||||
|
0.5f to Color(0xFF00FAAF),
|
||||||
|
0.75f to Color(0xFF00FAFF),
|
||||||
|
1.0f to Color(0xFF00B5FF)
|
||||||
|
)
|
||||||
|
val (start, end) = stops.zipWithNext()
|
||||||
|
.first { f <= it.second.first }
|
||||||
|
val c = (f - start.first) / (end.first - start.first)
|
||||||
|
return lerp(start.second, end.second, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pathBrush(
|
||||||
|
startY: Float,
|
||||||
|
endY: Float,
|
||||||
|
): Brush {
|
||||||
|
val stops = (0..20).map { i ->
|
||||||
|
val t = i / 20f
|
||||||
|
val y = lerp(startY, endY, t)
|
||||||
|
t to colorFromY(y)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Brush.linearGradient(
|
||||||
|
colorStops = stops.toTypedArray()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.layerBackdrop(backdrop)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(height)
|
||||||
|
.padding(horizontal = 20.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
) {
|
||||||
|
val dashCount = (height / 10.dp).toInt()
|
||||||
|
repeat(3) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.weight(1f),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxHeight(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
|
) {
|
||||||
|
for (i in 1..(dashCount)) {
|
||||||
|
val t = i.toFloat() / dashCount
|
||||||
|
val centerDistance = abs(0.5f - t)
|
||||||
|
val alpha = 1f - (centerDistance * 2f)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(9.dp)
|
||||||
|
.width(0.75.dp)
|
||||||
|
.background(
|
||||||
|
dashColor.copy(alpha),
|
||||||
|
RoundedCornerShape(28.dp)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val backgroundColor = MaterialTheme.colorScheme.surface
|
||||||
|
|
||||||
|
Canvas(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
) {
|
||||||
|
val canvasWidth = size.width
|
||||||
|
|
||||||
|
drawLine(
|
||||||
|
color = backgroundColor,
|
||||||
|
start = Offset(
|
||||||
|
x = 0f,
|
||||||
|
y = lowOffset.value + maxOffset
|
||||||
|
),
|
||||||
|
end = Offset(
|
||||||
|
x = 1 / 6f * canvasWidth,
|
||||||
|
y = lowOffset.value + maxOffset
|
||||||
|
),
|
||||||
|
strokeWidth = 10f
|
||||||
|
)
|
||||||
|
drawLine(
|
||||||
|
color = colorFromY(lowOffset.value),
|
||||||
|
start = Offset(
|
||||||
|
x = 0f,
|
||||||
|
y = lowOffset.value + maxOffset
|
||||||
|
),
|
||||||
|
end = Offset(
|
||||||
|
x = 1 / 6f * canvasWidth,
|
||||||
|
y = lowOffset.value + maxOffset
|
||||||
|
),
|
||||||
|
strokeWidth = 8f
|
||||||
|
)
|
||||||
|
|
||||||
|
val lowToMidPath = Path()
|
||||||
|
lowToMidPath.moveTo(
|
||||||
|
x = 1 / 6f * canvasWidth,
|
||||||
|
y = lowOffset.value + maxOffset
|
||||||
|
)
|
||||||
|
lowToMidPath.cubicTo(
|
||||||
|
x1 = canvasWidth * 1 / 6f + 108.dp.value,
|
||||||
|
y1 = lowOffset.value + maxOffset,
|
||||||
|
x2 = canvasWidth * 0.5f - 108.dp.value,
|
||||||
|
y2 = midOffset.value + maxOffset,
|
||||||
|
x3 = canvasWidth * 0.5f,
|
||||||
|
y3 = midOffset.value + maxOffset
|
||||||
|
)
|
||||||
|
drawPath(
|
||||||
|
color = backgroundColor,
|
||||||
|
path = lowToMidPath,
|
||||||
|
style = Stroke(width = 10f)
|
||||||
|
)
|
||||||
|
drawPath(
|
||||||
|
brush = pathBrush(
|
||||||
|
lowOffset.value,
|
||||||
|
midOffset.value
|
||||||
|
),
|
||||||
|
path = lowToMidPath,
|
||||||
|
style = Stroke(width = 8f)
|
||||||
|
)
|
||||||
|
|
||||||
|
val midToHighPath = Path()
|
||||||
|
midToHighPath.moveTo(
|
||||||
|
x = 0.5f * canvasWidth,
|
||||||
|
y = midOffset.value + maxOffset
|
||||||
|
)
|
||||||
|
midToHighPath.cubicTo(
|
||||||
|
x1 = canvasWidth * 0.5f + 108.dp.value,
|
||||||
|
y1 = midOffset.value + maxOffset,
|
||||||
|
x2 = canvasWidth * 5 / 6f - 108.dp.value,
|
||||||
|
y2 = highOffset.value + maxOffset,
|
||||||
|
x3 = canvasWidth * 5 / 6f,
|
||||||
|
y3 = highOffset.value + maxOffset
|
||||||
|
)
|
||||||
|
drawPath(
|
||||||
|
color = backgroundColor,
|
||||||
|
path = midToHighPath,
|
||||||
|
style = Stroke(width = 10f)
|
||||||
|
)
|
||||||
|
drawPath(
|
||||||
|
brush = pathBrush(
|
||||||
|
midOffset.value,
|
||||||
|
highOffset.value
|
||||||
|
),
|
||||||
|
path = midToHighPath,
|
||||||
|
style = Stroke(width = 8f)
|
||||||
|
)
|
||||||
|
drawLine(
|
||||||
|
color = backgroundColor,
|
||||||
|
start = Offset(
|
||||||
|
x = 5 / 6f * canvasWidth,
|
||||||
|
y = highOffset.value + maxOffset
|
||||||
|
),
|
||||||
|
end = Offset(
|
||||||
|
x = 1f * canvasWidth,
|
||||||
|
y = highOffset.value + maxOffset
|
||||||
|
),
|
||||||
|
strokeWidth = 10f
|
||||||
|
)
|
||||||
|
drawLine(
|
||||||
|
color = colorFromY(highOffset.value),
|
||||||
|
start = Offset(
|
||||||
|
x = 5 / 6f * canvasWidth,
|
||||||
|
y = highOffset.value + maxOffset
|
||||||
|
),
|
||||||
|
end = Offset(
|
||||||
|
x = 1f * canvasWidth,
|
||||||
|
y = highOffset.value + maxOffset
|
||||||
|
),
|
||||||
|
strokeWidth = 8f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 16.dp, horizontal = 20.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Low".uppercase(),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
|
||||||
|
0.2f
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Mid".uppercase(),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
|
||||||
|
0.2f
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "High".uppercase(),
|
||||||
|
style = TextStyle(
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
|
||||||
|
0.2f
|
||||||
|
),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(height)
|
||||||
|
.padding(horizontal = 20.dp),
|
||||||
|
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
for (i in 0..2) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f),
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
val pressed = remember { mutableStateOf(false) }
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.offset {
|
||||||
|
IntOffset(
|
||||||
|
x = 0,
|
||||||
|
y = when (i) { 0 -> lowOffset.value; 1 -> midOffset.value; 2-> highOffset.value else -> 0f}.roundToInt()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Crossfade(
|
||||||
|
pressed.value
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(96.dp)
|
||||||
|
.then(
|
||||||
|
if (it) {
|
||||||
|
Modifier.drawBackdrop(
|
||||||
|
backdrop = backdrop,
|
||||||
|
shape = { CircleShape },
|
||||||
|
highlight = {
|
||||||
|
Highlight.Ambient
|
||||||
|
},
|
||||||
|
onDrawSurface = {
|
||||||
|
drawCircle(
|
||||||
|
color = Color.White.copy(
|
||||||
|
0.2f
|
||||||
|
),
|
||||||
|
radius = size.height
|
||||||
|
)
|
||||||
|
drawCircle(
|
||||||
|
color = colorFromY(
|
||||||
|
when (i) {
|
||||||
|
0 -> lowOffset.value; 1 -> midOffset.value; 2 -> highOffset.value
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
),
|
||||||
|
style = Stroke(2.dp.value),
|
||||||
|
radius = size.height / 2
|
||||||
|
)
|
||||||
|
},
|
||||||
|
effects = {
|
||||||
|
lens(
|
||||||
|
refractionHeight = 32f.dp.value,
|
||||||
|
refractionAmount = size.height
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else Modifier
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(18.dp)
|
||||||
|
.background(
|
||||||
|
colorFromY(
|
||||||
|
when (i) {
|
||||||
|
0 -> lowOffset.value; 1 -> midOffset.value; 2 -> highOffset.value
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
),
|
||||||
|
CircleShape
|
||||||
|
)
|
||||||
|
.border(
|
||||||
|
2.5.dp,
|
||||||
|
MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
CircleShape
|
||||||
|
)
|
||||||
|
.draggable(
|
||||||
|
orientation = Orientation.Vertical,
|
||||||
|
state = rememberDraggableState { delta ->
|
||||||
|
when (i) {
|
||||||
|
0 -> {
|
||||||
|
lowOffset.value =
|
||||||
|
(lowOffset.value + delta).coerceIn(
|
||||||
|
-maxOffset,
|
||||||
|
maxOffset
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
1 -> {
|
||||||
|
midOffset.value =
|
||||||
|
(midOffset.value + delta).coerceIn(
|
||||||
|
-maxOffset,
|
||||||
|
maxOffset
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
2 -> {
|
||||||
|
highOffset.value =
|
||||||
|
(highOffset.value + delta).coerceIn(
|
||||||
|
-maxOffset,
|
||||||
|
maxOffset
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDragStarted = {
|
||||||
|
pressed.value = true
|
||||||
|
},
|
||||||
|
onDragStopped = {
|
||||||
|
pressed.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "Apple")
|
||||||
|
@Composable
|
||||||
|
fun EqualizerScreenPreviewApple() {
|
||||||
|
LibrePodsTheme(
|
||||||
|
m3eEnabled = false
|
||||||
|
) {
|
||||||
|
Box (
|
||||||
|
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
) {
|
||||||
|
EqualizerScreen(
|
||||||
|
state = demoState,
|
||||||
|
setCustomEqEnabled = { },
|
||||||
|
setCustomEq = {_, _, _ -> }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "Material")
|
||||||
|
@Composable
|
||||||
|
fun EqualizerScreenPreviewMaterial() {
|
||||||
|
LibrePodsTheme(
|
||||||
|
m3eEnabled = true
|
||||||
|
) {
|
||||||
|
val state = remember { mutableStateOf(demoState) }
|
||||||
|
Box (
|
||||||
|
modifier = Modifier
|
||||||
|
.wrapContentHeight()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
) {
|
||||||
|
EqualizerScreen(
|
||||||
|
state = state.value,
|
||||||
|
setCustomEqEnabled = { state.value = state.value.copy(customEq = state.value.customEq.copy(state = if (it) 2 else 1)) },
|
||||||
|
setCustomEq = {low, mid, high -> state.value = state.value.copy(customEq = state.value.customEq.copy(low = low, mid = mid, high = high))}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
-395
@@ -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,28 +110,11 @@ 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),
|
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||||
actionButtons = listOf(
|
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
||||||
{ scaffoldBackdrop ->
|
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
||||||
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("") }
|
var gestureText by remember { mutableStateOf("") }
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
@@ -156,41 +126,36 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController
|
|||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
.verticalScroll(scrollState),
|
.verticalScroll(scrollState),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
Column (
|
Column (
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.hazeSource(state = hazeState)
|
|
||||||
.layerBackdrop(backdrop)
|
.layerBackdrop(backdrop)
|
||||||
.padding(top = 8.dp)
|
.padding(top = 8.dp)
|
||||||
.padding(horizontal = 16.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(),
|
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(8.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
@@ -198,21 +163,11 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController
|
|||||||
checked = state.headGesturesEnabled,
|
checked = state.headGesturesEnabled,
|
||||||
onCheckedChange = { viewModel.setHeadGesturesEnabled(it) },
|
onCheckedChange = { viewModel.setHeadGesturesEnabled(it) },
|
||||||
enabled = state.isPremium || state.headGesturesEnabled,
|
enabled = state.isPremium || state.headGesturesEnabled,
|
||||||
description = stringResource(R.string.head_gestures_details)
|
description = stringResource(R.string.head_gestures_details),
|
||||||
|
header = true
|
||||||
)
|
)
|
||||||
|
|
||||||
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))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Text(
|
Text(
|
||||||
@@ -225,7 +180,7 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController
|
|||||||
),
|
),
|
||||||
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
|
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
|
||||||
)
|
)
|
||||||
AccelerationPlot()
|
Plot()
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
@@ -249,7 +204,9 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
backdrop = backdrop,
|
backdrop = backdrop,
|
||||||
modifier = Modifier.fillMaxWidth(0.75f),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
maxScale = 0.05f
|
maxScale = 0.05f
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
@@ -258,7 +215,7 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController
|
|||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
color = textColor
|
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -284,18 +241,15 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController
|
|||||||
gestureText = ""
|
gestureText = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ParticleText(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
fontSize = 20.sp,
|
fontSize = 20.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||||
color = textColor,
|
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
),
|
),
|
||||||
onAnimationComplete = {
|
color = MaterialTheme.colorScheme.onBackground
|
||||||
shouldExplode = false
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
@@ -313,321 +267,12 @@ fun HeadTrackingScreen(viewModel: AirPodsViewModel, navController: NavController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
|
||||||
}
|
|
||||||
private data class Particle(
|
|
||||||
val initialPosition: Offset,
|
|
||||||
val velocity: Offset,
|
|
||||||
var alpha: Float = 1f
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ParticleText(
|
|
||||||
text: String,
|
|
||||||
style: TextStyle,
|
|
||||||
onAnimationComplete: () -> Unit,
|
|
||||||
) {
|
|
||||||
val particles = remember { mutableStateListOf<Particle>() }
|
|
||||||
val textMeasurer = rememberTextMeasurer()
|
|
||||||
var isAnimating by remember { mutableStateOf(true) }
|
|
||||||
var textVisible by remember { mutableStateOf(true) }
|
|
||||||
|
|
||||||
Canvas(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
val textLayoutResult = textMeasurer.measure(text, style)
|
|
||||||
val textBounds = textLayoutResult.size
|
|
||||||
val centerX = (size.width - textBounds.width) / 2
|
|
||||||
val centerY = size.height / 2
|
|
||||||
|
|
||||||
if (textVisible && particles.isEmpty()) {
|
|
||||||
drawText(
|
|
||||||
textMeasurer = textMeasurer,
|
|
||||||
text = text,
|
|
||||||
style = style,
|
|
||||||
topLeft = Offset(centerX, centerY - textBounds.height / 2)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
@Composable
|
||||||
private fun HeadVisualization() {
|
private fun Plot() {
|
||||||
val orientation by HeadTracking.orientation.collectAsState()
|
|
||||||
val darkTheme = isSystemInDarkTheme()
|
|
||||||
val backgroundColor = if (darkTheme) Color(0xFF1C1C1E) else Color.White
|
|
||||||
val strokeColor = if (darkTheme) Color.White else Color.Black
|
|
||||||
|
|
||||||
Card(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.aspectRatio(2f),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = backgroundColor
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(28.dp)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Canvas(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
val width = size.width
|
|
||||||
val height = size.height
|
|
||||||
val center = Offset(width / 2, height / 2)
|
|
||||||
val faceRadius = height * 0.35f
|
|
||||||
|
|
||||||
val pitch = Math.toRadians(orientation.pitch.toDouble())
|
|
||||||
val yaw = Math.toRadians(orientation.yaw.toDouble())
|
|
||||||
|
|
||||||
val cosY = cos(yaw).toFloat()
|
|
||||||
val sinY = sin(yaw).toFloat()
|
|
||||||
val cosP = cos(pitch).toFloat()
|
|
||||||
val sinP = sin(pitch).toFloat()
|
|
||||||
|
|
||||||
fun rotate3D(point: Triple<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)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun project(point: Triple<Float, Float, Float>): Pair<Float, Float> {
|
|
||||||
val (x, y, z) = point
|
|
||||||
val scale = 1f + (z / width)
|
|
||||||
return Pair(center.x + x * scale, center.y + y * scale)
|
|
||||||
}
|
|
||||||
|
|
||||||
val earWidth = height * 0.08f
|
|
||||||
val earHeight = height * 0.2f
|
|
||||||
val earOffsetX = height * 0.4f
|
|
||||||
val earOffsetY = 0f
|
|
||||||
val earZ = 0f
|
|
||||||
|
|
||||||
for (xSign in listOf(-1f, 1f)) {
|
|
||||||
val rotated = rotate3D(Triple(earOffsetX * xSign, earOffsetY, earZ))
|
|
||||||
val (earX, earY) = project(rotated)
|
|
||||||
drawRoundRect(
|
|
||||||
color = strokeColor,
|
|
||||||
topLeft = Offset(earX - earWidth/2, earY - earHeight/2),
|
|
||||||
size = Size(earWidth, earHeight),
|
|
||||||
cornerRadius = CornerRadius(earWidth/2),
|
|
||||||
style = Stroke(width = 4.dp.toPx())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val spherePath = Path()
|
|
||||||
val firstPoint = project(rotate3D(Triple(faceRadius, 0f, 0f)))
|
|
||||||
spherePath.moveTo(firstPoint.first, firstPoint.second)
|
|
||||||
|
|
||||||
for (i in 1..32) {
|
|
||||||
val angle = (i * 2 * Math.PI / 32).toFloat()
|
|
||||||
val point = project(rotate3D(Triple(
|
|
||||||
cos(angle) * faceRadius,
|
|
||||||
sin(angle) * faceRadius,
|
|
||||||
0f
|
|
||||||
)))
|
|
||||||
spherePath.lineTo(point.first, point.second)
|
|
||||||
}
|
|
||||||
spherePath.close()
|
|
||||||
|
|
||||||
drawContext.canvas.nativeCanvas.apply {
|
|
||||||
val paint = Paint().apply {
|
|
||||||
style = Paint.Style.FILL
|
|
||||||
shader = RadialGradient(
|
|
||||||
center.x + sinY * faceRadius * 0.3f,
|
|
||||||
center.y - sinP * faceRadius * 0.3f,
|
|
||||||
faceRadius * 1.4f,
|
|
||||||
intArrayOf(
|
|
||||||
backgroundColor.copy(alpha = 1f).toArgb(),
|
|
||||||
backgroundColor.copy(alpha = 0.95f).toArgb(),
|
|
||||||
backgroundColor.copy(alpha = 0.9f).toArgb(),
|
|
||||||
backgroundColor.copy(alpha = 0.8f).toArgb(),
|
|
||||||
backgroundColor.copy(alpha = 0.7f).toArgb()
|
|
||||||
),
|
|
||||||
floatArrayOf(0.3f, 0.5f, 0.7f, 0.8f, 1f),
|
|
||||||
Shader.TileMode.CLAMP
|
|
||||||
)
|
|
||||||
}
|
|
||||||
drawPath(spherePath.asAndroidPath(), paint)
|
|
||||||
|
|
||||||
val highlightPaint = Paint().apply {
|
|
||||||
style = Paint.Style.FILL
|
|
||||||
shader = RadialGradient(
|
|
||||||
center.x - faceRadius * 0.4f - sinY * faceRadius * 0.5f,
|
|
||||||
center.y - faceRadius * 0.4f - sinP * faceRadius * 0.5f,
|
|
||||||
faceRadius * 0.9f,
|
|
||||||
intArrayOf(
|
|
||||||
android.graphics.Color.WHITE,
|
|
||||||
android.graphics.Color.argb(100, 255, 255, 255),
|
|
||||||
android.graphics.Color.TRANSPARENT
|
|
||||||
),
|
|
||||||
floatArrayOf(0f, 0.3f, 1f),
|
|
||||||
Shader.TileMode.CLAMP
|
|
||||||
)
|
|
||||||
alpha = if (darkTheme) 30 else 60
|
|
||||||
}
|
|
||||||
drawPath(spherePath.asAndroidPath(), highlightPaint)
|
|
||||||
|
|
||||||
val secondaryHighlightPaint = Paint().apply {
|
|
||||||
style = Paint.Style.FILL
|
|
||||||
shader = RadialGradient(
|
|
||||||
center.x + faceRadius * 0.3f + sinY * faceRadius * 0.3f,
|
|
||||||
center.y + faceRadius * 0.3f - sinP * faceRadius * 0.3f,
|
|
||||||
faceRadius * 0.7f,
|
|
||||||
intArrayOf(
|
|
||||||
android.graphics.Color.WHITE,
|
|
||||||
android.graphics.Color.TRANSPARENT
|
|
||||||
),
|
|
||||||
floatArrayOf(0f, 1f),
|
|
||||||
Shader.TileMode.CLAMP
|
|
||||||
)
|
|
||||||
alpha = if (darkTheme) 15 else 30
|
|
||||||
}
|
|
||||||
drawPath(spherePath.asAndroidPath(), secondaryHighlightPaint)
|
|
||||||
|
|
||||||
val shadowPaint = Paint().apply {
|
|
||||||
style = Paint.Style.FILL
|
|
||||||
shader = RadialGradient(
|
|
||||||
center.x + sinY * faceRadius * 0.5f,
|
|
||||||
center.y - sinP * faceRadius * 0.5f,
|
|
||||||
faceRadius * 1.1f,
|
|
||||||
intArrayOf(
|
|
||||||
android.graphics.Color.TRANSPARENT,
|
|
||||||
android.graphics.Color.BLACK
|
|
||||||
),
|
|
||||||
floatArrayOf(0.7f, 1f),
|
|
||||||
Shader.TileMode.CLAMP
|
|
||||||
)
|
|
||||||
alpha = if (darkTheme) 40 else 20
|
|
||||||
}
|
|
||||||
drawPath(spherePath.asAndroidPath(), shadowPaint)
|
|
||||||
}
|
|
||||||
|
|
||||||
drawPath(
|
|
||||||
path = spherePath,
|
|
||||||
color = strokeColor,
|
|
||||||
style = Stroke(width = 4.dp.toPx())
|
|
||||||
)
|
|
||||||
|
|
||||||
val smileRadius = faceRadius * 0.5f
|
|
||||||
val smileStartAngle = -340f
|
|
||||||
val smileSweepAngle = 140f
|
|
||||||
val smileOffsetY = faceRadius * 0.1f
|
|
||||||
|
|
||||||
val smilePath = Path()
|
|
||||||
for (i in 0..32) {
|
|
||||||
val angle = Math.toRadians(smileStartAngle + (smileSweepAngle * i / 32.0))
|
|
||||||
val x = cos(angle.toFloat()) * smileRadius
|
|
||||||
val y = sin(angle.toFloat()) * smileRadius + smileOffsetY
|
|
||||||
|
|
||||||
val rotated = rotate3D(Triple(x, y, 0f))
|
|
||||||
val projected = project(rotated)
|
|
||||||
|
|
||||||
if (i == 0) {
|
|
||||||
smilePath.moveTo(projected.first, projected.second)
|
|
||||||
} else {
|
|
||||||
smilePath.lineTo(projected.first, projected.second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
drawPath(
|
|
||||||
path = smilePath,
|
|
||||||
color = strokeColor,
|
|
||||||
style = Stroke(
|
|
||||||
width = 4.dp.toPx(),
|
|
||||||
cap = StrokeCap.Round
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val eyeOffsetX = height * 0.15f
|
|
||||||
val eyeOffsetY = height * 0.1f
|
|
||||||
val eyeLength = height * 0.08f
|
|
||||||
|
|
||||||
for (xSign in listOf(-1f, 1f)) {
|
|
||||||
val rotated = rotate3D(Triple(eyeOffsetX * xSign, -eyeOffsetY, 0f))
|
|
||||||
val (eyeX, eyeY) = project(rotated)
|
|
||||||
drawLine(
|
|
||||||
color = strokeColor,
|
|
||||||
start = Offset(eyeX, eyeY - eyeLength/2),
|
|
||||||
end = Offset(eyeX, eyeY + eyeLength/2),
|
|
||||||
strokeWidth = 4.dp.toPx(),
|
|
||||||
cap = StrokeCap.Round
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
drawContext.canvas.nativeCanvas.apply {
|
|
||||||
val paint = Paint().apply {
|
|
||||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
|
||||||
textSize = 12.sp.toPx()
|
|
||||||
textAlign = Paint.Align.RIGHT
|
|
||||||
typeface = Typeface.create(
|
|
||||||
"SF Pro",
|
|
||||||
Typeface.NORMAL
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val pitch = orientation.pitch.toInt()
|
|
||||||
val yaw = orientation.yaw.toInt()
|
|
||||||
val text = "Pitch: ${pitch}° Yaw: ${yaw}°"
|
|
||||||
|
|
||||||
drawText(
|
|
||||||
text,
|
|
||||||
width - 8.dp.toPx(),
|
|
||||||
height - 8.dp.toPx(),
|
|
||||||
paint
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AccelerationPlot() {
|
|
||||||
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
|
||||||
|
|||||||
+61
-128
@@ -20,50 +20,46 @@ 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.MutableState
|
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
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 kotlinx.coroutines.delay
|
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
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.StyledSlider
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
|
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||||
|
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
import java.io.IOException
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
|
|
||||||
private const val TAG = "HearingAidAdjustments"
|
private const val TAG = "HearingAidAdjustments"
|
||||||
|
|
||||||
@SuppressLint("DefaultLocale")
|
@SuppressLint("DefaultLocale")
|
||||||
@@ -71,67 +67,42 @@ 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 attManager = ServiceManager.getService()?.attManager ?: throw IllegalStateException("ATTManager not available")
|
|
||||||
|
|
||||||
val state by viewModel.uiState.collectAsState()
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
val debounceJob = remember { mutableStateOf<Job?>(null) }
|
||||||
StyledScaffold(
|
|
||||||
title = stringResource(R.string.adjustments)
|
|
||||||
) { spacerHeight ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.hazeSource(hazeState)
|
|
||||||
.fillMaxSize()
|
|
||||||
.layerBackdrop(backdrop)
|
|
||||||
.verticalScroll(verticalScrollState)
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
|
||||||
|
|
||||||
val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) }
|
val amplificationSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val balanceSliderValue = remember { mutableFloatStateOf(0.5f) }
|
val balanceSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val toneSliderValue = remember { mutableFloatStateOf(0.5f) }
|
val toneSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) }
|
val ambientNoiseReductionSliderValue = rememberSaveable { mutableFloatStateOf(0.0f) }
|
||||||
val conversationBoostEnabled = remember { mutableStateOf(false) }
|
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
|
||||||
val leftEQ = remember { mutableStateOf(FloatArray(8)) }
|
val leftEQ = rememberSaveable { mutableStateOf(FloatArray(8)) }
|
||||||
val rightEQ = remember { mutableStateOf(FloatArray(8)) }
|
val rightEQ = rememberSaveable { mutableStateOf(FloatArray(8)) }
|
||||||
val ownVoiceAmplification = remember { mutableFloatStateOf(0.5f) }
|
val ownVoiceAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
|
|
||||||
val initialLoadComplete = remember { mutableStateOf(false) }
|
val initialized = rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
val initialReadSucceeded = remember { mutableStateOf(false) }
|
val hearingAidSettings = remember { mutableStateOf(
|
||||||
val initialReadAttempts = remember { mutableIntStateOf(0) }
|
|
||||||
|
|
||||||
val hearingAidSettings = remember {
|
|
||||||
mutableStateOf(
|
|
||||||
HearingAidSettings(
|
HearingAidSettings(
|
||||||
leftEQ = leftEQ.value,
|
leftEQ = leftEQ.value,
|
||||||
rightEQ = rightEQ.value,
|
rightEQ = rightEQ.value,
|
||||||
leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2,
|
leftAmplification = 0f,
|
||||||
rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2,
|
rightAmplification = 0f,
|
||||||
leftTone = toneSliderValue.floatValue,
|
leftTone = 0f,
|
||||||
rightTone = toneSliderValue.floatValue,
|
rightTone = 0f,
|
||||||
leftConversationBoost = conversationBoostEnabled.value,
|
leftConversationBoost = false,
|
||||||
rightConversationBoost = conversationBoostEnabled.value,
|
rightConversationBoost = false,
|
||||||
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
leftAmbientNoiseReduction = 0f,
|
||||||
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
rightAmbientNoiseReduction = 0f,
|
||||||
netAmplification = amplificationSliderValue.floatValue,
|
netAmplification = 0f,
|
||||||
balance = balanceSliderValue.floatValue,
|
balance = 0f,
|
||||||
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
ownVoiceAmplification = 0f
|
||||||
)
|
)
|
||||||
)
|
) }
|
||||||
}
|
|
||||||
|
|
||||||
val hearingAidATTListener = remember {
|
LaunchedEffect(state.hearingAidData) {
|
||||||
object : (ByteArray) -> Unit {
|
parseHearingAidSettingsResponse(state.hearingAidData)?.let { parsed ->
|
||||||
override fun invoke(value: ByteArray) {
|
|
||||||
val parsed = parseHearingAidSettingsResponse(value)
|
|
||||||
if (parsed != null) {
|
|
||||||
amplificationSliderValue.floatValue = parsed.netAmplification
|
amplificationSliderValue.floatValue = parsed.netAmplification
|
||||||
balanceSliderValue.floatValue = parsed.balance
|
balanceSliderValue.floatValue = parsed.balance
|
||||||
toneSliderValue.floatValue = parsed.leftTone
|
toneSliderValue.floatValue = parsed.leftTone
|
||||||
@@ -140,25 +111,19 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
|
|||||||
leftEQ.value = parsed.leftEQ.copyOf()
|
leftEQ.value = parsed.leftEQ.copyOf()
|
||||||
rightEQ.value = parsed.rightEQ.copyOf()
|
rightEQ.value = parsed.rightEQ.copyOf()
|
||||||
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
|
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
|
||||||
Log.d(TAG, "Updated hearing aid settings from notification")
|
initialized.value = true
|
||||||
} else {
|
|
||||||
Log.w(TAG, "Failed to parse hearing aid settings from notification")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(amplificationSliderValue.floatValue, balanceSliderValue.floatValue, toneSliderValue.floatValue, conversationBoostEnabled.value, ambientNoiseReductionSliderValue.floatValue, ownVoiceAmplification.floatValue, initialLoadComplete.value, initialReadSucceeded.value) {
|
LaunchedEffect(
|
||||||
if (!initialLoadComplete.value) {
|
amplificationSliderValue.floatValue,
|
||||||
Log.d(TAG, "Initial device load not complete - skipping send")
|
balanceSliderValue.floatValue,
|
||||||
return@LaunchedEffect
|
toneSliderValue.floatValue,
|
||||||
}
|
conversationBoostEnabled.value,
|
||||||
|
ambientNoiseReductionSliderValue.floatValue,
|
||||||
if (!initialReadSucceeded.value) {
|
ownVoiceAmplification.floatValue
|
||||||
Log.d(TAG, "Initial device read not successful yet - skipping send until read succeeds")
|
) {
|
||||||
return@LaunchedEffect
|
if (!initialized.value) return@LaunchedEffect
|
||||||
}
|
|
||||||
|
|
||||||
hearingAidSettings.value = HearingAidSettings(
|
hearingAidSettings.value = HearingAidSettings(
|
||||||
leftEQ = leftEQ.value,
|
leftEQ = leftEQ.value,
|
||||||
rightEQ = rightEQ.value,
|
rightEQ = rightEQ.value,
|
||||||
@@ -175,53 +140,22 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
|
|||||||
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
||||||
)
|
)
|
||||||
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
||||||
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
|
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::setATTCharacteristicValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||||
Log.d(TAG, "Connecting to ATT...")
|
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
||||||
try {
|
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
||||||
attManager.enableNotifications(ATTHandles.HEARING_AID)
|
|
||||||
attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
|
|
||||||
|
|
||||||
var parsedSettings: HearingAidSettings? = null
|
Column(
|
||||||
for (attempt in 1..3) {
|
modifier = Modifier
|
||||||
initialReadAttempts.intValue = attempt
|
.fillMaxSize()
|
||||||
try {
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
val data = attManager.read(ATTHandles.HEARING_AID)
|
.verticalScroll(verticalScrollState)
|
||||||
parsedSettings = parseHearingAidSettingsResponse(data = data)
|
.padding(horizontal = 16.dp),
|
||||||
if (parsedSettings != null) {
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
Log.d(TAG, "Parsed settings on attempt $attempt")
|
) {
|
||||||
break
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
} else {
|
|
||||||
Log.d(TAG, "Parsing returned null on attempt $attempt")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
|
|
||||||
}
|
|
||||||
delay(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsedSettings != null) {
|
|
||||||
Log.d(TAG, "Initial hearing aid settings: $parsedSettings")
|
|
||||||
amplificationSliderValue.floatValue = parsedSettings.netAmplification
|
|
||||||
balanceSliderValue.floatValue = parsedSettings.balance
|
|
||||||
toneSliderValue.floatValue = parsedSettings.leftTone
|
|
||||||
ambientNoiseReductionSliderValue.floatValue = parsedSettings.leftAmbientNoiseReduction
|
|
||||||
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
|
||||||
leftEQ.value = parsedSettings.leftEQ.copyOf()
|
|
||||||
rightEQ.value = parsedSettings.rightEQ.copyOf()
|
|
||||||
ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification
|
|
||||||
initialReadSucceeded.value = true
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts")
|
|
||||||
}
|
|
||||||
} catch (e: IOException) {
|
|
||||||
e.printStackTrace()
|
|
||||||
} finally {
|
|
||||||
initialLoadComplete.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
StyledSlider(
|
StyledSlider(
|
||||||
label = stringResource(R.string.amplification),
|
label = stringResource(R.string.amplification),
|
||||||
@@ -235,7 +169,6 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
|
|||||||
independent = true,
|
independent = true,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.swipe_to_control_amplification),
|
label = stringResource(R.string.swipe_to_control_amplification),
|
||||||
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),
|
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),
|
||||||
@@ -284,9 +217,9 @@ fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
|
|||||||
label = stringResource(R.string.conversation_boost),
|
label = stringResource(R.string.conversation_boost),
|
||||||
checked = conversationBoostEnabled.value,
|
checked = conversationBoostEnabled.value,
|
||||||
onCheckedChange = { conversationBoostEnabled.value = it },
|
onCheckedChange = { conversationBoostEnabled.value = it },
|
||||||
independent = true,
|
|
||||||
description = stringResource(R.string.conversation_boost_description)
|
description = stringResource(R.string.conversation_boost_description)
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-62
@@ -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,22 +92,20 @@ 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) }
|
||||||
@@ -137,46 +131,18 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
|
|||||||
// // TODO
|
// // TODO
|
||||||
// }
|
// }
|
||||||
|
|
||||||
Text(
|
StyledList (title = stringResource(R.string.hearing_aid)) {
|
||||||
text = stringResource(R.string.hearing_aid),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
modifier = Modifier.padding(16.dp, bottom = 2.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
|
||||||
.clip(
|
|
||||||
RoundedCornerShape(28.dp)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.hearing_aid),
|
label = stringResource(R.string.hearing_aid),
|
||||||
checked = hearingAidEnabled.value,
|
checked = hearingAidEnabled.value,
|
||||||
onCheckedChange = { hearingAidEnabled.value = it },
|
onCheckedChange = { hearingAidEnabled.value = it },
|
||||||
independent = false
|
|
||||||
)
|
)
|
||||||
HorizontalDivider(
|
StyledListItem(
|
||||||
thickness = 1.dp,
|
|
||||||
color = Color(0x40888888),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
)
|
|
||||||
NavigationButton(
|
|
||||||
to = "hearing_aid_adjustments",
|
|
||||||
name = stringResource(R.string.adjustments),
|
name = stringResource(R.string.adjustments),
|
||||||
navController = navController,
|
onClick = onNavigateHearingAidAdjustments,
|
||||||
independent = false
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.hearing_aid_description),
|
text = stringResource(R.string.hearing_aid_description),
|
||||||
style = TextStyle(
|
style = TextStyle(
|
||||||
@@ -189,21 +155,19 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
NavigationButton(
|
StyledListItem(
|
||||||
to = "update_hearing_test",
|
|
||||||
name = stringResource(R.string.update_hearing_test),
|
name = stringResource(R.string.update_hearing_test),
|
||||||
navController = navController,
|
onClick = onNavigateHearingTest,
|
||||||
independent = true
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// not implemented yet
|
// not implemented yet
|
||||||
|
|
||||||
// StyledToggle(
|
// StyledToggle(
|
||||||
// title = stringResource(R.string.media_assist),
|
// titleRes = stringResource(R.string.media_assist),
|
||||||
// label = stringResource(R.string.media_assist),
|
// label = stringResource(R.string.media_assist),
|
||||||
// checkedState = mediaAssistEnabled,
|
// checkedState = mediaAssistEnabled,
|
||||||
// independent = true,
|
// independent = true,
|
||||||
// description = stringResource(R.string.media_assist_description)
|
// descriptionRes = stringResource(R.string.media_assist_description)
|
||||||
// )
|
// )
|
||||||
|
|
||||||
// Spacer(modifier = Modifier.height(8.dp))
|
// Spacer(modifier = Modifier.height(8.dp))
|
||||||
@@ -235,7 +199,7 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
|
|||||||
// }
|
// }
|
||||||
Spacer(modifier = Modifier.height(bottomPadding))
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
ConfirmationDialog(
|
ConfirmationDialog(
|
||||||
showDialog = showDialog,
|
showDialog = showDialog,
|
||||||
title = "Enable Hearing Aid",
|
title = "Enable Hearing Aid",
|
||||||
@@ -259,6 +223,10 @@ fun HearingAidScreen(viewModel: AirPodsViewModel, navController: NavController)
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val parsed = parseTransparencySettingsResponse(state.hearingAidData)
|
val parsed = parseTransparencySettingsResponse(state.hearingAidData)
|
||||||
|
if (parsed == null) {
|
||||||
|
Log.w(TAG, "transparency parse failed")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
val disabledSettings = parsed.copy(enabled = false)
|
val disabledSettings = parsed.copy(enabled = false)
|
||||||
sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings)
|
sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -270,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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-25
@@ -18,72 +18,70 @@
|
|||||||
|
|
||||||
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),
|
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||||
) { spacerHeight ->
|
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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
.layerBackdrop(backdrop)
|
.layerBackdrop(backdrop)
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
if (!state.isPremium) {
|
if (!state.isPremium) {
|
||||||
StyledButton(
|
StyledButton(
|
||||||
onClick = {
|
onClick = navigateToPurchase,
|
||||||
navController.navigate("purchase_screen")
|
|
||||||
},
|
|
||||||
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.vendorIdHook) {
|
if (state.vendorIdHook) {
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
title = stringResource(R.string.environmental_noise),
|
title = stringResource(R.string.environmental_noise),
|
||||||
@@ -115,6 +113,6 @@ fun HearingProtectionScreen(viewModel: AirPodsViewModel, navController: NavContr
|
|||||||
},
|
},
|
||||||
enabled = state.isPremium
|
enabled = state.isPremium
|
||||||
)
|
)
|
||||||
}
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+101
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-11
@@ -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,21 +54,22 @@ 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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.layerBackdrop(backdrop)
|
.layerBackdrop(backdrop)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val libraries by produceLibraries {
|
val libraries by produceLibraries {
|
||||||
context.resources.openRawResource(R.raw.aboutlibraries)
|
context.resources.openRawResource(R.raw.aboutlibraries)
|
||||||
@@ -77,6 +82,6 @@ fun OpenSourceLicensesScreen(navController: NavController) {
|
|||||||
.padding(0.dp)
|
.padding(0.dp)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
)
|
)
|
||||||
}
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-84
@@ -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
|
||||||
|
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
|
|
||||||
Column (
|
Column (
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.layerBackdrop(backdrop)
|
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
.verticalScroll(scrollState)
|
||||||
.padding(top = 8.dp)
|
.padding(top = 8.dp)
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
val actionItems = listOf(
|
|
||||||
SelectItem(
|
StyledList {
|
||||||
|
StyledListItem(
|
||||||
name = stringResource(R.string.noise_control),
|
name = stringResource(R.string.noise_control),
|
||||||
selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES,
|
selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES,
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.setLongPressAction(name, StemAction.CYCLE_NOISE_CONTROL_MODES)
|
viewModel.setLongPressAction(
|
||||||
|
name,
|
||||||
|
StemAction.CYCLE_NOISE_CONTROL_MODES
|
||||||
|
)
|
||||||
}
|
}
|
||||||
),
|
)
|
||||||
SelectItem(
|
|
||||||
|
StyledListItem(
|
||||||
name = stringResource(R.string.digital_assistant),
|
name = stringResource(R.string.digital_assistant),
|
||||||
selected = longPressAction == StemAction.DIGITAL_ASSISTANT,
|
selected = longPressAction == StemAction.DIGITAL_ASSISTANT,
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.setLongPressAction(name, StemAction.DIGITAL_ASSISTANT)
|
viewModel.setLongPressAction(
|
||||||
|
name,
|
||||||
|
StemAction.DIGITAL_ASSISTANT
|
||||||
|
)
|
||||||
},
|
},
|
||||||
enabled = state.isPremium
|
enabled = state.isPremium
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
StyledSelectList(items = actionItems)
|
|
||||||
|
|
||||||
if (!state.isPremium) {
|
if (!state.isPremium) {
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
StyledButton(
|
StyledButton(
|
||||||
onClick = {
|
onClick = navigateToPurchase,
|
||||||
navController.navigate("purchase_screen")
|
|
||||||
},
|
|
||||||
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(8.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) {
|
if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) {
|
||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.noise_control),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 18.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
val currentByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0)?.toInt() ?: 0
|
val currentByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0)?.toInt() ?: 0
|
||||||
|
|
||||||
val listeningModeItems = mutableListOf<SelectItem>()
|
StyledList(
|
||||||
|
title = stringResource(R.string.noise_control),
|
||||||
|
description = stringResource(R.string.press_and_hold_noise_control_description)
|
||||||
|
) {
|
||||||
if (state.offListeningMode) {
|
if (state.offListeningMode) {
|
||||||
listeningModeItems.add(
|
StyledListItem(
|
||||||
SelectItem(
|
|
||||||
name = stringResource(R.string.off),
|
name = stringResource(R.string.off),
|
||||||
description = stringResource(R.string.listening_mode_off_description),
|
description = stringResource(R.string.listening_mode_off_description),
|
||||||
iconRes = R.drawable.noise_cancellation,
|
|
||||||
selected = (currentByte and 0x01) != 0,
|
selected = (currentByte and 0x01) != 0,
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.toggleListeningMode(0x01)
|
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()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
listeningModeItems.addAll(listOf(
|
)
|
||||||
SelectItem(
|
}
|
||||||
|
|
||||||
|
StyledListItem(
|
||||||
name = stringResource(R.string.transparency),
|
name = stringResource(R.string.transparency),
|
||||||
description = stringResource(R.string.listening_mode_transparency_description),
|
description = stringResource(R.string.listening_mode_transparency_description),
|
||||||
iconRes = R.drawable.transparency,
|
|
||||||
selected = (currentByte and 0x04) != 0,
|
selected = (currentByte and 0x04) != 0,
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.toggleListeningMode(0x04)
|
viewModel.toggleListeningMode(0x04)
|
||||||
|
},
|
||||||
|
orientation = ListItemOrientation.Vertical,
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.transparency),
|
||||||
|
contentDescription = "Icon",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier
|
||||||
|
.height(42.dp)
|
||||||
|
.wrapContentWidth()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
),
|
)
|
||||||
SelectItem(
|
|
||||||
|
StyledListItem(
|
||||||
name = stringResource(R.string.adaptive),
|
name = stringResource(R.string.adaptive),
|
||||||
description = stringResource(R.string.listening_mode_adaptive_description),
|
description = stringResource(R.string.listening_mode_adaptive_description),
|
||||||
iconRes = R.drawable.adaptive,
|
|
||||||
selected = (currentByte and 0x08) != 0,
|
selected = (currentByte and 0x08) != 0,
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.toggleListeningMode(0x08)
|
viewModel.toggleListeningMode(0x08)
|
||||||
|
},
|
||||||
|
orientation = ListItemOrientation.Vertical,
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.adaptive),
|
||||||
|
contentDescription = "Icon",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier
|
||||||
|
.height(42.dp)
|
||||||
|
.wrapContentWidth()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
),
|
)
|
||||||
SelectItem(
|
|
||||||
|
StyledListItem(
|
||||||
name = stringResource(R.string.noise_cancellation),
|
name = stringResource(R.string.noise_cancellation),
|
||||||
description = stringResource(R.string.listening_mode_noise_cancellation_description),
|
description = stringResource(R.string.listening_mode_noise_cancellation_description),
|
||||||
iconRes = R.drawable.noise_cancellation,
|
|
||||||
selected = (currentByte and 0x02) != 0,
|
selected = (currentByte and 0x02) != 0,
|
||||||
onClick = {
|
onClick = {
|
||||||
viewModel.toggleListeningMode(0x02)
|
viewModel.toggleListeningMode(0x02)
|
||||||
}
|
},
|
||||||
)
|
orientation = ListItemOrientation.Vertical,
|
||||||
))
|
leadingContent = {
|
||||||
StyledSelectList(items = listeningModeItems)
|
Icon(
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
painter = painterResource(R.drawable.noise_cancellation),
|
||||||
Text(
|
contentDescription = "Icon",
|
||||||
text = stringResource(R.string.press_and_hold_noise_control_description),
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = FontWeight.Light,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(horizontal = 18.dp)
|
.height(42.dp)
|
||||||
|
.wrapContentWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-417
@@ -19,463 +19,161 @@
|
|||||||
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.BuildConfig
|
|
||||||
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
|
||||||
|
|
||||||
@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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
|
||||||
.layerBackdrop(backdrop)
|
.layerBackdrop(backdrop)
|
||||||
.hazeSource(state = hazeState)
|
|
||||||
.verticalScroll(scrollState)
|
.verticalScroll(scrollState)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(topPadding))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
val isDarkTheme = isSystemInDarkTheme()
|
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7)
|
|
||||||
val cardBackgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
|
||||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
|
||||||
LaunchedEffect(state.isPremium) {
|
LaunchedEffect(state.isPremium) {
|
||||||
if (state.isPremium) {
|
if (state.isPremium) {
|
||||||
navController.popBackStack()
|
if (backStack.size > 1) {
|
||||||
|
backStack.removeAt(backStack.lastIndex)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!state.isPremium) {
|
if (!state.isPremium) {
|
||||||
Box(
|
StyledList(title = stringResource(R.string.free_features)) {
|
||||||
modifier = Modifier
|
StyledListItem(
|
||||||
.background(backgroundColor)
|
name = stringResource(R.string.ear_detection),
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
description = stringResource(R.string.ear_detection_description),
|
||||||
) {
|
enabled = false,
|
||||||
Text(
|
orientation = ListItemOrientation.Vertical
|
||||||
text = stringResource(R.string.free_features),
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = textColor.copy(alpha = 0.6f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
StyledListItem(
|
||||||
|
name = stringResource(R.string.battery),
|
||||||
|
description = stringResource(R.string.battery_description),
|
||||||
|
enabled = false,
|
||||||
|
orientation = ListItemOrientation.Vertical
|
||||||
)
|
)
|
||||||
}
|
|
||||||
Column(
|
StyledListItem(
|
||||||
modifier = Modifier
|
name = stringResource(R.string.noise_control),
|
||||||
.fillMaxWidth()
|
description = stringResource(R.string.noise_control_description),
|
||||||
.background(cardBackgroundColor, RoundedCornerShape(28.dp))
|
enabled = false,
|
||||||
.padding(horizontal = 8.dp, vertical = 16.dp),
|
orientation = ListItemOrientation.Vertical
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (XposedState.isAvailable) {
|
||||||
|
StyledListItem(
|
||||||
|
name = "${stringResource(R.string.hearing_aid)} (${stringResource(R.string.requires_xposed)})",
|
||||||
|
description = stringResource(R.string.hearing_aid_description)
|
||||||
|
.substringBefore("\n\n"),
|
||||||
|
enabled = false,
|
||||||
|
orientation = ListItemOrientation.Vertical
|
||||||
)
|
)
|
||||||
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 (BuildConfig.FLAVOR == "xposed") {
|
|
||||||
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))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
StyledList(title = stringResource(R.string.advanced_features), description = stringResource(R.string.feature_availability_disclaimer)) {
|
||||||
.background(backgroundColor)
|
StyledListItem(
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
name = stringResource(R.string.conversational_awareness),
|
||||||
) {
|
description = stringResource(R.string.conversational_awareness_description),
|
||||||
Text(
|
enabled = false,
|
||||||
text = stringResource(R.string.advanced_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.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.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))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
@@ -486,17 +184,13 @@ fun PurchaseScreen(
|
|||||||
backdrop = rememberLayerBackdrop(),
|
backdrop = rememberLayerBackdrop(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
maxScale = 0.05f,
|
maxScale = 0.05f,
|
||||||
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF0091FF)
|
surfaceColor = MaterialTheme.colorScheme.primary,
|
||||||
else Color(0xFF0088FF) // if (isSystemInDarkTheme()) Color(0xFF916100) else Color(0xFFE59900)
|
materialButtonStyle = MaterialButtonStyle.Filled
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.buy_price, state.price),
|
stringResource(R.string.buy_price, state.price),
|
||||||
style = TextStyle(
|
style = MaterialTheme.typography.bodyMediumEmphasized,
|
||||||
fontSize = 16.sp,
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = Color.White
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,20 +203,15 @@ fun PurchaseScreen(
|
|||||||
backdrop = rememberLayerBackdrop(),
|
backdrop = rememberLayerBackdrop(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
maxScale = 0.05f,
|
maxScale = 0.05f,
|
||||||
isInteractive = false
|
isInteractive = false,
|
||||||
|
materialButtonStyle = MaterialButtonStyle.Outlined
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.restore_purchases),
|
stringResource(R.string.restore_purchases),
|
||||||
style = TextStyle(
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = textColor
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(bottomPadding))
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+471
@@ -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.updates
|
||||||
|
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").removeSuffix("-play")
|
||||||
|
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 = updates,
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-10
@@ -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,18 +63,21 @@ 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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
|
val name = sharedPreferences.getString("name", "")?: ""
|
||||||
|
val textFieldState = rememberTextFieldState(initialText = name)
|
||||||
|
|
||||||
val textFieldState = rememberTextFieldState()
|
|
||||||
textFieldState.edit { sharedPreferences.getString("name", "") ?: "" }
|
|
||||||
LaunchedEffect(textFieldState.text) {
|
LaunchedEffect(textFieldState.text) {
|
||||||
sharedPreferences.edit {putString("name", textFieldState.text as String?)}
|
sharedPreferences.edit {putString("name", textFieldState.text as String?)}
|
||||||
viewModel.setName(textFieldState.text.toString())
|
viewModel.setName(textFieldState.text.toString())
|
||||||
@@ -79,6 +87,8 @@ fun RenameScreen(viewModel: AirPodsViewModel) {
|
|||||||
textFieldState,
|
textFieldState,
|
||||||
focusRequester
|
focusRequester
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-92
@@ -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
|
||||||
@@ -46,9 +51,10 @@ 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.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.Saver
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
@@ -60,21 +66,16 @@ 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 kotlinx.coroutines.delay
|
|
||||||
import me.kavishdevar.librepods.BuildConfig
|
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
|
||||||
import me.kavishdevar.librepods.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.StyledSlider
|
||||||
|
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||||
|
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||||
|
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
import java.io.IOException
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
private const val TAG = "TransparencySettings"
|
private const val TAG = "TransparencySettings"
|
||||||
@@ -92,19 +93,16 @@ 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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.hazeSource(hazeState)
|
|
||||||
.layerBackdrop(backdrop)
|
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
.verticalScroll(verticalScrollState)
|
.verticalScroll(verticalScrollState)
|
||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
@@ -112,19 +110,26 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
|
|||||||
Spacer(modifier = Modifier.height(topPadding))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||||
|
|
||||||
val enabled = remember { mutableStateOf(false) }
|
val enabled = rememberSaveable { mutableStateOf(false) }
|
||||||
val amplificationSliderValue = remember { mutableFloatStateOf(0.5f) }
|
val amplificationSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val balanceSliderValue = remember { mutableFloatStateOf(0.5f) }
|
val balanceSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val toneSliderValue = remember { mutableFloatStateOf(0.5f) }
|
val toneSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val ambientNoiseReductionSliderValue = remember { mutableFloatStateOf(0.0f) }
|
val ambientNoiseReductionSliderValue = rememberSaveable { mutableFloatStateOf(0.0f) }
|
||||||
val conversationBoostEnabled = remember { mutableStateOf(false) }
|
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
|
||||||
val eq = remember { mutableStateOf(FloatArray(8)) }
|
val eq = rememberSaveable(
|
||||||
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
|
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 initialLoadComplete = remember { mutableStateOf(false) }
|
val initialized = rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
val initialReadSucceeded = remember { mutableStateOf(false) }
|
|
||||||
val initialReadAttempts = remember { mutableIntStateOf(0) }
|
|
||||||
|
|
||||||
val transparencySettings = remember {
|
val transparencySettings = remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
@@ -153,23 +158,9 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
|
|||||||
toneSliderValue.floatValue,
|
toneSliderValue.floatValue,
|
||||||
conversationBoostEnabled.value,
|
conversationBoostEnabled.value,
|
||||||
ambientNoiseReductionSliderValue.floatValue,
|
ambientNoiseReductionSliderValue.floatValue,
|
||||||
eq.value,
|
eq.value
|
||||||
initialLoadComplete.value,
|
|
||||||
initialReadSucceeded.value
|
|
||||||
) {
|
) {
|
||||||
if (!initialLoadComplete.value) {
|
if (!initialized.value) return@LaunchedEffect
|
||||||
Log.d(TAG, "Initial device load not complete - skipping send")
|
|
||||||
return@LaunchedEffect
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!initialReadSucceeded.value) {
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"Initial device read not successful yet - skipping send until read succeeds"
|
|
||||||
)
|
|
||||||
return@LaunchedEffect
|
|
||||||
}
|
|
||||||
|
|
||||||
transparencySettings.value = TransparencySettings(
|
transparencySettings.value = TransparencySettings(
|
||||||
enabled = enabled.value,
|
enabled = enabled.value,
|
||||||
leftEQ = eq.value,
|
leftEQ = eq.value,
|
||||||
@@ -189,38 +180,8 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
|
|||||||
sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
|
sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(state.transparencyData) {
|
||||||
Log.d(TAG, "Connecting to ATT...")
|
val parsedSettings = parseTransparencySettingsResponse(data = state.transparencyData) ?: return@LaunchedEffect
|
||||||
try {
|
|
||||||
// If we have an AACP manager, prefer its EQ data to populate EQ controls first
|
|
||||||
try {
|
|
||||||
Log.d(TAG, "Found AACPManager, reading cached EQ data")
|
|
||||||
val aacpEQ = state.eqData
|
|
||||||
if (aacpEQ.isNotEmpty()) {
|
|
||||||
eq.value = aacpEQ.copyOf()
|
|
||||||
phoneMediaEQ.value = aacpEQ.copyOf()
|
|
||||||
Log.d(TAG, "Populated EQ from AACPManager: ${aacpEQ.toList()}")
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "AACPManager EQ data empty")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Error reading EQ from AACPManager: ${e.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
var parsedSettings: TransparencySettings? = null
|
|
||||||
for (attempt in 1..3) {
|
|
||||||
initialReadAttempts.intValue = attempt
|
|
||||||
try {
|
|
||||||
val data = state.transparencyData
|
|
||||||
parsedSettings = parseTransparencySettingsResponse(data = data)
|
|
||||||
Log.d(TAG, "Parsed settings on attempt $attempt")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
|
|
||||||
}
|
|
||||||
delay(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsedSettings != null) {
|
|
||||||
Log.d(TAG, "Initial transparency settings: $parsedSettings")
|
Log.d(TAG, "Initial transparency settings: $parsedSettings")
|
||||||
enabled.value = parsedSettings.enabled
|
enabled.value = parsedSettings.enabled
|
||||||
amplificationSliderValue.floatValue = parsedSettings.netAmplification
|
amplificationSliderValue.floatValue = parsedSettings.netAmplification
|
||||||
@@ -229,26 +190,16 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
|
|||||||
ambientNoiseReductionSliderValue.floatValue =
|
ambientNoiseReductionSliderValue.floatValue =
|
||||||
parsedSettings.leftAmbientNoiseReduction
|
parsedSettings.leftAmbientNoiseReduction
|
||||||
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
||||||
|
if (!eq.value.contentEquals(parsedSettings.leftEQ)) {
|
||||||
eq.value = parsedSettings.leftEQ.copyOf()
|
eq.value = parsedSettings.leftEQ.copyOf()
|
||||||
initialReadSucceeded.value = true
|
|
||||||
} else {
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"Failed to read/parse initial transparency settings after ${initialReadAttempts.intValue} attempts"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: IOException) {
|
|
||||||
e.printStackTrace()
|
|
||||||
} finally {
|
|
||||||
initialLoadComplete.value = true
|
|
||||||
}
|
}
|
||||||
|
initialized.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.vendorIdHook) {
|
if (state.vendorIdHook) {
|
||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.transparency_mode),
|
label = stringResource(R.string.transparency_mode),
|
||||||
checked = enabled.value,
|
checked = enabled.value,
|
||||||
independent = true,
|
|
||||||
description = stringResource(R.string.customize_transparency_mode_description),
|
description = stringResource(R.string.customize_transparency_mode_description),
|
||||||
onCheckedChange = { enabled.value = it }
|
onCheckedChange = { enabled.value = it }
|
||||||
)
|
)
|
||||||
@@ -305,7 +256,6 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
|
|||||||
StyledToggle(
|
StyledToggle(
|
||||||
label = stringResource(R.string.conversation_boost),
|
label = stringResource(R.string.conversation_boost),
|
||||||
checked = conversationBoostEnabled.value,
|
checked = conversationBoostEnabled.value,
|
||||||
independent = true,
|
|
||||||
description = stringResource(R.string.conversation_boost_description),
|
description = stringResource(R.string.conversation_boost_description),
|
||||||
onCheckedChange = { conversationBoostEnabled.value = it }
|
onCheckedChange = { conversationBoostEnabled.value = it }
|
||||||
)
|
)
|
||||||
@@ -412,5 +362,4 @@ fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(bottomPadding))
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-9
@@ -38,10 +38,14 @@ 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.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
@@ -84,17 +88,14 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
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.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
|
||||||
import me.kavishdevar.librepods.utils.LogCollector
|
import me.kavishdevar.librepods.utils.LogCollector
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
@@ -118,7 +119,7 @@ fun CustomIconButton(
|
|||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun TroubleshootingScreen(navController: NavController) {
|
fun TroubleshootingScreen() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
@@ -214,15 +215,15 @@ fun TroubleshootingScreen(navController: NavController) {
|
|||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize()
|
modifier = Modifier.fillMaxSize()
|
||||||
) {
|
) {
|
||||||
StyledScaffold(
|
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
||||||
title = stringResource(R.string.troubleshooting)
|
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
||||||
){ topPadding, hazeState, bottomPadding ->
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.layerBackdrop(backdrop)
|
.layerBackdrop(backdrop)
|
||||||
.hazeSource(state = hazeState)
|
|
||||||
.verticalScroll(scrollState)
|
.verticalScroll(scrollState)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(topPadding))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
@@ -745,7 +746,8 @@ fun TroubleshootingScreen(navController: NavController) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showBottomSheet) {
|
if (showBottomSheet) {
|
||||||
|
|||||||
+116
-142
@@ -19,111 +19,133 @@
|
|||||||
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
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.Saver
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.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 kotlinx.coroutines.delay
|
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
import me.kavishdevar.librepods.presentation.components.StyledScaffold
|
|
||||||
import me.kavishdevar.librepods.services.ServiceManager
|
|
||||||
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
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 java.io.IOException
|
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||||
|
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||||
|
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsUiState
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||||
|
import me.kavishdevar.librepods.presentation.viewmodel.demoState
|
||||||
|
|
||||||
private var debounceJob: MutableState<Job?> = mutableStateOf(null)
|
private const val TAG = "UpdateHearingTestScreen"
|
||||||
private const val TAG = "HearingAidAdjustments"
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UpdateHearingTestScreen() {
|
fun UpdateHearingTestRoute(viewModel: AirPodsViewModel) {
|
||||||
val verticalScrollState = rememberScrollState()
|
val state by viewModel.uiState.collectAsState()
|
||||||
val attManager = ServiceManager.getService()?.attManager
|
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||||
if (attManager == null) {
|
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
||||||
Text(
|
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
||||||
text = stringResource(R.string.att_manager_is_null_try_reconnecting),
|
|
||||||
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(16.dp),
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
textAlign = TextAlign.Center
|
) {
|
||||||
|
UpdateHearingTestScreen(
|
||||||
|
state = state,
|
||||||
|
topPadding = topPadding,
|
||||||
|
bottomPadding = bottomPadding,
|
||||||
|
setATTCharacteristicValue = viewModel::setATTCharacteristicValue
|
||||||
)
|
)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UpdateHearingTestScreen(
|
||||||
|
state: AirPodsUiState,
|
||||||
|
topPadding: Dp = 16.dp,
|
||||||
|
bottomPadding: Dp = 16.dp,
|
||||||
|
setATTCharacteristicValue: (ATTHandles, ByteArray) -> Unit
|
||||||
|
) {
|
||||||
|
val verticalScrollState = rememberScrollState()
|
||||||
|
|
||||||
val backdrop = rememberLayerBackdrop()
|
|
||||||
StyledScaffold(
|
|
||||||
title = stringResource(R.string.hearing_test)
|
|
||||||
) { topPadding, hazeState, bottomPadding ->
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.hazeSource(hazeState)
|
|
||||||
.fillMaxSize()
|
|
||||||
.layerBackdrop(backdrop)
|
|
||||||
.verticalScroll(verticalScrollState)
|
.verticalScroll(verticalScrollState)
|
||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(topPadding))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.hearing_test_value_instruction),
|
text = stringResource(R.string.hearing_test_value_instruction),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
style = MaterialTheme.typography.labelMedium,
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
val tone = remember { mutableFloatStateOf(0.5f) }
|
val tone = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val ambientNoiseReduction = remember { mutableFloatStateOf(0.0f) }
|
val ambientNoiseReduction = rememberSaveable { mutableFloatStateOf(0.0f) }
|
||||||
val ownVoiceAmplification = remember { mutableFloatStateOf(0.5f) }
|
val ownVoiceAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val leftAmplification = remember { mutableFloatStateOf(0.5f) }
|
val leftAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val rightAmplification = remember { mutableFloatStateOf(0.5f) }
|
val rightAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||||
val conversationBoostEnabled = remember { mutableStateOf(false) }
|
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
|
||||||
val leftEQ = remember { mutableStateOf(FloatArray(8)) }
|
val leftEQ = rememberSaveable(
|
||||||
val rightEQ = remember { mutableStateOf(FloatArray(8)) }
|
saver = Saver(
|
||||||
|
save = { it.value.toList() },
|
||||||
|
restore = { mutableStateOf(it.toFloatArray()) }
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
mutableStateOf(FloatArray(8))
|
||||||
|
}
|
||||||
|
val rightEQ = rememberSaveable(
|
||||||
|
saver = Saver(
|
||||||
|
save = { it.value.toList() },
|
||||||
|
restore = { mutableStateOf(it.toFloatArray()) }
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
mutableStateOf(FloatArray(8))
|
||||||
|
}
|
||||||
|
|
||||||
val initialLoadComplete = remember { mutableStateOf(false) }
|
val debounceJob = remember { mutableStateOf<Job?>(null) }
|
||||||
val initialReadSucceeded = remember { mutableStateOf(false) }
|
val initialized = rememberSaveable { mutableStateOf(false) }
|
||||||
val initialReadAttempts = remember { mutableIntStateOf(0) }
|
|
||||||
|
|
||||||
val hearingAidSettings = remember {
|
val hearingAidSettings = remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
@@ -145,10 +167,8 @@ fun UpdateHearingTestScreen() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val hearingAidATTListener = remember {
|
LaunchedEffect(state.hearingAidData) {
|
||||||
object : (ByteArray) -> Unit {
|
val parsed = parseHearingAidSettingsResponse(state.hearingAidData)
|
||||||
override fun invoke(value: ByteArray) {
|
|
||||||
val parsed = parseHearingAidSettingsResponse(value)
|
|
||||||
if (parsed != null) {
|
if (parsed != null) {
|
||||||
leftEQ.value = parsed.leftEQ.copyOf()
|
leftEQ.value = parsed.leftEQ.copyOf()
|
||||||
rightEQ.value = parsed.rightEQ.copyOf()
|
rightEQ.value = parsed.rightEQ.copyOf()
|
||||||
@@ -158,46 +178,24 @@ fun UpdateHearingTestScreen() {
|
|||||||
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
|
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
|
||||||
leftAmplification.floatValue = parsed.leftAmplification
|
leftAmplification.floatValue = parsed.leftAmplification
|
||||||
rightAmplification.floatValue = parsed.rightAmplification
|
rightAmplification.floatValue = parsed.rightAmplification
|
||||||
|
initialized.value = true
|
||||||
Log.d(TAG, "Updated hearing aid settings from notification")
|
Log.d(TAG, "Updated hearing aid settings from notification")
|
||||||
} else {
|
} else {
|
||||||
Log.w(TAG, "Failed to parse hearing aid settings from notification")
|
Log.w(TAG, "Failed to parse hearing aid settings from notification")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
|
||||||
onDispose {
|
|
||||||
attManager.unregisterListener(ATTHandles.HEARING_AID, hearingAidATTListener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(
|
LaunchedEffect(
|
||||||
leftEQ.value,
|
leftEQ.value,
|
||||||
rightEQ.value,
|
rightEQ.value,
|
||||||
conversationBoostEnabled.value,
|
conversationBoostEnabled.value,
|
||||||
initialLoadComplete.value,
|
|
||||||
initialReadSucceeded.value,
|
|
||||||
leftAmplification.floatValue,
|
leftAmplification.floatValue,
|
||||||
rightAmplification.floatValue,
|
rightAmplification.floatValue,
|
||||||
tone.floatValue,
|
tone.floatValue,
|
||||||
ambientNoiseReduction.floatValue,
|
ambientNoiseReduction.floatValue,
|
||||||
ownVoiceAmplification.floatValue
|
ownVoiceAmplification.floatValue
|
||||||
) {
|
) {
|
||||||
if (!initialLoadComplete.value) {
|
if (!initialized.value) return@LaunchedEffect
|
||||||
Log.d(TAG, "Initial device load not complete - skipping send")
|
|
||||||
return@LaunchedEffect
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!initialReadSucceeded.value) {
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"Initial device read not successful yet - skipping send until read succeeds"
|
|
||||||
)
|
|
||||||
return@LaunchedEffect
|
|
||||||
}
|
|
||||||
|
|
||||||
hearingAidSettings.value = HearingAidSettings(
|
hearingAidSettings.value = HearingAidSettings(
|
||||||
leftEQ = leftEQ.value,
|
leftEQ = leftEQ.value,
|
||||||
rightEQ = rightEQ.value,
|
rightEQ = rightEQ.value,
|
||||||
@@ -214,55 +212,7 @@ fun UpdateHearingTestScreen() {
|
|||||||
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
||||||
)
|
)
|
||||||
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
||||||
sendHearingAidSettings(attManager, hearingAidSettings.value, debounceJob)
|
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, setATTCharacteristicValue)
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
Log.d(TAG, "Connecting to ATT...")
|
|
||||||
try {
|
|
||||||
attManager.enableNotifications(ATTHandles.HEARING_AID)
|
|
||||||
attManager.registerListener(ATTHandles.HEARING_AID, hearingAidATTListener)
|
|
||||||
|
|
||||||
var parsedSettings: HearingAidSettings? = null
|
|
||||||
for (attempt in 1..3) {
|
|
||||||
initialReadAttempts.intValue = attempt
|
|
||||||
try {
|
|
||||||
val data = attManager.read(ATTHandles.HEARING_AID)
|
|
||||||
parsedSettings = parseHearingAidSettingsResponse(data = data)
|
|
||||||
if (parsedSettings != null) {
|
|
||||||
Log.d(TAG, "Parsed settings on attempt $attempt")
|
|
||||||
break
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "Parsing returned null on attempt $attempt")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Read attempt $attempt failed: ${e.message}")
|
|
||||||
}
|
|
||||||
delay(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsedSettings != null) {
|
|
||||||
Log.d(TAG, "Initial hearing aid settings: $parsedSettings")
|
|
||||||
leftEQ.value = parsedSettings.leftEQ.copyOf()
|
|
||||||
rightEQ.value = parsedSettings.rightEQ.copyOf()
|
|
||||||
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
|
||||||
tone.floatValue = parsedSettings.leftTone
|
|
||||||
ambientNoiseReduction.floatValue = parsedSettings.leftAmbientNoiseReduction
|
|
||||||
ownVoiceAmplification.floatValue = parsedSettings.ownVoiceAmplification
|
|
||||||
leftAmplification.floatValue = parsedSettings.leftAmplification
|
|
||||||
rightAmplification.floatValue = parsedSettings.rightAmplification
|
|
||||||
initialReadSucceeded.value = true
|
|
||||||
} else {
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"Failed to read/parse initial hearing aid settings after ${initialReadAttempts.intValue} attempts"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (e: IOException) {
|
|
||||||
e.printStackTrace()
|
|
||||||
} finally {
|
|
||||||
initialLoadComplete.value = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val frequencies =
|
val frequencies =
|
||||||
@@ -277,21 +227,13 @@ fun UpdateHearingTestScreen() {
|
|||||||
text = stringResource(R.string.left),
|
text = stringResource(R.string.left),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
style = TextStyle(
|
style = MaterialTheme.typography.labelMediumEmphasized
|
||||||
fontSize = 18.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.right),
|
text = stringResource(R.string.right),
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
style = TextStyle(
|
style = MaterialTheme.typography.labelMediumEmphasized
|
||||||
fontSize = 18.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
|
||||||
color = textColor
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,11 +248,7 @@ fun UpdateHearingTestScreen() {
|
|||||||
.width(60.dp)
|
.width(60.dp)
|
||||||
.align(Alignment.CenterVertically),
|
.align(Alignment.CenterVertically),
|
||||||
textAlign = TextAlign.End,
|
textAlign = TextAlign.End,
|
||||||
style = TextStyle(
|
style = MaterialTheme.typography.labelSmall
|
||||||
color = textColor,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = leftEQ.value[index].toString(),
|
value = leftEQ.value[index].toString(),
|
||||||
@@ -354,5 +292,41 @@ fun UpdateHearingTestScreen() {
|
|||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(bottomPadding))
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(name = "Apple")
|
||||||
|
@Composable
|
||||||
|
fun UpdateHearingTestScreenPreviewApple() {
|
||||||
|
LibrePodsTheme(
|
||||||
|
m3eEnabled = false
|
||||||
|
) {
|
||||||
|
Box (
|
||||||
|
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
) {
|
||||||
|
UpdateHearingTestScreen(
|
||||||
|
state = demoState,
|
||||||
|
setATTCharacteristicValue = { _, _ -> }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Preview(name = "Material")
|
||||||
|
@Composable
|
||||||
|
fun UpdateHearingTestScreenPreviewMaterial() {
|
||||||
|
LibrePodsTheme(
|
||||||
|
m3eEnabled = true
|
||||||
|
) {
|
||||||
|
Box (
|
||||||
|
modifier = Modifier
|
||||||
|
.wrapContentHeight()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
) {
|
||||||
|
UpdateHearingTestScreen(
|
||||||
|
state = demoState,
|
||||||
|
setATTCharacteristicValue = { _, _ -> }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+29
-128
@@ -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(
|
|
||||||
title = stringResource(R.string.version)
|
|
||||||
) { spacerHeight ->
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.layerBackdrop(backdrop)
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
) {
|
) {
|
||||||
Spacer(modifier = Modifier.height(spacerHeight))
|
Spacer(modifier = Modifier.height(topPadding))
|
||||||
Box(
|
StyledList(title = stringResource(R.string.version)) {
|
||||||
modifier = Modifier
|
StyledListItem(
|
||||||
.background(if (isDarkTheme) Color(0xFF000000) else Color(0xFFF2F2F7))
|
name = stringResource(R.string.version) + " 1",
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
description = state.version1,
|
||||||
){
|
enabled = false
|
||||||
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)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.version) + " 1",
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor,
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = state.version1,
|
|
||||||
style = TextStyle(
|
|
||||||
fontSize = 16.sp,
|
|
||||||
color = textColor.copy(0.8f),
|
|
||||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
StyledListItem(
|
||||||
|
name = stringResource(R.string.version) + " 3",
|
||||||
|
description = state.version3,
|
||||||
|
enabled = false
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
HorizontalDivider(
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
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))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+57
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+229
@@ -0,0 +1,229 @@
|
|||||||
|
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
|
||||||
|
import me.kavishdevar.librepods.utils.removeDeviceCheckBypass
|
||||||
|
|
||||||
|
@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)
|
||||||
|
animationScope.launch {
|
||||||
|
state.animateScrollToItem(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
3 -> {
|
||||||
|
PermissionsPage(
|
||||||
|
onBackward = {
|
||||||
|
animationScope.launch {
|
||||||
|
if (state.canScrollBackward) state.animateScrollToItem(if (isSupported) 1 else 2)
|
||||||
|
if (!isSupported) {
|
||||||
|
removeDeviceCheckBypass(sharedPreferences)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onForward = onOnboardingComplete
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(bottomPadding))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun OnboardingScreenPreview(){
|
||||||
|
OnboardingScreen {}
|
||||||
|
}
|
||||||
+345
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+197
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -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(
|
||||||
|
LocalDesignSystem provides
|
||||||
|
if (m3eEnabled) DesignSystem.Material
|
||||||
|
else DesignSystem.Apple
|
||||||
|
) {
|
||||||
|
MaterialExpressiveTheme(
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
typography = Typography,
|
motionScheme = MotionScheme.expressive(),
|
||||||
|
typography = if (m3eEnabled) MaterialTypography else AppleTypography,
|
||||||
content = content
|
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),
|
||||||
|
displayMedium = displayMedium.copy(fontFamily = sfProFamily),
|
||||||
|
displaySmall = displaySmall.copy(fontFamily = sfProFamily),
|
||||||
|
|
||||||
|
headlineLarge = headlineLarge.copy(fontFamily = sfProFamily),
|
||||||
|
headlineMedium = headlineMedium.copy(fontFamily = sfProFamily),
|
||||||
|
headlineSmall = headlineSmall.copy(fontFamily = sfProFamily),
|
||||||
|
|
||||||
|
titleLarge = titleLarge.copy(fontFamily = sfProFamily),
|
||||||
|
titleMedium = titleMedium.copy(fontFamily = sfProFamily),
|
||||||
|
titleSmall = titleSmall.copy(fontFamily = sfProFamily),
|
||||||
|
|
||||||
|
bodyLarge = bodyLarge.copy(fontFamily = sfProFamily),
|
||||||
|
bodyMedium = bodyMedium.copy(
|
||||||
|
fontFamily = sfProFamily,
|
||||||
|
fontSize = 16.sp
|
||||||
|
),
|
||||||
|
bodySmall = bodySmall.copy(
|
||||||
|
fontFamily = sfProFamily,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 18.sp
|
||||||
|
),
|
||||||
|
|
||||||
|
labelLarge = labelLarge.copy(fontFamily = sfProFamily),
|
||||||
|
|
||||||
|
labelMedium = labelMedium.copy(
|
||||||
|
fontFamily = sfProFamily,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
),
|
||||||
|
labelMediumEmphasized = labelMediumEmphasized.copy(
|
||||||
|
fontFamily = sfProFamily,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
),
|
||||||
|
labelSmallEmphasized = labelSmallEmphasized.copy(
|
||||||
|
fontFamily = sfProFamily,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val provider = GoogleFont.Provider(
|
||||||
|
providerAuthority = "com.google.android.gms.fonts",
|
||||||
|
providerPackage = "com.google.android.gms",
|
||||||
|
certificates = R.array.com_google_android_gms_fonts_certs
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun robotoFlex(
|
||||||
|
wght: Float = 400f,
|
||||||
|
slnt: Float = 0f,
|
||||||
|
grad: Float = 0f,
|
||||||
|
wdth: Float = 100f,
|
||||||
|
xtra: Float = 468f,
|
||||||
|
xopq: Float = 96f,
|
||||||
|
yopq: Float = 79f,
|
||||||
|
) = FontFamily(
|
||||||
|
androidx.compose.ui.text.googlefonts.Font(
|
||||||
|
// Font(
|
||||||
|
// resId = R.font.roboto_flex,
|
||||||
|
googleFont = GoogleFont("Roboto Flex"),
|
||||||
|
fontProvider = provider,
|
||||||
|
variationSettings = FontVariation.Settings(
|
||||||
|
FontVariation.Setting("wght", wght),
|
||||||
|
FontVariation.Setting("wdth", wdth),
|
||||||
|
FontVariation.Setting("slnt", slnt),
|
||||||
|
FontVariation.Setting("grad", grad),
|
||||||
|
FontVariation.Setting("xtra", xtra),
|
||||||
|
FontVariation.Setting("xopq", xopq),
|
||||||
|
FontVariation.Setting("yopq", yopq),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val display = robotoFlex(
|
||||||
|
wght = 800f,
|
||||||
|
grad = 100f,
|
||||||
|
wdth = 100f
|
||||||
|
)
|
||||||
|
|
||||||
|
val displayEmphasized = robotoFlex(
|
||||||
|
wght = 1000f,
|
||||||
|
slnt = -2f,
|
||||||
|
grad = 150f,
|
||||||
|
wdth = 150f,
|
||||||
|
)
|
||||||
|
|
||||||
|
val body = robotoFlex()
|
||||||
|
|
||||||
|
val bodyEmphasized = robotoFlex(
|
||||||
|
wght = 600f,
|
||||||
|
wdth = 130f,
|
||||||
|
grad = 75f,
|
||||||
|
)
|
||||||
|
|
||||||
|
val label = robotoFlex(
|
||||||
|
wght = 450f,
|
||||||
|
grad = 50f
|
||||||
|
)
|
||||||
|
|
||||||
|
val labelEmphasized = robotoFlex(
|
||||||
|
wght = 600f,
|
||||||
|
wdth = 140f,
|
||||||
|
grad = 75f
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
val MaterialTypography = Typography().run {
|
||||||
|
copy(
|
||||||
|
titleSmall = titleSmall.copy(
|
||||||
|
fontFamily = display,
|
||||||
|
fontSize = 24.sp,
|
||||||
|
lineHeight = 30.sp,
|
||||||
|
),
|
||||||
|
|
||||||
|
titleMedium = titleMedium.copy(
|
||||||
|
fontFamily = display,
|
||||||
|
fontSize = 28.sp,
|
||||||
|
lineHeight = 32.sp,
|
||||||
|
),
|
||||||
|
|
||||||
|
titleLarge = titleLarge.copy(
|
||||||
|
fontFamily = display,
|
||||||
|
fontSize = 32.sp,
|
||||||
|
lineHeight = 36.sp,
|
||||||
|
),
|
||||||
|
|
||||||
|
titleSmallEmphasized = titleSmallEmphasized.copy(
|
||||||
|
fontFamily = display,
|
||||||
|
fontSize = 24.sp,
|
||||||
|
lineHeight = 30.sp,
|
||||||
|
),
|
||||||
|
|
||||||
|
titleMediumEmphasized = titleMediumEmphasized.copy(
|
||||||
|
fontFamily = displayEmphasized,
|
||||||
|
fontSize = 28.sp,
|
||||||
|
lineHeight = 32.sp,
|
||||||
|
),
|
||||||
|
|
||||||
|
titleLargeEmphasized = titleLargeEmphasized.copy(
|
||||||
|
fontFamily = displayEmphasized,
|
||||||
|
fontSize = 32.sp,
|
||||||
|
lineHeight = 36.sp,
|
||||||
|
),
|
||||||
|
|
||||||
|
displaySmall = displaySmall.copy(
|
||||||
|
fontFamily = display,
|
||||||
|
fontSize = 32.sp,
|
||||||
|
lineHeight = 36.sp,
|
||||||
|
),
|
||||||
|
|
||||||
|
displayMedium = displayMedium.copy(
|
||||||
|
fontFamily = display,
|
||||||
|
fontSize = 36.sp,
|
||||||
|
lineHeight = 40.sp,
|
||||||
|
),
|
||||||
|
|
||||||
|
displayLarge = displayLarge.copy(
|
||||||
|
fontFamily = display,
|
||||||
|
fontSize = 40.sp,
|
||||||
|
lineHeight = 44.sp,
|
||||||
|
),
|
||||||
|
|
||||||
|
displaySmallEmphasized = displaySmallEmphasized.copy(
|
||||||
|
fontFamily = displayEmphasized,
|
||||||
|
fontSize = 38.sp,
|
||||||
|
lineHeight = 42.sp,
|
||||||
|
),
|
||||||
|
|
||||||
|
displayMediumEmphasized = displayMediumEmphasized.copy(
|
||||||
|
fontFamily = displayEmphasized,
|
||||||
|
fontSize = 42.sp,
|
||||||
|
lineHeight = 48.sp,
|
||||||
|
),
|
||||||
|
|
||||||
|
displayLargeEmphasized = displayLargeEmphasized.copy(
|
||||||
|
fontFamily = displayEmphasized,
|
||||||
|
fontSize = 48.sp,
|
||||||
|
lineHeight = 52.sp,
|
||||||
|
),
|
||||||
|
|
||||||
|
bodySmall = bodySmall.copy(
|
||||||
|
fontFamily = body,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
),
|
||||||
|
|
||||||
|
bodyMedium = bodyMedium.copy(
|
||||||
|
fontFamily = body,
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
lineHeight = 24.sp,
|
lineHeight = 24.sp,
|
||||||
letterSpacing = 0.5.sp
|
|
||||||
)
|
|
||||||
/* Other default text styles to override
|
|
||||||
titleLarge = TextStyle(
|
|
||||||
fontFamily = FontFamily.Default,
|
|
||||||
fontWeight = FontWeight.Normal,
|
|
||||||
fontSize = 22.sp,
|
|
||||||
lineHeight = 28.sp,
|
|
||||||
letterSpacing = 0.sp
|
|
||||||
),
|
),
|
||||||
labelSmall = TextStyle(
|
|
||||||
fontFamily = FontFamily.Default,
|
bodyLarge = bodyLarge.copy(
|
||||||
fontWeight = FontWeight.Medium,
|
fontFamily = body,
|
||||||
fontSize = 11.sp,
|
fontSize = 18.sp,
|
||||||
lineHeight = 16.sp,
|
lineHeight = 28.sp,
|
||||||
letterSpacing = 0.5.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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+298
-118
@@ -25,20 +25,24 @@ import android.content.IntentFilter
|
|||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
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.delay
|
|
||||||
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
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.BuildConfig
|
||||||
import me.kavishdevar.librepods.billing.BillingManager
|
import me.kavishdevar.librepods.billing.BillingManager
|
||||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
|
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
|
||||||
|
import me.kavishdevar.librepods.bluetooth.ATTCCCDHandles
|
||||||
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||||
|
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
|
||||||
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
|
||||||
@@ -47,13 +51,14 @@ import me.kavishdevar.librepods.data.BatteryComponent
|
|||||||
import me.kavishdevar.librepods.data.BatteryStatus
|
import me.kavishdevar.librepods.data.BatteryStatus
|
||||||
import me.kavishdevar.librepods.data.Capability
|
import me.kavishdevar.librepods.data.Capability
|
||||||
import me.kavishdevar.librepods.data.ControlCommandRepository
|
import me.kavishdevar.librepods.data.ControlCommandRepository
|
||||||
|
import me.kavishdevar.librepods.data.CustomEq
|
||||||
import me.kavishdevar.librepods.data.StemAction
|
import me.kavishdevar.librepods.data.StemAction
|
||||||
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
|
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
|
||||||
import me.kavishdevar.librepods.services.AirPodsService
|
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,
|
||||||
|
|
||||||
@@ -93,27 +98,131 @@ data class AirPodsUiState(
|
|||||||
|
|
||||||
val dynamicEndOfCharge: Boolean = false,
|
val dynamicEndOfCharge: Boolean = false,
|
||||||
|
|
||||||
val connectionSuccessful: Boolean = false
|
val connectionSuccessful: Boolean = false,
|
||||||
|
val timeUntilFOSSPremiumExpiry: Long = 0L,
|
||||||
|
|
||||||
|
val customEq: CustomEq = CustomEq(1, 50, 50, 50) // disabled
|
||||||
|
)
|
||||||
|
|
||||||
|
val demoInstance = AirPodsInstance(
|
||||||
|
name = "[DEMO] AirPods Pro",
|
||||||
|
model = AirPodsModels.getModelByModelNumber("A3064")!!,
|
||||||
|
actualModelNumber = "A3064",
|
||||||
|
serialNumber = "JXF9Q94A40",
|
||||||
|
leftSerialNumber = "L-DEMO",
|
||||||
|
rightSerialNumber = "R-DEMO",
|
||||||
|
version1 = "90.3388000000000000.1786",
|
||||||
|
version2 = "90.3388000000000000.1786",
|
||||||
|
version3 = "9441861",
|
||||||
|
)
|
||||||
|
|
||||||
|
val demoState = AirPodsUiState(
|
||||||
|
deviceName = demoInstance.name,
|
||||||
|
|
||||||
|
isLocallyConnected = true,
|
||||||
|
|
||||||
|
capabilities = demoInstance.model.capabilities,
|
||||||
|
|
||||||
|
battery = listOf(
|
||||||
|
Battery(BatteryComponent.LEFT, 80, BatteryStatus.OPTIMIZED_CHARGING),
|
||||||
|
Battery(BatteryComponent.RIGHT, 18, BatteryStatus.CHARGING),
|
||||||
|
Battery(BatteryComponent.CASE, 76, BatteryStatus.NOT_CHARGING)
|
||||||
|
),
|
||||||
|
|
||||||
|
ancMode = 3,
|
||||||
|
offListeningMode = false,
|
||||||
|
|
||||||
|
modelName = demoInstance.model.displayName,
|
||||||
|
actualModel = demoInstance.actualModelNumber,
|
||||||
|
serialNumbers = listOf(
|
||||||
|
demoInstance.serialNumber?: "",
|
||||||
|
demoInstance.leftSerialNumber?: "",
|
||||||
|
demoInstance.rightSerialNumber?: ""
|
||||||
|
),
|
||||||
|
|
||||||
|
version1 = demoInstance.version1?: "",
|
||||||
|
version2 = demoInstance.version2?: "",
|
||||||
|
version3 = demoInstance.version3?: "",
|
||||||
|
|
||||||
|
headTrackingActive = true,
|
||||||
|
headGesturesEnabled = true,
|
||||||
|
|
||||||
|
automaticEarDetectionEnabled = true,
|
||||||
|
automaticConnectionEnabled = true,
|
||||||
|
|
||||||
|
leftAction = StemAction.CYCLE_NOISE_CONTROL_MODES,
|
||||||
|
rightAction = StemAction.DIGITAL_ASSISTANT,
|
||||||
|
|
||||||
|
loudSoundReductionEnabled = true,
|
||||||
|
|
||||||
|
isPremium = true,
|
||||||
|
vendorIdHook = true,
|
||||||
|
|
||||||
|
dynamicEndOfCharge = true,
|
||||||
|
|
||||||
|
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(
|
class AirPodsViewModel(
|
||||||
private val service: AirPodsService,
|
|
||||||
private val sharedPreferences: SharedPreferences,
|
|
||||||
private val controlRepo: ControlCommandRepository,
|
|
||||||
private val appContext: Context
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val _uiState = MutableStateFlow(
|
private lateinit var sharedPreferences: SharedPreferences
|
||||||
AirPodsUiState(
|
private lateinit var appContext: Context
|
||||||
deviceName = sharedPreferences.getString(
|
private lateinit var service: AirPodsService
|
||||||
"name",
|
private lateinit var controlRepo: ControlCommandRepository
|
||||||
"AirPods Pro"
|
|
||||||
) ?: "AirPods Pro"
|
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>()
|
||||||
@@ -122,40 +231,48 @@ 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
|
||||||
|
//
|
||||||
|
// fun setCameraAction(action: AACPManager.Companion.StemPressType?) {
|
||||||
|
// sharedPreferences.edit {
|
||||||
|
// if (action == null) remove("camera_action")
|
||||||
|
// else putString("camera_action", action.name)
|
||||||
|
// }
|
||||||
|
// _cameraAction.value = action
|
||||||
|
// }
|
||||||
|
|
||||||
val cameraAction: StateFlow<AACPManager.Companion.StemPressType?> = _cameraAction
|
fun setCustomEq(low: Int, mid: Int, high: Int) {
|
||||||
|
require(low in 0..100)
|
||||||
fun setCameraAction(action: AACPManager.Companion.StemPressType?) {
|
require(mid in 0..100)
|
||||||
sharedPreferences.edit {
|
require(high in 0..100)
|
||||||
if (action == null) remove("camera_action")
|
val updatedEq = _uiState.value.customEq.copy(low = low, mid = mid, high = high)
|
||||||
else putString("camera_action", action.name)
|
service.aacpManager.sendCustomEqPacket(updatedEq)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
customEq = updatedEq
|
||||||
|
)
|
||||||
}
|
}
|
||||||
_cameraAction.value = action
|
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
fun setCustomEqEnabled(enabled: Boolean) {
|
||||||
observeBroadcasts()
|
service.aacpManager.sendCustomEqPacket(_uiState.value.customEq.copy(state = if (enabled) 2 else 1))
|
||||||
loadName()
|
_uiState.update {
|
||||||
loadInstance()
|
it.copy(
|
||||||
loadSharedPreferences()
|
customEq = it.customEq.copy(state = if (enabled) 2 else 1)
|
||||||
setupControlObservers()
|
)
|
||||||
observeBilling()
|
}
|
||||||
loadControlList()
|
|
||||||
observeATT()
|
|
||||||
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
|
||||||
appContext.unregisterReceiver(broadcastReceiver)
|
appContext.unregisterReceiver(broadcastReceiver)
|
||||||
|
|
||||||
super.onCleared()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadName() {
|
private fun loadName() {
|
||||||
@@ -166,23 +283,38 @@ 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) {
|
if (premium) {
|
||||||
// billingFirstCollectDone = true
|
sharedPreferences.edit {
|
||||||
// return@collect
|
remove("premium_expiry_time")
|
||||||
// }
|
if (BuildConfig.PLAY_BUILD) remove("foss_upgraded")
|
||||||
if (!premium) {
|
}
|
||||||
|
_uiState.update { it.copy(isPremium = true, timeUntilFOSSPremiumExpiry = 0L) }
|
||||||
|
} else {
|
||||||
|
if (_uiState.value.timeUntilFOSSPremiumExpiry <= 0L) {
|
||||||
setControlCommandBoolean(
|
setControlCommandBoolean(
|
||||||
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
|
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
setHeadGesturesEnabled(false)
|
setHeadGesturesEnabled(false)
|
||||||
}
|
_uiState.update { it.copy(isPremium = false) }
|
||||||
_uiState.update { it.copy(isPremium = premium) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeSharedPreferences() {
|
||||||
|
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||||
|
when (key) {
|
||||||
|
"name" -> loadName()
|
||||||
|
"off_listening_mode", "automatic_ear_detection", "automatic_connection_ctrl_cmd",
|
||||||
|
"head_gestures", "left_long_press_action", "right_long_press_action",
|
||||||
|
"dynamic_end_of_charge", "foss_upgraded", "premium_expiry_time" -> loadSharedPreferences()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
|
||||||
|
}
|
||||||
|
|
||||||
private fun observeBroadcasts() {
|
private fun observeBroadcasts() {
|
||||||
broadcastReceiver = object : BroadcastReceiver() {
|
broadcastReceiver = object : BroadcastReceiver() {
|
||||||
@@ -289,7 +421,7 @@ class AirPodsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// I'm lazy, sorry.
|
// I'm lazy, sorry.
|
||||||
fun setupControlObservers() {
|
fun observeAACP() {
|
||||||
val identifiersList = listOf(
|
val identifiersList = listOf(
|
||||||
ControlCommandIdentifiers.MIC_MODE,
|
ControlCommandIdentifiers.MIC_MODE,
|
||||||
ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
|
ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
|
||||||
@@ -321,14 +453,20 @@ class AirPodsViewModel(
|
|||||||
for (identifier in identifiersList) {
|
for (identifier in identifiersList) {
|
||||||
observeControl(identifier)
|
observeControl(identifier)
|
||||||
}
|
}
|
||||||
|
service.aacpManager.customEqCallback = { customEq ->
|
||||||
|
_uiState.update { it.copy(customEq = customEq) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = service.isConnected(), battery = service.getBattery()
|
isLocallyConnected = BluetoothConnectionManager.aacpSocket?.isConnected == true,
|
||||||
|
battery = service.getBattery(),
|
||||||
|
ancMode = controlRepo.getValue(ControlCommandIdentifiers.LISTENING_MODE)?.get(0)?.toInt() ?: 1,
|
||||||
|
controlStates = controlRepo.getMap()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -368,9 +506,58 @@ class AirPodsViewModel(
|
|||||||
rightAction = rightAction,
|
rightAction = rightAction,
|
||||||
vendorIdHook = vendorIdHook,
|
vendorIdHook = vendorIdHook,
|
||||||
dynamicEndOfCharge = dynamicEndOfCharge,
|
dynamicEndOfCharge = dynamicEndOfCharge,
|
||||||
connectionSuccessful = connectionSuccessful
|
connectionSuccessful = connectionSuccessful,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// faulty update on Play caused PLAY_BUILD to be false and resulted in use of FOSS billing in Play. since FOSS is not verified, we need to give 2 weeks to verify the purchase
|
||||||
|
if (BuildConfig.PLAY_BUILD) {
|
||||||
|
val fossUpgraded = sharedPreferences.getBoolean("foss_upgraded", false)
|
||||||
|
val expiryTime = sharedPreferences.getLong("premium_expiry_time", 0L)
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
|
||||||
|
when {
|
||||||
|
// existing temporary premium
|
||||||
|
expiryTime > 0L -> {
|
||||||
|
if (expiryTime <= now) {
|
||||||
|
sharedPreferences.edit {
|
||||||
|
remove("premium_expiry_time")
|
||||||
|
remove("foss_upgraded")
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
timeUntilFOSSPremiumExpiry = 0L,
|
||||||
|
isPremium = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
timeUntilFOSSPremiumExpiry = expiryTime - now,
|
||||||
|
isPremium = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// First migration from accidental FOSS Play build
|
||||||
|
fossUpgraded && !_uiState.value.isPremium -> {
|
||||||
|
val newExpiry = now + 28L * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
sharedPreferences.edit {
|
||||||
|
putLong("premium_expiry_time", newExpiry)
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
timeUntilFOSSPremiumExpiry = newExpiry - now,
|
||||||
|
isPremium = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setOffListeningMode(enabled: Boolean) {
|
fun setOffListeningMode(enabled: Boolean) {
|
||||||
@@ -396,10 +583,10 @@ class AirPodsViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadControlList() {
|
private fun loadEq() {
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
controlStates = controlRepo.getMap()
|
customEq = service.aacpManager.customEq
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -454,51 +641,68 @@ class AirPodsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun setATTCharacteristicValue(handle: ATTHandles, value: ByteArray) {
|
fun setATTCharacteristicValue(handle: ATTHandles, value: ByteArray) {
|
||||||
if (handle == ATTHandles.LOUD_SOUND_REDUCTION) {
|
when (handle) {
|
||||||
_uiState.update { it.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01) }
|
// ideally should be using a different viewmodel for ATT based things because there are a lot of values, and I am not going to add all to this state, but there's loudsoundreduction.
|
||||||
|
ATTHandles.LOUD_SOUND_REDUCTION -> {
|
||||||
|
_uiState.value = _uiState.value.copy(loudSoundReductionEnabled = value[0].toInt() == 0x01)
|
||||||
|
}
|
||||||
|
ATTHandles.HEARING_AID -> {
|
||||||
|
_uiState.value = _uiState.value.copy(hearingAidData = value)
|
||||||
|
}
|
||||||
|
ATTHandles.TRANSPARENCY -> {
|
||||||
|
_uiState.value = _uiState.value.copy(transparencyData = value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
service.attManager?.connect()
|
service.attManager.writeCharacteristic(handle, value)
|
||||||
while (service.attManager?.socket?.isConnected != true) {
|
|
||||||
delay(250)
|
|
||||||
}
|
|
||||||
service.attManager?.write(handle, value)
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshATT() {
|
fun loadATT() {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
val loudSoundReduction = service.attManager.getCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) ?: byteArrayOf()
|
||||||
val loudSoundReduction =
|
val loudSoundReductionEnabled = if (loudSoundReduction.isNotEmpty()) {
|
||||||
runCatching { service.attManager?.read(ATTHandles.LOUD_SOUND_REDUCTION) }.getOrNull()
|
loudSoundReduction[0].toInt() == 1
|
||||||
val transparencyData =
|
} else false
|
||||||
runCatching { service.attManager?.read(ATTHandles.TRANSPARENCY) }.getOrNull()?: byteArrayOf()
|
val hearingAidData = service.attManager.getCharacteristic(ATTHandles.HEARING_AID) ?: byteArrayOf()
|
||||||
val hearingAid =
|
val transparencyData = service.attManager.getCharacteristic(ATTHandles.TRANSPARENCY) ?: byteArrayOf()
|
||||||
runCatching { service.attManager?.read(ATTHandles.HEARING_AID) }.getOrNull()?: byteArrayOf()
|
_uiState.update {
|
||||||
_uiState.value = _uiState.value.copy(
|
it.copy(
|
||||||
loudSoundReductionEnabled = loudSoundReduction?.get(0)?.toInt() == 0x01,
|
loudSoundReductionEnabled = loudSoundReductionEnabled,
|
||||||
transparencyData = transparencyData,
|
transparencyData = transparencyData,
|
||||||
hearingAidData = hearingAid
|
hearingAidData = hearingAidData
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun observeATT() {
|
fun observeATT() {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
service.attManager?.connect()
|
service.attManager.enableNotification(ATTCCCDHandles.HEARING_AID)
|
||||||
while (service.attManager?.socket?.isConnected != true) {
|
service.attManager.enableNotification(ATTCCCDHandles.TRANSPARENCY)
|
||||||
delay(1000)
|
}
|
||||||
|
service.attManager.setOnNotificationReceived { handle, value ->
|
||||||
|
when (handle) {
|
||||||
|
ATTHandles.LOUD_SOUND_REDUCTION.value.toByte() -> {
|
||||||
|
val loudSoundReductionEnabled = if (value.isNotEmpty()) {
|
||||||
|
value[0].toInt() == 1
|
||||||
|
} else false
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(loudSoundReductionEnabled = loudSoundReductionEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ATTHandles.HEARING_AID.value.toByte() -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(hearingAidData = value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ATTHandles.TRANSPARENCY.value.toByte() -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(transparencyData = value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
service.attManager?.enableNotifications(ATTHandles.LOUD_SOUND_REDUCTION)
|
|
||||||
service.attManager?.enableNotifications(ATTHandles.TRANSPARENCY)
|
|
||||||
service.attManager?.enableNotifications(ATTHandles.HEARING_AID)
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
refreshATT()
|
|
||||||
delay(15000)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -525,40 +729,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) {
|
||||||
@@ -595,10 +766,19 @@ class AirPodsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun disconnect() {
|
fun disconnect() {
|
||||||
|
if (isDemoMode) {
|
||||||
|
isDemoMode = false
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(isLocallyConnected = false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
service.disconnectAirPods()
|
service.disconnectAirPods()
|
||||||
if (appContext.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") != PackageManager.PERMISSION_GRANTED) {
|
if (appContext.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") != PackageManager.PERMISSION_GRANTED) {
|
||||||
Toast.makeText(appContext, "App has disconnected, disconnect from Android Settings.",
|
Toast.makeText(
|
||||||
Toast.LENGTH_LONG).show()
|
appContext, "App has disconnected, disconnect from Android Settings.",
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-4
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import me.kavishdevar.librepods.BuildConfig
|
||||||
import me.kavishdevar.librepods.billing.BillingManager
|
import me.kavishdevar.librepods.billing.BillingManager
|
||||||
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
|
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
@@ -34,7 +35,9 @@ data class AppSettingsUiState(
|
|||||||
val isPremium: Boolean = false,
|
val isPremium: Boolean = false,
|
||||||
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 m3eEnabled: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
class AppSettingsViewModel(application: Application) : AndroidViewModel(application) {
|
class AppSettingsViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
@@ -60,18 +63,76 @@ 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() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
BillingManager.provider.isPremium.collect { premium ->
|
BillingManager.provider.isPremium.collect { premium ->
|
||||||
_uiState.update { it.copy(isPremium = premium) }
|
if (premium) {
|
||||||
|
sharedPreferences.edit {
|
||||||
|
remove("premium_expiry_time")
|
||||||
|
if (BuildConfig.PLAY_BUILD) remove("foss_upgraded")
|
||||||
|
}
|
||||||
|
_uiState.update { it.copy(isPremium = true, timeUntilFOSSPremiumExpiry = 0L) }
|
||||||
|
} else {
|
||||||
|
// No billing premium, only update if no temporary premium is active
|
||||||
|
if (_uiState.value.timeUntilFOSSPremiumExpiry <= 0L) {
|
||||||
|
_uiState.update { it.copy(isPremium = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadSettings() {
|
private fun loadSettings() {
|
||||||
|
// faulty update on Play caused PLAY_BUILD to be false and resulted in use of FOSS billing in Play. since FOSS is not verified, we need to give 2 weeks to verify the purchase
|
||||||
|
|
||||||
|
val fossUpgraded = sharedPreferences.getBoolean("foss_upgraded", false)
|
||||||
|
val expiryTime = sharedPreferences.getLong("premium_expiry_time", 0L)
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
|
||||||
|
when {
|
||||||
|
// existing temporary premium
|
||||||
|
expiryTime > 0L -> {
|
||||||
|
if (expiryTime <= now) {
|
||||||
|
sharedPreferences.edit {
|
||||||
|
remove("premium_expiry_time")
|
||||||
|
remove("foss_upgraded")
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
timeUntilFOSSPremiumExpiry = 0L,
|
||||||
|
isPremium = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
timeUntilFOSSPremiumExpiry = expiryTime - now,
|
||||||
|
isPremium = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// First migration from accidental FOSS Play build
|
||||||
|
fossUpgraded && !_uiState.value.isPremium && BuildConfig.PLAY_BUILD -> {
|
||||||
|
val newExpiry = now + 28L * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
sharedPreferences.edit {
|
||||||
|
putLong("premium_expiry_time", newExpiry)
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
timeUntilFOSSPremiumExpiry = newExpiry - now,
|
||||||
|
isPremium = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_uiState.update { currentState ->
|
_uiState.update { currentState ->
|
||||||
currentState.copy(
|
currentState.copy(
|
||||||
showPhoneBatteryInWidget = sharedPreferences.getBoolean("show_phone_battery_in_widget", false),
|
showPhoneBatteryInWidget = sharedPreferences.getBoolean("show_phone_battery_in_widget", false),
|
||||||
@@ -90,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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,9 +35,10 @@ import android.util.Log
|
|||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import me.kavishdevar.librepods.QuickSettingsDialogActivity
|
import me.kavishdevar.librepods.QuickSettingsDialogActivity
|
||||||
import me.kavishdevar.librepods.R
|
import me.kavishdevar.librepods.R
|
||||||
|
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||||
|
import me.kavishdevar.librepods.bluetooth.BluetoothConnectionManager
|
||||||
import me.kavishdevar.librepods.data.AirPodsNotifications
|
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||||
import me.kavishdevar.librepods.data.NoiseControlMode
|
import me.kavishdevar.librepods.data.NoiseControlMode
|
||||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
@RequiresApi(Build.VERSION_CODES.Q)
|
||||||
@@ -98,7 +99,7 @@ class AirPodsQSService : TileService() {
|
|||||||
Log.d("AirPodsQSService", "onStartListening")
|
Log.d("AirPodsQSService", "onStartListening")
|
||||||
|
|
||||||
val service = ServiceManager.getService()
|
val service = ServiceManager.getService()
|
||||||
isAirPodsConnected = service?.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,15 +85,19 @@ 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.ATTManager
|
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||||
|
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
|
||||||
import me.kavishdevar.librepods.data.Battery
|
import me.kavishdevar.librepods.data.Battery
|
||||||
import me.kavishdevar.librepods.data.BatteryComponent
|
import me.kavishdevar.librepods.data.BatteryComponent
|
||||||
import me.kavishdevar.librepods.data.BatteryStatus
|
import me.kavishdevar.librepods.data.BatteryStatus
|
||||||
|
import me.kavishdevar.librepods.data.Capability
|
||||||
|
import me.kavishdevar.librepods.data.CustomEq
|
||||||
import me.kavishdevar.librepods.data.StemAction
|
import me.kavishdevar.librepods.data.StemAction
|
||||||
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
|
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
|
||||||
import me.kavishdevar.librepods.data.isHeadTrackingData
|
import me.kavishdevar.librepods.data.isHeadTrackingData
|
||||||
@@ -126,9 +130,9 @@ import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_
|
|||||||
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD
|
import me.kavishdevar.librepods.utils.SystemApisUtils.METADATA_UNTETHERED_RIGHT_LOW_BATTERY_THRESHOLD
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
import java.time.LocalDateTime
|
|
||||||
import kotlin.io.encoding.Base64
|
import kotlin.io.encoding.Base64
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
private const val TAG = "AirPodsService"
|
private const val TAG = "AirPodsService"
|
||||||
|
|
||||||
@@ -151,7 +155,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
var macAddress = ""
|
var macAddress = ""
|
||||||
var localMac = ""
|
var localMac = ""
|
||||||
lateinit var aacpManager: AACPManager
|
lateinit var aacpManager: AACPManager
|
||||||
var attManager: ATTManager? = null
|
lateinit var attManager: ATTManagerv2
|
||||||
var airpodsInstance: AirPodsInstance? = null
|
var airpodsInstance: AirPodsInstance? = null
|
||||||
var cameraActive = false
|
var cameraActive = false
|
||||||
private var disconnectedBecauseReversed = false
|
private var disconnectedBecauseReversed = false
|
||||||
@@ -231,8 +235,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
|
|
||||||
lateinit var bleManager: BLEManager
|
lateinit var bleManager: BLEManager
|
||||||
|
|
||||||
private lateinit var socket: BluetoothSocket
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
init {
|
init {
|
||||||
System.loadLibrary("bluetooth_socket")
|
System.loadLibrary("bluetooth_socket")
|
||||||
@@ -244,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" && !isConnected()) { // 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
|
||||||
@@ -256,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 (socket.isConnected) 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
|
||||||
@@ -289,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 (socket.isConnected) 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
|
||||||
@@ -323,7 +325,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onBatteryChanged(device: BLEManager.AirPodsStatus) {
|
override fun onBatteryChanged(device: BLEManager.AirPodsStatus) {
|
||||||
if (socket.isConnected) 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
|
||||||
@@ -380,6 +382,8 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
aacpManager = AACPManager()
|
aacpManager = AACPManager()
|
||||||
initializeAACPManagerCallback()
|
initializeAACPManagerCallback()
|
||||||
|
|
||||||
|
attManager = ATTManagerv2()
|
||||||
|
|
||||||
sharedPreferences.registerOnSharedPreferenceChangeListener(this)
|
sharedPreferences.registerOnSharedPreferenceChangeListener(this)
|
||||||
|
|
||||||
localMac = config.selfMacAddress
|
localMac = config.selfMacAddress
|
||||||
@@ -654,6 +658,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
addAction("android.bluetooth.headset.action.VENDOR_SPECIFIC_HEADSET_EVENT")
|
addAction("android.bluetooth.headset.action.VENDOR_SPECIFIC_HEADSET_EVENT")
|
||||||
addAction("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED")
|
addAction("android.bluetooth.a2dp.profile.action.CONNECTION_STATE_CHANGED")
|
||||||
addAction("android.bluetooth.a2dp.profile.action.PLAYING_STATE_CHANGED")
|
addAction("android.bluetooth.a2dp.profile.action.PLAYING_STATE_CHANGED")
|
||||||
|
addAction("android.bluetooth.device.action.UUID")
|
||||||
}
|
}
|
||||||
|
|
||||||
connectionReceiver = object : BroadcastReceiver() {
|
connectionReceiver = object : BroadcastReceiver() {
|
||||||
@@ -691,8 +696,9 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
// isConnectedLocally = false
|
// isConnectedLocally = false
|
||||||
popupShown = false
|
popupShown = false
|
||||||
updateNotificationContent(false)
|
updateNotificationContent(false)
|
||||||
attManager?.disconnect()
|
aacpManager.disconnected()
|
||||||
attManager = null
|
BluetoothConnectionManager.aacpSocket = null
|
||||||
|
BluetoothConnectionManager.attSocket = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1019,7 +1025,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
)
|
)
|
||||||
// Store in SharedPreferences
|
// Store in SharedPreferences
|
||||||
sharedPreferences.edit {
|
sharedPreferences.edit {
|
||||||
putString("airpods_name", deviceInformation.name)
|
putString("name", deviceInformation.name)
|
||||||
putString("airpods_model_number", deviceInformation.modelNumber)
|
putString("airpods_model_number", deviceInformation.modelNumber)
|
||||||
putString("airpods_manufacturer", deviceInformation.manufacturer)
|
putString("airpods_manufacturer", deviceInformation.manufacturer)
|
||||||
putString("airpods_serial_number", deviceInformation.serialNumber)
|
putString("airpods_serial_number", deviceInformation.serialNumber)
|
||||||
@@ -1094,9 +1100,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
"Stem press received: $stemPressType on $bud, cameraActive: $cameraActive, cameraAction: ${config.cameraAction}"
|
"Stem press received: $stemPressType on $bud, cameraActive: $cameraActive, cameraAction: ${config.cameraAction}"
|
||||||
)
|
)
|
||||||
if (cameraActive && config.cameraAction != null && stemPressType == config.cameraAction) {
|
if (cameraActive && config.cameraAction != null && stemPressType == config.cameraAction) {
|
||||||
if (BuildConfig.FLAVOR == "xposed") {
|
|
||||||
Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 27"))
|
Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 27"))
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
val action = getActionFor(bud, stemPressType)
|
val action = getActionFor(bud, stemPressType)
|
||||||
Log.d("AirPodsParser", "$bud $stemPressType action: $action")
|
Log.d("AirPodsParser", "$bud $stemPressType action: $action")
|
||||||
@@ -1157,13 +1161,21 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onEQPacketReceived(eqData: FloatArray) {
|
override fun onHeadphoneAccommodationReceived(eqData: FloatArray) {
|
||||||
sendBroadcast(
|
sendBroadcast(
|
||||||
Intent(AirPodsNotifications.EQ_DATA).putExtra("eqData", eqData).apply {
|
Intent(AirPodsNotifications.EQ_DATA).putExtra("eqData", eqData).apply {
|
||||||
setPackage(packageName)
|
setPackage(packageName)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onCustomEqReceived(customEq: CustomEq) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCapabilitiesReceived(capabilities: List<Capability>) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
override fun onUnknownPacketReceived(packet: ByteArray) {
|
override fun onUnknownPacketReceived(packet: ByteArray) {
|
||||||
Log.d(
|
Log.d(
|
||||||
"AACPManager",
|
"AACPManager",
|
||||||
@@ -1735,7 +1747,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
|
|
||||||
val socketFailureChannel = NotificationChannel(
|
val socketFailureChannel = NotificationChannel(
|
||||||
"socket_connection_failure",
|
"socket_connection_failure",
|
||||||
"AirPods Socket 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"
|
||||||
@@ -1781,7 +1793,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
if (BuildConfig.FLAVOR != "xposed") {
|
if (BuildConfig.FLAVOR != "xposed") {
|
||||||
Log.w(
|
Log.w(
|
||||||
TAG,
|
TAG,
|
||||||
"Not showing socket 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
|
||||||
}
|
}
|
||||||
@@ -1910,7 +1922,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
)
|
)
|
||||||
it.setViewVisibility(
|
it.setViewVisibility(
|
||||||
R.id.left_charging_icon,
|
R.id.left_charging_icon,
|
||||||
if (leftBattery?.status == BatteryStatus.CHARGING) View.VISIBLE else View.GONE
|
if (leftBattery?.status == BatteryStatus.CHARGING || leftBattery?.status == BatteryStatus.OPTIMIZED_CHARGING) View.VISIBLE else View.GONE
|
||||||
)
|
)
|
||||||
|
|
||||||
it.setTextViewText(R.id.right_battery_widget, rightBattery?.let {
|
it.setTextViewText(R.id.right_battery_widget, rightBattery?.let {
|
||||||
@@ -1921,7 +1933,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
)
|
)
|
||||||
it.setViewVisibility(
|
it.setViewVisibility(
|
||||||
R.id.right_charging_icon,
|
R.id.right_charging_icon,
|
||||||
if (rightBattery?.status == BatteryStatus.CHARGING) View.VISIBLE else View.GONE
|
if (rightBattery?.status == BatteryStatus.CHARGING || rightBattery?.status == BatteryStatus.OPTIMIZED_CHARGING ) View.VISIBLE else View.GONE
|
||||||
)
|
)
|
||||||
|
|
||||||
it.setTextViewText(R.id.case_battery_widget, caseBattery?.let {
|
it.setTextViewText(R.id.case_battery_widget, caseBattery?.let {
|
||||||
@@ -1932,7 +1944,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
)
|
)
|
||||||
it.setViewVisibility(
|
it.setViewVisibility(
|
||||||
R.id.case_charging_icon,
|
R.id.case_charging_icon,
|
||||||
if (caseBattery?.status == BatteryStatus.CHARGING) View.VISIBLE else View.GONE
|
if (caseBattery?.status == BatteryStatus.CHARGING || caseBattery?.status == BatteryStatus.OPTIMIZED_CHARGING ) View.VISIBLE else View.GONE
|
||||||
)
|
)
|
||||||
|
|
||||||
it.setViewVisibility(
|
it.setViewVisibility(
|
||||||
@@ -2036,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 (!::socket.isInitialized) {
|
if (BluetoothConnectionManager.aacpSocket == null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (connected && (config.bleOnlyMode || socket.isConnected)) {
|
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)
|
||||||
@@ -2087,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 && !socket.isConnected) {
|
} else if (!config.bleOnlyMode && BluetoothConnectionManager.aacpSocket?.isConnected != true) {
|
||||||
showSocketConnectionFailureNotification("Socket created, but not connected. Check logs")
|
showSocketConnectionFailureNotification("BluetoothConnectionManager.aacpSocket? created, but not connected. Check logs")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2367,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"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2390,16 +2402,27 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
?.getString("name", bluetoothDevice?.name)
|
?.getString("name", bluetoothDevice?.name)
|
||||||
if (bluetoothDevice != null && !action.isNullOrEmpty()) {
|
if (bluetoothDevice != null && !action.isNullOrEmpty()) {
|
||||||
Log.d(TAG, "Received bluetooth connection broadcast: action=$action")
|
Log.d(TAG, "Received bluetooth connection broadcast: action=$action")
|
||||||
if (BluetoothDevice.ACTION_ACL_CONNECTED == action) {
|
|
||||||
val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
|
val uuid = ParcelUuid.fromString("74ec2172-0bad-4d01-8f77-997b2be0722a")
|
||||||
bluetoothDevice.fetchUuidsWithSdp()
|
|
||||||
if (bluetoothDevice.uuids != null) {
|
if (BluetoothDevice.ACTION_ACL_CONNECTED == action) {
|
||||||
if (bluetoothDevice.uuids.contains(uuid)) {
|
if (bluetoothDevice.uuids?.contains(uuid) == true) {
|
||||||
val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
|
val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
|
||||||
intent.putExtra("name", name)
|
intent.putExtra("name", name)
|
||||||
intent.putExtra("device", bluetoothDevice)
|
intent.putExtra("device", bluetoothDevice)
|
||||||
context?.sendBroadcast(intent)
|
context?.sendBroadcast(intent)
|
||||||
|
} else {
|
||||||
|
bluetoothDevice.fetchUuidsWithSdp()
|
||||||
}
|
}
|
||||||
|
} else if ("android.bluetooth.device.action.UUID" == action) {
|
||||||
|
val savedMac = context?.getSharedPreferences("settings", MODE_PRIVATE)
|
||||||
|
?.getString("mac_address", "") ?: ""
|
||||||
|
val matchedByMac = savedMac.isNotEmpty() && bluetoothDevice.address == savedMac
|
||||||
|
val matchedByUuid = bluetoothDevice.uuids?.contains(uuid) == true
|
||||||
|
if (matchedByUuid || matchedByMac) {
|
||||||
|
val intent = Intent(AirPodsNotifications.AIRPODS_CONNECTION_DETECTED)
|
||||||
|
intent.putExtra("name", name)
|
||||||
|
intent.putExtra("device", bluetoothDevice)
|
||||||
|
context?.sendBroadcast(intent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2452,8 +2475,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
Log.d(
|
Log.d(
|
||||||
TAG, "owns connection: $ownsConnection"
|
TAG, "owns connection: $ownsConnection"
|
||||||
)
|
)
|
||||||
if (!::socket.isInitialized) return
|
if (BluetoothConnectionManager.aacpSocket?.isConnected == true) {
|
||||||
if (socket.isConnected) {
|
|
||||||
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
|
||||||
@@ -2611,62 +2633,16 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
// CrossDevice.isAvailable = false
|
// CrossDevice.isAvailable = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createBluetoothSocket(
|
|
||||||
adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid
|
|
||||||
): BluetoothSocket {
|
|
||||||
val type = 3 // L2CAP
|
|
||||||
val constructorSpecs = listOf(
|
|
||||||
arrayOf(adapter, device, type, true, true, 0x1001, uuid), // A16QPR3
|
|
||||||
arrayOf(device, type, true, true, 0x1001, uuid),
|
|
||||||
arrayOf(device, type, 1, true, true, 0x1001, uuid),
|
|
||||||
arrayOf(type, 1, true, true, device, 0x1001, uuid),
|
|
||||||
arrayOf(type, true, true, device, 0x1001, 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.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) {
|
||||||
socket = try {
|
val socket = try {
|
||||||
createBluetoothSocket(adapter, device, uuid)
|
createBluetoothSocket(adapter, device, uuid, 4097)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}")
|
Log.e(TAG, "Failed to create BluetoothSocket: ${e.message}")
|
||||||
showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.localizedMessage}")
|
showSocketConnectionFailureNotification("Failed to create Bluetooth socket: ${e.localizedMessage}")
|
||||||
@@ -2675,19 +2651,31 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
withTimeout(5000L) {
|
withTimeout(5000.milliseconds) {
|
||||||
try {
|
try {
|
||||||
socket.connect()
|
socket.connect()
|
||||||
// isConnectedLocally = true
|
|
||||||
this@AirPodsService.device = device
|
this@AirPodsService.device = device
|
||||||
|
|
||||||
BluetoothConnectionManager.setCurrentConnection(socket, device)
|
|
||||||
val xposedRemotePref = XposedRemotePrefProvider.create()
|
val xposedRemotePref = XposedRemotePrefProvider.create()
|
||||||
if (xposedRemotePref.getBoolean("vendor_id_hook", false)) {
|
val attSocket = if (xposedRemotePref.getBoolean("vendor_id_hook", false)) {
|
||||||
attManager = ATTManager(adapter, device)
|
createBluetoothSocket(
|
||||||
attManager!!.connect()
|
adapter,
|
||||||
|
device,
|
||||||
|
ParcelUuid.fromString("00000000-0000-0000-0000-000000000000"),
|
||||||
|
31
|
||||||
|
)
|
||||||
|
} else null
|
||||||
|
attSocket?.connect()
|
||||||
|
|
||||||
|
if (attSocket != null) {
|
||||||
|
attManager.startReader()
|
||||||
|
attManager.readCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION)
|
||||||
|
attManager.readCharacteristic(ATTHandles.TRANSPARENCY)
|
||||||
|
attManager.readCharacteristic(ATTHandles.HEARING_AID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 =
|
||||||
@@ -2740,7 +2728,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!socket.isConnected) {
|
if (!socket.isConnected) {
|
||||||
Log.d(TAG, "<LogCollector:Complete:Failed> Socket not connected")
|
Log.d(TAG, "<LogCollector:Complete:Failed> socket not connected")
|
||||||
if (manual) {
|
if (manual) {
|
||||||
sendToast(
|
sendToast(
|
||||||
"Couldn't connect to socket: timeout."
|
"Couldn't connect to socket: timeout."
|
||||||
@@ -2751,13 +2739,14 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
this@AirPodsService.device = device
|
this@AirPodsService.device = device
|
||||||
socket.let {
|
BluetoothConnectionManager.aacpSocket?.let {
|
||||||
aacpManager.sendPacket(aacpManager.createHandshakePacket())
|
aacpManager.sendPacket(aacpManager.createHandshakePacket())
|
||||||
aacpManager.sendSetFeatureFlagsPacket()
|
aacpManager.sendSetFeatureFlagsPacket()
|
||||||
aacpManager.sendNotificationRequest()
|
aacpManager.sendNotificationRequest()
|
||||||
Log.d(TAG, "Requesting proximity keys")
|
Log.d(TAG, "Requesting proximity keys")
|
||||||
aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte())
|
aacpManager.sendRequestProximityKeys((AACPManager.Companion.ProximityKeyType.IRK.value + AACPManager.Companion.ProximityKeyType.ENC_KEY.value).toByte())
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
|
delay(200)
|
||||||
aacpManager.sendPacket(aacpManager.createHandshakePacket())
|
aacpManager.sendPacket(aacpManager.createHandshakePacket())
|
||||||
delay(200)
|
delay(200)
|
||||||
aacpManager.sendSetFeatureFlagsPacket()
|
aacpManager.sendSetFeatureFlagsPacket()
|
||||||
@@ -2785,7 +2774,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
setupStemActions()
|
setupStemActions()
|
||||||
|
|
||||||
while (socket.isConnected) {
|
while (socket.isConnected) {
|
||||||
socket.let { it ->
|
|
||||||
try {
|
try {
|
||||||
val buffer = ByteArray(1024)
|
val buffer = ByteArray(1024)
|
||||||
val bytesRead = it.inputStream.read(buffer)
|
val bytesRead = it.inputStream.read(buffer)
|
||||||
@@ -2813,7 +2801,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
}
|
}
|
||||||
|
|
||||||
} else if (bytesRead == -1) {
|
} else if (bytesRead == -1) {
|
||||||
Log.d("AirPods Service", "Socket closed (bytesRead = -1)")
|
Log.d("AirPodsService", "socket closed (bytesRead = -1)")
|
||||||
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
|
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
|
||||||
setPackage(packageName)
|
setPackage(packageName)
|
||||||
})
|
})
|
||||||
@@ -2829,11 +2817,10 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
aacpManager.disconnected()
|
aacpManager.disconnected()
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
Log.d("AirPods Service", "socket closed")
|
||||||
Log.d("AirPods Service", "Socket closed")
|
|
||||||
// isConnectedLocally = false
|
// isConnectedLocally = false
|
||||||
socket.close()
|
|
||||||
aacpManager.disconnected()
|
aacpManager.disconnected()
|
||||||
updateNotificationContent(false)
|
updateNotificationContent(false)
|
||||||
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
|
sendBroadcast(Intent(AirPodsNotifications.AIRPODS_DISCONNECTED).apply {
|
||||||
@@ -2843,20 +2830,19 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
Log.d(TAG, "Failed to connect to socket: ${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 socket connection (isConnectedLocally = $isConnectedLocally, socket.isConnected = ${this::socket.isInitialized && socket.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() {
|
||||||
if (!this::socket.isInitialized) return
|
BluetoothConnectionManager.aacpSocket?.close()
|
||||||
socket.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(
|
||||||
@@ -2887,11 +2873,18 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun disconnectAirPods() {
|
fun disconnectAirPods() {
|
||||||
if (!this::socket.isInitialized) return
|
if (BluetoothConnectionManager.aacpSocket == null) return
|
||||||
socket.close()
|
try {
|
||||||
|
BluetoothConnectionManager.aacpSocket?.close()
|
||||||
|
} catch(e: Exception) {
|
||||||
|
Log.e(TAG, "error closing aacp socket ${e.message}")
|
||||||
|
}
|
||||||
// isConnectedLocally = false
|
// isConnectedLocally = false
|
||||||
aacpManager.disconnected()
|
aacpManager.disconnected()
|
||||||
attManager?.disconnect()
|
|
||||||
|
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)
|
||||||
@@ -3183,6 +3176,7 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
aacpManager.sendStopHeadTracking()
|
aacpManager.sendStopHeadTracking()
|
||||||
}
|
}
|
||||||
isHeadTrackingActive = false
|
isHeadTrackingActive = false
|
||||||
|
gestureDetector?.stopDetection()
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
@@ -3199,10 +3193,6 @@ class AirPodsService : Service(), SharedPreferences.OnSharedPreferenceChangeList
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun isConnected(): Boolean {
|
|
||||||
return if (::socket.isInitialized) socket.isConnected else false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Int.dpToPx(): Int {
|
private fun Int.dpToPx(): Int {
|
||||||
|
|||||||
@@ -20,26 +20,29 @@ 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 {
|
||||||
val isPixel = Build.MANUFACTURER.lowercase() == "google"
|
if (Build.VERSION.SDK_INT >= 37) return true
|
||||||
val isOppoFamily = Build.MANUFACTURER.lowercase() in listOf("oneplus", "oppo", "realme")
|
|
||||||
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
|
||||||
|
|
||||||
if (isPixel) {
|
val isPixel = Build.MANUFACTURER.lowercase() == "google"
|
||||||
when (Build.VERSION.SDK_INT) {
|
val isOppoFamily = Build.MANUFACTURER.lowercase() in listOf("oneplus", "oppo", "realme")
|
||||||
36 -> {
|
|
||||||
return Build.ID.startsWith("CP1A")
|
|
||||||
}
|
|
||||||
|
|
||||||
37 -> {
|
if (isPixel && Build.VERSION.SDK_INT == 36) {
|
||||||
return true
|
return Build.ID.startsWith("CP1A")
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (isOppoFamily) {
|
} else if (isOppoFamily) {
|
||||||
return Build.VERSION.SDK_INT >= 36
|
return Build.VERSION.SDK_INT >= 36
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun bypassDeviceCheck(sharedPreferences: SharedPreferences) {
|
||||||
|
sharedPreferences.edit{ putBoolean("bypass_device_check.v2", true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeDeviceCheckBypass(sharedPreferences: SharedPreferences) {
|
||||||
|
sharedPreferences.edit{ putBoolean("bypass_device_check.v2", false) }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
<foreground android:drawable="@drawable/ic_launcher_monochrome" />
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -275,5 +275,20 @@
|
|||||||
<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="custom">Custom</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>
|
||||||
|
<string name="compatibility_check_bypassed">Compatibility check bypassed. Are you sure your device is compatible?</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[versions]
|
[versions]
|
||||||
accompanistPermissions = "0.37.3"
|
accompanistPermissions = "0.37.3"
|
||||||
agp = "9.1.1"
|
agp = "9.2.1"
|
||||||
kotlin = "2.3.21"
|
kotlin = "2.3.21"
|
||||||
coreKtx = "1.18.0"
|
coreKtx = "1.18.0"
|
||||||
lifecycleRuntimeKtx = "2.10.0"
|
lifecycleRuntimeKtx = "2.10.0"
|
||||||
@@ -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" }
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
#Mon Oct 07 22:30:36 IST 2024
|
#Mon Oct 07 22:30:36 IST 2024
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
Reference in New Issue
Block a user