mirror of
https://github.com/kavishdevar/librepods.git
synced 2026-09-04 18:29:37 +02:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53679cc902 | |||
| b4cacb89f1 | |||
| cf6d226fca | |||
| 4c7a3cb203 | |||
| 1783bb742e | |||
| 3cc4d92d62 | |||
| 95ecc0eb3d | |||
| a7537f2361 | |||
| 21babd819f |
@@ -6,10 +6,6 @@ plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.aboutLibraries)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.room3)
|
||||
alias(libs.plugins.protobuf)
|
||||
// alias(libs.plugins.hilt)
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
@@ -28,35 +24,10 @@ val releaseSigningAvailable = listOf(
|
||||
"RELEASE_KEY_PASSWORD"
|
||||
).all { props[it]?.toString()?.isNotBlank() == true }
|
||||
|
||||
room3 {
|
||||
schemaDirectory("$projectDir/schemas")
|
||||
}
|
||||
|
||||
protobuf {
|
||||
protoc {
|
||||
artifact = libs.protobuf.protoc.get().toString()
|
||||
}
|
||||
|
||||
generateProtoTasks {
|
||||
all().forEach { task ->
|
||||
task.builtins {
|
||||
create("java")
|
||||
create("kotlin")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
optIn.addAll(
|
||||
"androidx.compose.material3.ExperimentalMaterial3ExpressiveApi",
|
||||
"kotlin.uuid.ExperimentalUuidApi",
|
||||
"kotlinx.coroutines.FlowPreview",
|
||||
"kotlinx.serialization.ExperimentalSerializationApi",
|
||||
"kotlinx.coroutines.ExperimentalCoroutinesApi",
|
||||
"dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi",
|
||||
"com.google.accompanist.permissions.ExperimentalPermissionsApi"
|
||||
optIn.add(
|
||||
"androidx.compose.material3.ExperimentalMaterial3ExpressiveApi"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -78,7 +49,7 @@ android {
|
||||
defaultConfig {
|
||||
applicationId = "me.kavishdevar.librepods"
|
||||
targetSdk = 37
|
||||
versionCode = 85
|
||||
versionCode = 63
|
||||
versionName = appVersionName
|
||||
}
|
||||
buildTypes {
|
||||
@@ -154,8 +125,8 @@ android {
|
||||
dependencies {
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.accompanist.permissions)
|
||||
implementation(libs.androidx.compose.ui.text.google.fonts)
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.foundation.layout)
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
@@ -169,11 +140,12 @@ dependencies {
|
||||
implementation(libs.haze)
|
||||
implementation(libs.haze.materials)
|
||||
implementation(libs.androidx.dynamicanimation)
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.material.icons.core)
|
||||
implementation(libs.billing)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
implementation(libs.androidx.compose.foundation.layout)
|
||||
implementation(libs.aboutlibraries.compose)
|
||||
implementation(libs.aboutlibraries)
|
||||
implementation(libs.aboutlibraries.compose.m3)
|
||||
implementation(libs.backdrop)
|
||||
// implementation(libs.hilt)
|
||||
@@ -186,12 +158,6 @@ dependencies {
|
||||
implementation(libs.androidx.navigation3.runtime)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
||||
implementation(libs.androidx.navigationevent)
|
||||
implementation(libs.androidx.room3.runtime)
|
||||
ksp(libs.androidx.room3.compiler)
|
||||
implementation(libs.kotlinx.serialization.cbor)
|
||||
implementation(libs.protobuf.kotlin)
|
||||
// compileOnly(files("../../../framework-classes.jar"))
|
||||
implementation(libs.androidx.healthconnect.client)
|
||||
}
|
||||
|
||||
aboutLibraries {
|
||||
@@ -212,7 +178,6 @@ fun registerRootModuleZipTask(
|
||||
flavor: String,
|
||||
buildType: String
|
||||
) = tasks.register<Zip>(name) {
|
||||
description = "Zips the root module for the $flavor-$buildType variant."
|
||||
|
||||
val variantTask = "assemble${cap(flavor)}${cap(buildType)}"
|
||||
dependsOn(variantTask)
|
||||
@@ -248,7 +213,6 @@ val zipDebug = registerRootModuleZipTask(
|
||||
|
||||
val collect = tasks.register<Copy>("collectReleaseArtifacts") {
|
||||
|
||||
description = "Collects release artifacts (APK, AAB, and root module ZIP) into a single directory for easier distribution."
|
||||
dependsOn(
|
||||
zipRelease,
|
||||
zipDebug,
|
||||
@@ -277,6 +241,5 @@ val collect = tasks.register<Copy>("collectReleaseArtifacts") {
|
||||
}
|
||||
|
||||
tasks.register("packageReleaseArtifacts") {
|
||||
description = "Packages release artifacts (APK, AAB, and root module ZIP) into a single directory for easier distribution."
|
||||
dependsOn(collect)
|
||||
}
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "c547604d916583c3b94c7bf1ac787fdf",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "AppleEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`macAddress` TEXT NOT NULL, `settings` BLOB NOT NULL, `metadata` BLOB NOT NULL, `cache` BLOB NOT NULL, PRIMARY KEY(`macAddress`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "macAddress",
|
||||
"columnName": "macAddress",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "settings",
|
||||
"columnName": "settings",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "metadata",
|
||||
"columnName": "metadata",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "cache",
|
||||
"columnName": "cache",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"macAddress"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "AppSettingsEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `nightMode` TEXT NOT NULL, `designSystem` TEXT NOT NULL, `useHighestRefreshRate` INTEGER NOT NULL, `debugMode` INTEGER NOT NULL, `bleScanMode` INTEGER NOT NULL, `bleReportDelay` INTEGER NOT NULL, `swipeAnywhereForBack` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "nightMode",
|
||||
"columnName": "nightMode",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "designSystem",
|
||||
"columnName": "designSystem",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "useHighestRefreshRate",
|
||||
"columnName": "useHighestRefreshRate",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "debugMode",
|
||||
"columnName": "debugMode",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bleScanMode",
|
||||
"columnName": "bleScanMode",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bleReportDelay",
|
||||
"columnName": "bleReportDelay",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "swipeAnywhereForBack",
|
||||
"columnName": "swipeAnywhereForBack",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "AppStateEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `isPremium` INTEGER NOT NULL, `hasCompletedOnboarding` INTEGER NOT NULL, `lastVersionShown` TEXT, `hasConnectedToAACP` INTEGER NOT NULL, `firstSuccessfulConnectionTime` INTEGER, `reviewPrompted` INTEGER NOT NULL, `timeUntilFOSSPremiumExpiry` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isPremium",
|
||||
"columnName": "isPremium",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hasCompletedOnboarding",
|
||||
"columnName": "hasCompletedOnboarding",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastVersionShown",
|
||||
"columnName": "lastVersionShown",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "hasConnectedToAACP",
|
||||
"columnName": "hasConnectedToAACP",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "firstSuccessfulConnectionTime",
|
||||
"columnName": "firstSuccessfulConnectionTime",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "reviewPrompted",
|
||||
"columnName": "reviewPrompted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timeUntilFOSSPremiumExpiry",
|
||||
"columnName": "timeUntilFOSSPremiumExpiry",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "WidgetConfigEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appWidgetId` INTEGER NOT NULL, `macAddress` TEXT NOT NULL, PRIMARY KEY(`appWidgetId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "appWidgetId",
|
||||
"columnName": "appWidgetId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "macAddress",
|
||||
"columnName": "macAddress",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"appWidgetId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "HeartRateSampleEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `bpm` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bpm",
|
||||
"columnName": "bpm",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c547604d916583c3b94c7bf1ac787fdf')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 2,
|
||||
"identityHash": "721149fa5e2a13e82b73a83de312968a",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "AppleEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`macAddress` TEXT NOT NULL, `settings` BLOB NOT NULL, `metadata` BLOB NOT NULL, `cache` BLOB NOT NULL, PRIMARY KEY(`macAddress`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "macAddress",
|
||||
"columnName": "macAddress",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "settings",
|
||||
"columnName": "settings",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "metadata",
|
||||
"columnName": "metadata",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "cache",
|
||||
"columnName": "cache",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"macAddress"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "AppSettingsEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `nightMode` TEXT NOT NULL, `designSystem` TEXT NOT NULL, `useHighestRefreshRate` INTEGER NOT NULL, `debugMode` INTEGER NOT NULL, `bleScanMode` INTEGER NOT NULL, `bleReportDelay` INTEGER NOT NULL, `swipeAnywhereForBack` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "nightMode",
|
||||
"columnName": "nightMode",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "designSystem",
|
||||
"columnName": "designSystem",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "useHighestRefreshRate",
|
||||
"columnName": "useHighestRefreshRate",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "debugMode",
|
||||
"columnName": "debugMode",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bleScanMode",
|
||||
"columnName": "bleScanMode",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bleReportDelay",
|
||||
"columnName": "bleReportDelay",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "swipeAnywhereForBack",
|
||||
"columnName": "swipeAnywhereForBack",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "AppStateEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `isPremium` INTEGER NOT NULL, `hasCompletedOnboarding` INTEGER NOT NULL, `lastVersionShown` TEXT, `hasConnectedToAACP` INTEGER NOT NULL, `firstSuccessfulConnectionTime` INTEGER, `reviewPrompted` INTEGER NOT NULL, `timeUntilFOSSPremiumExpiry` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isPremium",
|
||||
"columnName": "isPremium",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hasCompletedOnboarding",
|
||||
"columnName": "hasCompletedOnboarding",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastVersionShown",
|
||||
"columnName": "lastVersionShown",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "hasConnectedToAACP",
|
||||
"columnName": "hasConnectedToAACP",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "firstSuccessfulConnectionTime",
|
||||
"columnName": "firstSuccessfulConnectionTime",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "reviewPrompted",
|
||||
"columnName": "reviewPrompted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timeUntilFOSSPremiumExpiry",
|
||||
"columnName": "timeUntilFOSSPremiumExpiry",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "WidgetConfigEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appWidgetId` INTEGER NOT NULL, `macAddress` TEXT NOT NULL, PRIMARY KEY(`appWidgetId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "appWidgetId",
|
||||
"columnName": "appWidgetId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "macAddress",
|
||||
"columnName": "macAddress",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"appWidgetId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "HeartRateSampleEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `bpm` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bpm",
|
||||
"columnName": "bpm",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '721149fa5e2a13e82b73a83de312968a')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 3,
|
||||
"identityHash": "721149fa5e2a13e82b73a83de312968a",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "AppleEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`macAddress` TEXT NOT NULL, `settings` BLOB NOT NULL, `metadata` BLOB NOT NULL, `cache` BLOB NOT NULL, PRIMARY KEY(`macAddress`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "macAddress",
|
||||
"columnName": "macAddress",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "settings",
|
||||
"columnName": "settings",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "metadata",
|
||||
"columnName": "metadata",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "cache",
|
||||
"columnName": "cache",
|
||||
"affinity": "BLOB",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"macAddress"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "AppSettingsEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `nightMode` TEXT NOT NULL, `designSystem` TEXT NOT NULL, `overrideMaterialColor` INTEGER, `useHighestRefreshRate` INTEGER NOT NULL, `debugMode` INTEGER NOT NULL, `bleScanMode` INTEGER NOT NULL, `bleReportDelay` INTEGER NOT NULL, `swipeAnywhereForBack` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "nightMode",
|
||||
"columnName": "nightMode",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "designSystem",
|
||||
"columnName": "designSystem",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "overrideMaterialColor",
|
||||
"columnName": "overrideMaterialColor",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "useHighestRefreshRate",
|
||||
"columnName": "useHighestRefreshRate",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "debugMode",
|
||||
"columnName": "debugMode",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bleScanMode",
|
||||
"columnName": "bleScanMode",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bleReportDelay",
|
||||
"columnName": "bleReportDelay",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "swipeAnywhereForBack",
|
||||
"columnName": "swipeAnywhereForBack",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "AppStateEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `isPremium` INTEGER NOT NULL, `hasCompletedOnboarding` INTEGER NOT NULL, `lastVersionShown` TEXT, `hasConnectedToAACP` INTEGER NOT NULL, `firstSuccessfulConnectionTime` INTEGER, `reviewPrompted` INTEGER NOT NULL, `timeUntilFOSSPremiumExpiry` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isPremium",
|
||||
"columnName": "isPremium",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hasCompletedOnboarding",
|
||||
"columnName": "hasCompletedOnboarding",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastVersionShown",
|
||||
"columnName": "lastVersionShown",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "hasConnectedToAACP",
|
||||
"columnName": "hasConnectedToAACP",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "firstSuccessfulConnectionTime",
|
||||
"columnName": "firstSuccessfulConnectionTime",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "reviewPrompted",
|
||||
"columnName": "reviewPrompted",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timeUntilFOSSPremiumExpiry",
|
||||
"columnName": "timeUntilFOSSPremiumExpiry",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "WidgetConfigEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`appWidgetId` INTEGER NOT NULL, `macAddress` TEXT NOT NULL, PRIMARY KEY(`appWidgetId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "appWidgetId",
|
||||
"columnName": "appWidgetId",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "macAddress",
|
||||
"columnName": "macAddress",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"appWidgetId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "HeartRateSampleEntity",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `bpm` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "timestamp",
|
||||
"columnName": "timestamp",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "bpm",
|
||||
"columnName": "bpm",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '721149fa5e2a13e82b73a83de312968a')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -26,22 +26,15 @@
|
||||
<uses-permission
|
||||
android:name="android.permission.INTERACT_ACROSS_USERS"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<uses-permission
|
||||
android:name="android.permission.MODIFY_AUDIO_ROUTING"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation"
|
||||
tools:ignore="UnusedAttribute" />
|
||||
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
|
||||
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
|
||||
<!-- <uses-permission android:name="android.permission.INTERNET" />-->
|
||||
<!-- <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"-->
|
||||
<!-- android:maxSdkVersion="30" />-->
|
||||
@@ -49,8 +42,6 @@
|
||||
<!-- android:maxSdkVersion="30" />-->
|
||||
<uses-permission android:name="com.android.vending.BILLING" />
|
||||
|
||||
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE"/>
|
||||
|
||||
<application
|
||||
android:name=".LibrePodsApplication"
|
||||
android:allowBackup="true"
|
||||
@@ -89,7 +80,7 @@
|
||||
</receiver>
|
||||
|
||||
<activity
|
||||
android:name=".presentation.activities.MainActivity"
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.LibrePods">
|
||||
<intent-filter>
|
||||
@@ -107,11 +98,16 @@
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".presentation.activities.NoiseControlWidgetConfigurationActivity"
|
||||
android:exported="false" />
|
||||
android:name=".QuickSettingsDialogActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.TransparentDialog"
|
||||
android:launchMode="singleTask"
|
||||
android:excludeFromRecents="true"
|
||||
android:taskAffinity=""
|
||||
/>
|
||||
|
||||
<service
|
||||
android:name=".services.LibrePodsService"
|
||||
android:name=".services.AirPodsService"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="connectedDevice"
|
||||
@@ -119,7 +115,7 @@
|
||||
<service
|
||||
android:name=".services.AirPodsQSService"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic_airpods"
|
||||
android:icon="@drawable/airpods"
|
||||
android:label="ANC Mode"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
@@ -157,28 +153,6 @@
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<activity
|
||||
android:name=".presentation.activities.PrivacyPolicyActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity-alias
|
||||
android:name="ViewPermissionUsageActivity"
|
||||
android:exported="true"
|
||||
android:targetActivity=".presentation.activities.PrivacyPolicyActivity"
|
||||
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
|
||||
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
</application>
|
||||
<queries>
|
||||
<package android:name="com.google.android.apps.healthdata" />
|
||||
</queries>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -3,21 +3,21 @@ cmake_minimum_required(VERSION 3.22.1)
|
||||
project("l2c_fcr_hook")
|
||||
set(CMAKE_CXX_STANDARD 23)
|
||||
|
||||
add_library(hiddenapi SHARED
|
||||
hiddenapi.cpp
|
||||
add_library(bluetooth_socket SHARED
|
||||
bluetooth_socket.cpp
|
||||
)
|
||||
|
||||
target_compile_options(hiddenapi PRIVATE
|
||||
target_compile_options(bluetooth_socket PRIVATE
|
||||
-O2
|
||||
-fvisibility=hidden
|
||||
)
|
||||
|
||||
target_link_options(hiddenapi PRIVATE
|
||||
target_link_options(bluetooth_socket PRIVATE
|
||||
-Wl,--strip-all
|
||||
-Wl,--gc-sections
|
||||
)
|
||||
|
||||
target_link_libraries(hiddenapi
|
||||
target_link_libraries(bluetooth_socket
|
||||
android
|
||||
log
|
||||
)
|
||||
@@ -25,8 +25,8 @@ target_link_libraries(hiddenapi
|
||||
|
||||
set(XPOSED_SRC_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../xposed/cpp)
|
||||
|
||||
add_library(fluoride_hooks SHARED
|
||||
fluoride_hooks.cpp
|
||||
add_library(l2c_fcr_hook SHARED
|
||||
l2c_fcr_hook.cpp
|
||||
|
||||
xz/xz_crc32.c
|
||||
xz/xz_crc64.c
|
||||
@@ -36,11 +36,11 @@ add_library(fluoride_hooks SHARED
|
||||
xz/xz_dec_bcj.c
|
||||
)
|
||||
|
||||
target_include_directories(fluoride_hooks PRIVATE
|
||||
target_include_directories(l2c_fcr_hook PRIVATE
|
||||
xz
|
||||
)
|
||||
|
||||
target_compile_definitions(fluoride_hooks PRIVATE
|
||||
target_compile_definitions(l2c_fcr_hook PRIVATE
|
||||
XZ_DEC_X86
|
||||
XZ_DEC_ARM
|
||||
XZ_DEC_ARMTHUMB
|
||||
@@ -51,7 +51,7 @@ target_compile_definitions(fluoride_hooks PRIVATE
|
||||
XZ_DEC_CONCATENATED
|
||||
)
|
||||
|
||||
target_link_libraries(fluoride_hooks
|
||||
target_link_libraries(l2c_fcr_hook
|
||||
android
|
||||
log
|
||||
)
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
|
||||
constexpr auto c5 = ENC("([Ljava/lang/String;)V");
|
||||
constexpr auto c6 = ENC("java/lang/String");
|
||||
constexpr auto c7 = ENC("Landroid/bluetooth/BluetoothSocket;");
|
||||
constexpr auto c8 = ENC("L");
|
||||
constexpr auto c8 = ENC("Landroid/bluetooth/BluetoothDevice;");
|
||||
|
||||
JNIEnv* env;
|
||||
getVm()->AttachCurrentThread(&env, nullptr);
|
||||
@@ -1,203 +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/>.
|
||||
*/
|
||||
|
||||
#include <android/log.h>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <elf.h>
|
||||
#include <atomic>
|
||||
#include <jni.h>
|
||||
#include "helpers.h"
|
||||
|
||||
#include "fluoride_hooks.h"
|
||||
|
||||
static std::atomic<bool> enableSdpHook(false);
|
||||
|
||||
static HookFunType hook_func = nullptr;
|
||||
|
||||
static uint8_t (*original_l2c_fcr_chk_chan_modes)(void *) = nullptr;
|
||||
|
||||
uint8_t fake_l2c_fcr_chk_chan_modes(void *p_ccb) {
|
||||
LOGI("fake_l2c_fcr_chk_chan_modes called");
|
||||
uint8_t orig = 0;
|
||||
if (original_l2c_fcr_chk_chan_modes)
|
||||
orig = original_l2c_fcr_chk_chan_modes(p_ccb);
|
||||
|
||||
LOGI("fake_l2c_fcr_chk_chan_modes: orig = %d, returning 1", orig);
|
||||
return 1;
|
||||
}
|
||||
|
||||
static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(tSDP_DI_RECORD *, uint32_t *) = nullptr;
|
||||
|
||||
tBTA_STATUS fake_BTA_DmSetLocalDiRecord(tSDP_DI_RECORD *p_device_info, uint32_t *p_handle) {
|
||||
|
||||
LOGI("fake_BTA_DmSetLocalDiRecord called");
|
||||
|
||||
if (original_BTA_DmSetLocalDiRecord &&
|
||||
enableSdpHook.load(std::memory_order_relaxed))
|
||||
original_BTA_DmSetLocalDiRecord(p_device_info, p_handle);
|
||||
|
||||
LOGI("fake_BTA_DmSetLocalDiRecord: modifying vendor to 0x004C, vendor_id_source to 0x0001");
|
||||
|
||||
if (p_device_info) {
|
||||
p_device_info->vendor = 0x004C;
|
||||
p_device_info->vendor_id_source = 0x0001;
|
||||
}
|
||||
|
||||
LOGI("fake_BTA_DmSetLocalDiRecord: returning status %d",
|
||||
original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info, p_handle)
|
||||
: BTA_FAILURE);
|
||||
return original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info,
|
||||
p_handle)
|
||||
: BTA_FAILURE;
|
||||
}
|
||||
|
||||
|
||||
static bool hookLibrary(const char *libname) {
|
||||
LOGI("hookLibrary called with libname: %s", libname);
|
||||
|
||||
if (!hook_func) {
|
||||
LOGE("hook_func not initialized");
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string path;
|
||||
if (!getLibraryPath(libname, path)) {
|
||||
LOGE("Failed to locate %s", libname);
|
||||
return false;
|
||||
}
|
||||
LOGI("hookLibrary: located path: %s", path.c_str());
|
||||
|
||||
int fd = open(path.c_str(), O_RDONLY);
|
||||
if (fd < 0) {
|
||||
LOGE("hookLibrary: open failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
struct stat st{};
|
||||
if (fstat(fd, &st) != 0) {
|
||||
LOGE("hookLibrary: fstat failed");
|
||||
close(fd);
|
||||
return false;
|
||||
}
|
||||
LOGI("hookLibrary: opened file, size: %lld", (long long) st.st_size);
|
||||
|
||||
std::vector<uint8_t> file(st.st_size);
|
||||
read(fd, file.data(), st.st_size);
|
||||
close(fd);
|
||||
|
||||
auto *eh = reinterpret_cast<Elf64_Ehdr *>(file.data());
|
||||
auto *shdr = reinterpret_cast<Elf64_Shdr *>(
|
||||
file.data() + eh->e_shoff);
|
||||
|
||||
const char *shstr = reinterpret_cast<const char *>(
|
||||
file.data() + shdr[eh->e_shstrndx].sh_offset);
|
||||
|
||||
uint64_t chk_offset = 0;
|
||||
uint64_t sdp_offset = 0;
|
||||
|
||||
for (int i = 0; i < eh->e_shnum; ++i) {
|
||||
if (!strcmp(shstr + shdr[i].sh_name, ".gnu_debugdata")) {
|
||||
LOGI("hookLibrary: found .gnu_debugdata section");
|
||||
|
||||
std::vector<uint8_t> compressed(file.begin() + shdr[i].sh_offset,
|
||||
file.begin() + shdr[i].sh_offset + shdr[i].sh_size);
|
||||
|
||||
std::vector<uint8_t> decompressed;
|
||||
|
||||
if (decompressXZ(compressed.data(), compressed.size(), decompressed)) {
|
||||
chk_offset = findSymbolOffset(decompressed, "l2c_fcr_chk_chan_modes");
|
||||
sdp_offset = findSymbolOffset(decompressed, "BTA_DmSetLocalDiRecord");
|
||||
} else {
|
||||
LOGE("debugdata decompress failed");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!chk_offset) {
|
||||
LOGI("fallback dynsym chk");
|
||||
chk_offset = findSymbolOffsetDynsym(file, "l2c_fcr_chk_chan_modes");
|
||||
}
|
||||
|
||||
if (!sdp_offset) {
|
||||
LOGI("fallback dynsym sdp");
|
||||
sdp_offset = findSymbolOffsetDynsym(file, "BTA_DmSetLocalDiRecord");
|
||||
}
|
||||
|
||||
uintptr_t base = getModuleBase(libname);
|
||||
|
||||
if (!base) {
|
||||
LOGE("hookLibrary: getModuleBase failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (chk_offset) {
|
||||
void *target = reinterpret_cast<void *>(base + chk_offset);
|
||||
hook_func(target, (void *) fake_l2c_fcr_chk_chan_modes,
|
||||
(void **) &original_l2c_fcr_chk_chan_modes);
|
||||
LOGI("hooked chk");
|
||||
}
|
||||
|
||||
if (sdp_offset) {
|
||||
void *target = reinterpret_cast<void *>(base + sdp_offset);
|
||||
hook_func(target, (void *) fake_BTA_DmSetLocalDiRecord,
|
||||
(void **) &original_BTA_DmSetLocalDiRecord);
|
||||
LOGI("hooked sdp");
|
||||
}
|
||||
|
||||
return chk_offset || sdp_offset;
|
||||
}
|
||||
|
||||
static void on_library_loaded(const char *name, void *) {
|
||||
LOGI("on_library_loaded called with name: %s", name);
|
||||
|
||||
if (strstr(name, "libbluetooth_jni.so")) {
|
||||
LOGI("Bluetooth JNI loaded");
|
||||
hookLibrary("libbluetooth_jni.so");
|
||||
}
|
||||
|
||||
if (strstr(name, "libbluetooth_qti.so")) {
|
||||
LOGI("Bluetooth QTI loaded");
|
||||
hookLibrary("libbluetooth_qti.so");
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" [[gnu::visibility("default")]]
|
||||
[[gnu::used]]
|
||||
NativeOnModuleLoaded native_init(const NativeAPIEntries *entries) {
|
||||
LOGI("native_init called with entries: %p", entries);
|
||||
hook_func = (HookFunType) entries->hook_func;
|
||||
LOGI("LibrePodsNativeHook initialized, sdp hook enabled: %d",
|
||||
enableSdpHook.load(std::memory_order_relaxed));
|
||||
return on_library_loaded;
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_me_kavishdevar_librepods_utils_NativeBridge_setSdpHook(JNIEnv *, jobject thiz,
|
||||
jboolean enable) {
|
||||
LOGI("setSdpHook called with enable: %d", enable);
|
||||
enableSdpHook.store(enable, std::memory_order_relaxed);
|
||||
|
||||
LOGI("sdp hook enabled: %d", enable);
|
||||
}
|
||||
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
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/>.
|
||||
*/
|
||||
|
||||
#include <android/log.h>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
@@ -6,17 +24,60 @@
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <elf.h>
|
||||
#include <atomic>
|
||||
#include <jni.h>
|
||||
|
||||
#include "l2c_fcr_hook.h"
|
||||
|
||||
extern "C" {
|
||||
#include "xz.h"
|
||||
#include "xz.h"
|
||||
}
|
||||
|
||||
#define LOG_TAG "LibrePodsHook"
|
||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
||||
|
||||
static HookFunType hook_func = nullptr;
|
||||
|
||||
static uint8_t (*original_l2c_fcr_chk_chan_modes)(void *) = nullptr;
|
||||
|
||||
static tBTA_STATUS (*original_BTA_DmSetLocalDiRecord)(tSDP_DI_RECORD *, uint32_t *) = nullptr;
|
||||
|
||||
static std::atomic<bool> enableSdpHook(false);
|
||||
|
||||
uint8_t fake_l2c_fcr_chk_chan_modes(void *p_ccb) {
|
||||
LOGI("fake_l2c_fcr_chk_chan_modes called");
|
||||
uint8_t orig = 0;
|
||||
if (original_l2c_fcr_chk_chan_modes)
|
||||
orig = original_l2c_fcr_chk_chan_modes(p_ccb);
|
||||
|
||||
LOGI("fake_l2c_fcr_chk_chan_modes: orig = %d, returning 1", orig);
|
||||
return 1;
|
||||
}
|
||||
|
||||
tBTA_STATUS fake_BTA_DmSetLocalDiRecord(tSDP_DI_RECORD *p_device_info, uint32_t *p_handle) {
|
||||
|
||||
LOGI("fake_BTA_DmSetLocalDiRecord called");
|
||||
|
||||
if (original_BTA_DmSetLocalDiRecord &&
|
||||
enableSdpHook.load(std::memory_order_relaxed))
|
||||
original_BTA_DmSetLocalDiRecord(p_device_info, p_handle);
|
||||
|
||||
LOGI("fake_BTA_DmSetLocalDiRecord: modifying vendor to 0x004C, vendor_id_source to 0x0001");
|
||||
|
||||
if (p_device_info) {
|
||||
p_device_info->vendor = 0x004C;
|
||||
p_device_info->vendor_id_source = 0x0001;
|
||||
}
|
||||
|
||||
LOGI("fake_BTA_DmSetLocalDiRecord: returning status %d",
|
||||
original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info, p_handle)
|
||||
: BTA_FAILURE);
|
||||
return original_BTA_DmSetLocalDiRecord ? original_BTA_DmSetLocalDiRecord(p_device_info,
|
||||
p_handle)
|
||||
: BTA_FAILURE;
|
||||
}
|
||||
|
||||
static bool decompressXZ(const uint8_t *input, size_t input_size, std::vector<uint8_t> &output) {
|
||||
|
||||
LOGI("decompressXZ called with input_size: %zu", input_size);
|
||||
@@ -123,7 +184,7 @@ static uintptr_t getModuleBase(const char *name) {
|
||||
while (fgets(line, sizeof(line), fp)) {
|
||||
if (strstr(line, name)) {
|
||||
base = strtoull(line, nullptr, 16);
|
||||
LOGI("getModuleBase: found base at 0x%x", base);
|
||||
LOGI("getModuleBase: found base at 0x%lx", base);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -241,3 +302,134 @@ static uint64_t findSymbolOffset(const std::vector<uint8_t> &elf, const char *sy
|
||||
LOGI("findSymbolOffset: no match found for %s", symbol_substring);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static bool hookLibrary(const char *libname) {
|
||||
LOGI("hookLibrary called with libname: %s", libname);
|
||||
|
||||
if (!hook_func) {
|
||||
LOGE("hook_func not initialized");
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string path;
|
||||
if (!getLibraryPath(libname, path)) {
|
||||
LOGE("Failed to locate %s", libname);
|
||||
return false;
|
||||
}
|
||||
LOGI("hookLibrary: located path: %s", path.c_str());
|
||||
|
||||
int fd = open(path.c_str(), O_RDONLY);
|
||||
if (fd < 0) {
|
||||
LOGE("hookLibrary: open failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
struct stat st{};
|
||||
if (fstat(fd, &st) != 0) {
|
||||
LOGE("hookLibrary: fstat failed");
|
||||
close(fd);
|
||||
return false;
|
||||
}
|
||||
LOGI("hookLibrary: opened file, size: %lld", (long long) st.st_size);
|
||||
|
||||
std::vector<uint8_t> file(st.st_size);
|
||||
read(fd, file.data(), st.st_size);
|
||||
close(fd);
|
||||
|
||||
auto *eh = reinterpret_cast<Elf64_Ehdr *>(file.data());
|
||||
auto *shdr = reinterpret_cast<Elf64_Shdr *>(
|
||||
file.data() + eh->e_shoff);
|
||||
|
||||
const char *shstr = reinterpret_cast<const char *>(
|
||||
file.data() + shdr[eh->e_shstrndx].sh_offset);
|
||||
|
||||
uint64_t chk_offset = 0;
|
||||
uint64_t sdp_offset = 0;
|
||||
|
||||
for (int i = 0; i < eh->e_shnum; ++i) {
|
||||
if (!strcmp(shstr + shdr[i].sh_name, ".gnu_debugdata")) {
|
||||
LOGI("hookLibrary: found .gnu_debugdata section");
|
||||
|
||||
std::vector<uint8_t> compressed(file.begin() + shdr[i].sh_offset,
|
||||
file.begin() + shdr[i].sh_offset + shdr[i].sh_size);
|
||||
|
||||
std::vector<uint8_t> decompressed;
|
||||
|
||||
if (decompressXZ(compressed.data(), compressed.size(), decompressed)) {
|
||||
|
||||
chk_offset = findSymbolOffset(decompressed, "l2c_fcr_chk_chan_modes");
|
||||
|
||||
sdp_offset = findSymbolOffset(decompressed, "BTA_DmSetLocalDiRecord");
|
||||
} else {
|
||||
LOGE("debugdata decompress failed");
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!chk_offset) {
|
||||
LOGI("fallback dynsym chk");
|
||||
chk_offset = findSymbolOffsetDynsym(file, "l2c_fcr_chk_chan_modes");
|
||||
}
|
||||
|
||||
if (!sdp_offset) {
|
||||
LOGI("fallback dynsym sdp");
|
||||
sdp_offset = findSymbolOffsetDynsym(file, "BTA_DmSetLocalDiRecord");
|
||||
}
|
||||
|
||||
uintptr_t base = getModuleBase(libname);
|
||||
if (!base) {
|
||||
LOGE("hookLibrary: getModuleBase failed");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (chk_offset) {
|
||||
void *target = reinterpret_cast<void *>(base + chk_offset);
|
||||
hook_func(target, (void *) fake_l2c_fcr_chk_chan_modes,
|
||||
(void **) &original_l2c_fcr_chk_chan_modes);
|
||||
LOGI("hooked chk");
|
||||
}
|
||||
|
||||
if (sdp_offset) {
|
||||
void *target = reinterpret_cast<void *>(base + sdp_offset);
|
||||
hook_func(target, (void *) fake_BTA_DmSetLocalDiRecord,
|
||||
(void **) &original_BTA_DmSetLocalDiRecord);
|
||||
LOGI("hooked sdp");
|
||||
}
|
||||
|
||||
return chk_offset || sdp_offset;
|
||||
}
|
||||
|
||||
static void on_library_loaded(const char *name, void *) {
|
||||
LOGI("on_library_loaded called with name: %s", name);
|
||||
|
||||
if (strstr(name, "libbluetooth_jni.so")) {
|
||||
LOGI("Bluetooth JNI loaded");
|
||||
hookLibrary("libbluetooth_jni.so");
|
||||
}
|
||||
|
||||
if (strstr(name, "libbluetooth_qti.so")) {
|
||||
LOGI("Bluetooth QTI loaded");
|
||||
hookLibrary("libbluetooth_qti.so");
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" [[gnu::visibility("default")]]
|
||||
[[gnu::used]]
|
||||
NativeOnModuleLoaded native_init(const NativeAPIEntries *entries) {
|
||||
LOGI("native_init called with entries: %p", entries);
|
||||
hook_func = (HookFunType) entries->hook_func;
|
||||
LOGI("LibrePodsNativeHook initialized, sdp hook enabled: %d",
|
||||
enableSdpHook.load(std::memory_order_relaxed));
|
||||
return on_library_loaded;
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_me_kavishdevar_librepods_utils_NativeBridge_setSdpHook(JNIEnv *, jobject thiz,
|
||||
jboolean enable) {
|
||||
LOGI("setSdpHook called with enable: %d", enable);
|
||||
enableSdpHook.store(enable, std::memory_order_relaxed);
|
||||
|
||||
LOGI("sdp hook enabled: %d", enable);
|
||||
}
|
||||
-44
@@ -1,67 +1,23 @@
|
||||
package me.kavishdevar.librepods
|
||||
|
||||
import android.app.Application
|
||||
import androidx.health.connect.client.HealthConnectClient
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.room3.Room
|
||||
import io.github.libxposed.service.XposedService
|
||||
import io.github.libxposed.service.XposedServiceHelper
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import me.kavishdevar.librepods.billing.BillingManager
|
||||
import me.kavishdevar.librepods.billing.BillingProviderFactory
|
||||
import me.kavishdevar.librepods.database.LibrePodsDatabase
|
||||
import me.kavishdevar.librepods.repository.AppDataRepository
|
||||
import me.kavishdevar.librepods.repository.AppleRepository
|
||||
import me.kavishdevar.librepods.repository.HeartRateRepository
|
||||
import me.kavishdevar.librepods.repository.RecordingRepository
|
||||
import me.kavishdevar.librepods.repository.WidgetConfigRepository
|
||||
import me.kavishdevar.librepods.utils.GestureFeedback
|
||||
import me.kavishdevar.librepods.utils.XposedServiceHolder
|
||||
import me.kavishdevar.librepods.utils.XposedState
|
||||
|
||||
class LibrePodsApplication: Application(), XposedServiceHelper.OnServiceListener, DefaultLifecycleObserver {
|
||||
lateinit var database: LibrePodsDatabase
|
||||
private set
|
||||
|
||||
val appleRepository by lazy { AppleRepository(database.appleDao()) }
|
||||
val appDataRepository by lazy { AppDataRepository(database.appSettingsDao(), database.appStateDao()) }
|
||||
val widgetConfigRepository by lazy { WidgetConfigRepository(database.widgetConfigDao()) }
|
||||
|
||||
val recordingRepository by lazy { RecordingRepository(applicationContext) }
|
||||
val heartRateRepository by lazy { HeartRateRepository(database.heartRateDao()) }
|
||||
|
||||
val healthConnectClient: HealthConnectClient? by lazy {
|
||||
val status = HealthConnectClient.getSdkStatus(this)
|
||||
if (status == HealthConnectClient.SDK_AVAILABLE) {
|
||||
HealthConnectClient.getOrCreate(this)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
System.loadLibrary("hiddenapi")
|
||||
|
||||
database = Room.databaseBuilder(
|
||||
applicationContext,
|
||||
LibrePodsDatabase::class.java,
|
||||
"librepods.db"
|
||||
).build()
|
||||
|
||||
XposedServiceHelper.registerListener(this)
|
||||
|
||||
runBlocking(Dispatchers.IO) {
|
||||
appDataRepository.awaitInitialized()
|
||||
}
|
||||
|
||||
BillingManager.provider = BillingProviderFactory.create(this)
|
||||
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||
|
||||
GestureFeedback.init(this)
|
||||
|
||||
super<Application>.onCreate()
|
||||
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/*
|
||||
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
|
||||
|
||||
// import me.kavishdevar.librepods.screens.Onboarding
|
||||
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||
//import dagger.hilt.android.AndroidEntryPoint
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Context.MODE_PRIVATE
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.edit
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.android.play.core.review.ReviewManagerFactory
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.data.ControlCommandRepository
|
||||
import me.kavishdevar.librepods.presentation.navigation.NavigationRoot
|
||||
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import me.kavishdevar.librepods.utils.XposedState
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
lateinit var serviceConnection: ServiceConnection
|
||||
lateinit var connectionStatusReceiver: BroadcastReceiver
|
||||
lateinit var testReviewReceiver: BroadcastReceiver
|
||||
|
||||
//@AndroidEntryPoint
|
||||
@ExperimentalMaterial3Api
|
||||
class MainActivity : ComponentActivity() {
|
||||
companion object {
|
||||
init {
|
||||
if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
|
||||
System.loadLibrary("l2c_fcr_hook")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
setContent {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
try {
|
||||
unbindService(serviceConnection)
|
||||
Log.d("MainActivity", "Unbound service")
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Error while unbinding service: $e")
|
||||
}
|
||||
try {
|
||||
unregisterReceiver(connectionStatusReceiver)
|
||||
Log.d("MainActivity", "Unregistered receiver")
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Error while unregistering receiver: $e")
|
||||
}
|
||||
sendBroadcast(Intent(AirPodsNotifications.DISCONNECT_RECEIVERS))
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
try {
|
||||
unbindService(serviceConnection)
|
||||
Log.d("MainActivity", "Unbound service")
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Error while unbinding service: $e")
|
||||
}
|
||||
try {
|
||||
unregisterReceiver(connectionStatusReceiver)
|
||||
Log.d("MainActivity", "Unregistered receiver")
|
||||
} catch (e: Exception) {
|
||||
Log.e("MainActivity", "Error while unregistering receiver: $e")
|
||||
}
|
||||
super.onStop()
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@SuppressLint("MissingPermission", "InlinedApi", "UnspecifiedRegisterReceiverFlag")
|
||||
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun Main() {
|
||||
val context = LocalContext.current
|
||||
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
|
||||
|
||||
val airPodsService = remember { mutableStateOf<AirPodsService?>(null) }
|
||||
|
||||
val airPodsViewModel: AirPodsViewModel = viewModel()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (BuildConfig.PLAY_BUILD) {
|
||||
val now = System.currentTimeMillis()
|
||||
val firstConn =
|
||||
sharedPreferences.getLong("first_connection_successful_time", 0L)
|
||||
|
||||
val alreadyPrompted =
|
||||
sharedPreferences.getBoolean("review_prompted", false)
|
||||
|
||||
val oneDay = 24 * 60 * 60 * 1000L
|
||||
|
||||
if (
|
||||
firstConn != 0L &&
|
||||
!alreadyPrompted &&
|
||||
(now - firstConn) > oneDay
|
||||
) {
|
||||
triggerReviewFlow(context as? Activity ?: return@LaunchedEffect)
|
||||
|
||||
sharedPreferences.edit {
|
||||
putBoolean("review_prompted", true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val onboardingComplete = sharedPreferences.getBoolean("onboarding_complete", false)
|
||||
|
||||
val releaseNotesShownPrefKey = "release_notes_shown_${BuildConfig.VERSION_NAME.removeSuffix("-debug").removeSuffix("-play")}"
|
||||
val releaseNotesShown = sharedPreferences.getBoolean(releaseNotesShownPrefKey, false)
|
||||
|
||||
fun bindService() {
|
||||
context.startForegroundService(Intent(context, AirPodsService::class.java))
|
||||
serviceConnection = object: ServiceConnection {
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
val binder = service as AirPodsService.LocalBinder
|
||||
val service = binder.getService()
|
||||
airPodsService.value = service
|
||||
airPodsViewModel.init(
|
||||
service = service,
|
||||
controlRepo = ControlCommandRepository(service.aacpManager),
|
||||
sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE),
|
||||
appContext = context.applicationContext
|
||||
)
|
||||
|
||||
if (!sharedPreferences.contains("first_connection_successful_time")) {
|
||||
sharedPreferences.edit {
|
||||
putLong("first_connection_successful_time", System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?) {
|
||||
airPodsService.value = null
|
||||
}
|
||||
}
|
||||
|
||||
context.bindService(
|
||||
Intent(context, AirPodsService::class.java),
|
||||
serviceConnection,
|
||||
Context.BIND_AUTO_CREATE
|
||||
)
|
||||
}
|
||||
|
||||
if (onboardingComplete) {
|
||||
bindService()
|
||||
}
|
||||
|
||||
NavigationRoot(
|
||||
showReleaseNotes = !releaseNotesShown,
|
||||
updatesShown = { sharedPreferences.edit { putBoolean(releaseNotesShownPrefKey, true) } },
|
||||
showOnboarding = !onboardingComplete,
|
||||
onboardingComplete = {
|
||||
sharedPreferences.edit { putBoolean("onboarding_complete", true) }
|
||||
bindService()
|
||||
},
|
||||
airPodsViewModel = airPodsViewModel
|
||||
)
|
||||
}
|
||||
|
||||
private fun triggerReviewFlow(activity: Activity) {
|
||||
val manager = ReviewManagerFactory.create(activity)
|
||||
val request = manager.requestReviewFlow()
|
||||
request.addOnCompleteListener { task ->
|
||||
if (task.isSuccessful) {
|
||||
val reviewInfo = task.result
|
||||
manager.launchReviewFlow(activity, reviewInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
/*
|
||||
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
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.ServiceConnection
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
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.clickable
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
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.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.presentation.components.AdaptiveRainbowBrush
|
||||
import me.kavishdevar.librepods.presentation.components.ControlCenterNoiseControlSegmentedButton
|
||||
import me.kavishdevar.librepods.presentation.components.IconAreaSize
|
||||
import me.kavishdevar.librepods.presentation.components.VerticalVolumeSlider
|
||||
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.data.NoiseControlMode
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.abs
|
||||
|
||||
class QuickSettingsDialogActivity : ComponentActivity() {
|
||||
|
||||
private var airPodsService: AirPodsService? = null
|
||||
private var isBound = false
|
||||
|
||||
private var isNoiseControlExpandedState by mutableStateOf(false)
|
||||
|
||||
private val connection = object : ServiceConnection {
|
||||
override fun onServiceConnected(className: ComponentName, service: IBinder) {
|
||||
val binder = service as AirPodsService.LocalBinder
|
||||
airPodsService = binder.getService()
|
||||
isBound = true
|
||||
Log.d("QSActivity", "Service bound")
|
||||
setContent {
|
||||
LibrePodsTheme {
|
||||
DraggableDismissBox(
|
||||
onDismiss = { finish() },
|
||||
onlyCollapseWhenClicked = {
|
||||
if (isNoiseControlExpandedState) {
|
||||
isNoiseControlExpandedState = false
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (isBound && airPodsService != null) {
|
||||
NewControlCenterDialogContent(
|
||||
service = airPodsService,
|
||||
isNoiseControlExpanded = isNoiseControlExpandedState,
|
||||
onNoiseControlExpandedChange = { isNoiseControlExpandedState = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(arg0: ComponentName) {
|
||||
isBound = false
|
||||
airPodsService = null
|
||||
Log.d("QSActivity", "Service unbound")
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_BLUR_BEHIND)
|
||||
window.setGravity(Gravity.BOTTOM)
|
||||
|
||||
Intent(this, AirPodsService::class.java).also { intent ->
|
||||
bindService(intent, connection, BIND_AUTO_CREATE)
|
||||
}
|
||||
|
||||
setContent {
|
||||
LibrePodsTheme {
|
||||
DraggableDismissBox(
|
||||
onDismiss = { finish() },
|
||||
onlyCollapseWhenClicked = {
|
||||
if (isNoiseControlExpandedState) {
|
||||
isNoiseControlExpandedState = false
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (isBound && airPodsService != null) {
|
||||
NewControlCenterDialogContent(
|
||||
service = airPodsService,
|
||||
isNoiseControlExpanded = isNoiseControlExpandedState,
|
||||
onNoiseControlExpandedChange = { isNoiseControlExpandedState = it }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
if (isBound) {
|
||||
unbindService(connection)
|
||||
isBound = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DraggableDismissBox(
|
||||
onDismiss: () -> Unit,
|
||||
onlyCollapseWhenClicked: () -> Boolean,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var dragOffset by remember { mutableFloatStateOf(0f) }
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
val dismissThreshold = 400f
|
||||
|
||||
val animatedOffset = remember { Animatable(0f) }
|
||||
val animatedScale = remember { Animatable(1f) }
|
||||
val animatedAlpha = remember { Animatable(1f) }
|
||||
|
||||
val backgroundAlpha by animateFloatAsState(
|
||||
targetValue = if (isDragging) {
|
||||
val dragProgress = (abs(dragOffset) / 800f).coerceIn(0f, 0.8f)
|
||||
1f - dragProgress
|
||||
} else 1f,
|
||||
label = "BackgroundFade"
|
||||
)
|
||||
|
||||
LaunchedEffect(isDragging) {
|
||||
if (!isDragging) {
|
||||
if (abs(dragOffset) < dismissThreshold) {
|
||||
val springSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessHigh,
|
||||
visibilityThreshold = 0.1f
|
||||
)
|
||||
launch { animatedOffset.animateTo(0f, springSpec) }
|
||||
launch { animatedScale.animateTo(1f, springSpec) }
|
||||
launch { animatedAlpha.animateTo(1f, tween(100)) }
|
||||
dragOffset = 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(dragOffset, isDragging) {
|
||||
if (isDragging) {
|
||||
val dragProgress = (abs(dragOffset) / 1000f).coerceIn(0f, 0.5f)
|
||||
|
||||
animatedOffset.snapTo(dragOffset)
|
||||
animatedScale.snapTo(1f - dragProgress * 0.3f)
|
||||
animatedAlpha.snapTo(1f - dragProgress * 0.7f)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.5f * backgroundAlpha))
|
||||
.pointerInput(Unit) {
|
||||
detectVerticalDragGestures(
|
||||
onDragStart = { isDragging = true },
|
||||
onDragEnd = {
|
||||
isDragging = false
|
||||
if (abs(dragOffset) > dismissThreshold) {
|
||||
coroutineScope.launch {
|
||||
val direction = if (dragOffset > 0) 1f else -1f
|
||||
|
||||
launch {
|
||||
animatedOffset.animateTo(
|
||||
direction * 1500f,
|
||||
tween(350, easing = FastOutSlowInEasing)
|
||||
)
|
||||
}
|
||||
launch { animatedScale.animateTo(0.7f, tween(350)) }
|
||||
launch { animatedAlpha.animateTo(0f, tween(250)) }
|
||||
|
||||
kotlinx.coroutines.delay(350)
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
},
|
||||
onDragCancel = { isDragging = false },
|
||||
onVerticalDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
dragOffset += dragAmount
|
||||
}
|
||||
)
|
||||
}
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) {
|
||||
onlyCollapseWhenClicked()
|
||||
},
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer(
|
||||
translationY = animatedOffset.value,
|
||||
scaleX = animatedScale.value,
|
||||
scaleY = animatedScale.value,
|
||||
alpha = animatedAlpha.value
|
||||
),
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||
@Composable
|
||||
fun NewControlCenterDialogContent(
|
||||
service: AirPodsService?,
|
||||
isNoiseControlExpanded: Boolean,
|
||||
onNoiseControlExpandedChange: (Boolean) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
val textColor = Color.White
|
||||
|
||||
var currentAncMode by remember { mutableStateOf(NoiseControlMode.TRANSPARENCY) }
|
||||
var isConvAwarenessEnabled by remember { mutableStateOf(false) }
|
||||
|
||||
val isOffModeEnabled = remember { sharedPreferences.getBoolean("off_listening_mode", true) }
|
||||
val availableModes = remember(isOffModeEnabled) {
|
||||
mutableListOf(
|
||||
NoiseControlMode.TRANSPARENCY,
|
||||
NoiseControlMode.ADAPTIVE,
|
||||
NoiseControlMode.NOISE_CANCELLATION
|
||||
).apply {
|
||||
if (isOffModeEnabled) {
|
||||
add(0, NoiseControlMode.OFF)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
val maxVolume = remember { audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) }
|
||||
var currentVolumeInt by remember { mutableIntStateOf(audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)) }
|
||||
val animatedVolumeFraction by animateFloatAsState(
|
||||
targetValue = currentVolumeInt.toFloat() / maxVolume.toFloat(),
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow
|
||||
),
|
||||
label = "VolumeAnimation"
|
||||
)
|
||||
var liveDragFraction by remember { mutableFloatStateOf(animatedVolumeFraction) }
|
||||
var isDraggingVolume by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(animatedVolumeFraction, isDraggingVolume) {
|
||||
if (!isDraggingVolume) {
|
||||
liveDragFraction = animatedVolumeFraction
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(service, availableModes) {
|
||||
val ancReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == AirPodsNotifications.ANC_DATA && service != null) {
|
||||
val newModeOrdinal = intent.getIntExtra("data", NoiseControlMode.TRANSPARENCY.ordinal + 1) - 1
|
||||
val newMode = NoiseControlMode.entries.getOrElse(newModeOrdinal) { NoiseControlMode.TRANSPARENCY }
|
||||
if (availableModes.contains(newMode)) {
|
||||
currentAncMode = newMode
|
||||
} else if (newMode == NoiseControlMode.OFF && !isOffModeEnabled) {
|
||||
currentAncMode = NoiseControlMode.TRANSPARENCY
|
||||
}
|
||||
Log.d("QSActivity", "ANC Receiver updated mode to: $currentAncMode (available: ${availableModes.joinToString()})")
|
||||
}
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter(AirPodsNotifications.ANC_DATA)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.registerReceiver(ancReceiver, filter, Context.RECEIVER_EXPORTED)
|
||||
} else {
|
||||
context.registerReceiver(ancReceiver, filter)
|
||||
}
|
||||
|
||||
service?.let {
|
||||
val initialModeOrdinal = it.getANC().minus(1)
|
||||
var initialMode = NoiseControlMode.entries.getOrElse(initialModeOrdinal) { NoiseControlMode.TRANSPARENCY }
|
||||
if (!availableModes.contains(initialMode)) {
|
||||
initialMode = NoiseControlMode.TRANSPARENCY
|
||||
}
|
||||
currentAncMode = initialMode
|
||||
isConvAwarenessEnabled = sharedPreferences.getBoolean("conversational_awareness", true)
|
||||
Log.d("QSActivity", "Initial ANC: $currentAncMode, ConvAware: $isConvAwarenessEnabled")
|
||||
}
|
||||
|
||||
onDispose {
|
||||
context.unregisterReceiver(ancReceiver)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
val volumeReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == "android.media.VOLUME_CHANGED_ACTION") {
|
||||
val newVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC)
|
||||
if (newVolume != currentVolumeInt) {
|
||||
currentVolumeInt = newVolume
|
||||
Log.d("QSActivity", "Volume Receiver updated volume to: $currentVolumeInt")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter("android.media.VOLUME_CHANGED_ACTION")
|
||||
context.registerReceiver(volumeReceiver, filter)
|
||||
onDispose {
|
||||
context.unregisterReceiver(volumeReceiver)
|
||||
}
|
||||
}
|
||||
|
||||
val deviceName = remember { sharedPreferences.getString("name", "AirPods") ?: "AirPods" }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Transparent)
|
||||
.padding(horizontal = 24.dp)
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent()
|
||||
}
|
||||
}
|
||||
},
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
if (service != null) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(2f)
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.airpods),
|
||||
contentDescription = "Device Icon",
|
||||
tint = textColor.copy(alpha = 0.8f),
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = deviceName,
|
||||
color = textColor,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
VerticalVolumeSlider(
|
||||
displayFraction = animatedVolumeFraction,
|
||||
maxVolume = maxVolume,
|
||||
onVolumeChange = { newVolume ->
|
||||
currentVolumeInt = newVolume
|
||||
try {
|
||||
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, newVolume, 0)
|
||||
} catch (e: Exception) { Log.e("QSActivity", "Failed to set volume", e) }
|
||||
},
|
||||
initialFraction = animatedVolumeFraction,
|
||||
onDragStateChange = { dragging -> isDraggingVolume = dragging },
|
||||
baseSliderHeight = 400.dp,
|
||||
baseSliderWidth = 145.dp,
|
||||
baseCornerRadius = 48.dp,
|
||||
maxStretchFactor = 1.15f,
|
||||
minCompressionFactor = 0.875f,
|
||||
stretchSensitivity = 0.3f,
|
||||
compressionSensitivity = 0.3f,
|
||||
cornerRadiusChangeFactor = -0.5f,
|
||||
directionalStretchRatio = 0.75f,
|
||||
modifier = Modifier
|
||||
.width(145.dp)
|
||||
.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 72.dp)
|
||||
.animateContentSize(
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMedium
|
||||
)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = isNoiseControlExpanded,
|
||||
animationSpec = tween(durationMillis = 300),
|
||||
label = "NoiseControlCrossfade"
|
||||
) { expanded ->
|
||||
if (expanded) {
|
||||
ControlCenterNoiseControlSegmentedButton(
|
||||
availableModes = availableModes,
|
||||
selectedMode = currentAncMode,
|
||||
onModeSelected = { newMode ->
|
||||
service.aacpManager.sendControlCommand(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE.value,
|
||||
value = newMode.ordinal + 1
|
||||
)
|
||||
currentAncMode = newMode
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(0.8f)
|
||||
)
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(0.85f),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
val noiseControlButtonBrush = if (currentAncMode == NoiseControlMode.ADAPTIVE) {
|
||||
AdaptiveRainbowBrush
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(IconAreaSize)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
brush = noiseControlButtonBrush ?:
|
||||
Brush.linearGradient(colors = listOf(Color(0xFF0A84FF), Color(0xFF0A84FF)))
|
||||
)
|
||||
.clickable(
|
||||
onClick = { onNoiseControlExpandedChange(true) },
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = getModeIconRes(currentAncMode)),
|
||||
contentDescription = getModeLabel(currentAncMode),
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = getModeLabel(currentAncMode),
|
||||
color = Color.White,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(24.dp))
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(IconAreaSize)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
Brush.linearGradient(
|
||||
colors = listOf(
|
||||
if (isConvAwarenessEnabled) Color(0xFF0A84FF) else Color(0x593C3C3E),
|
||||
if (isConvAwarenessEnabled) Color(0xFF0A84FF) else Color(0x593C3C3E)
|
||||
)
|
||||
)
|
||||
)
|
||||
.clickable(
|
||||
onClick = {
|
||||
val newState = !isConvAwarenessEnabled
|
||||
service.aacpManager.sendControlCommand(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG.value,
|
||||
value = newState
|
||||
)
|
||||
isConvAwarenessEnabled = newState
|
||||
},
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.airpods),
|
||||
contentDescription = "Conversational Awareness",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "Conversational\nAwareness",
|
||||
color = Color.White,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
lineHeight = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
Text("Loading...", color = textColor)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getModeIconRes(mode: NoiseControlMode): Int {
|
||||
return when (mode) {
|
||||
NoiseControlMode.OFF -> R.drawable.noise_cancellation
|
||||
NoiseControlMode.TRANSPARENCY -> R.drawable.transparency
|
||||
NoiseControlMode.ADAPTIVE -> R.drawable.adaptive
|
||||
NoiseControlMode.NOISE_CANCELLATION -> R.drawable.noise_cancellation
|
||||
}
|
||||
}
|
||||
|
||||
private fun getModeLabel(mode: NoiseControlMode): String {
|
||||
return when (mode) {
|
||||
NoiseControlMode.OFF -> "Off"
|
||||
NoiseControlMode.TRANSPARENCY -> "Transparency"
|
||||
NoiseControlMode.ADAPTIVE -> "Adaptive"
|
||||
NoiseControlMode.NOISE_CANCELLATION -> "Noise Cancel"
|
||||
}
|
||||
}
|
||||
+11
-9
@@ -21,6 +21,7 @@ package me.kavishdevar.librepods.billing
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -30,19 +31,17 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.LibrePodsApplication
|
||||
import me.kavishdevar.librepods.R
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class FOSSBillingProvider(context: Context): BillingProvider {
|
||||
private val appDataRepository = (context.applicationContext as LibrePodsApplication).appDataRepository
|
||||
|
||||
private val _isPremium = MutableStateFlow(appDataRepository.state.value.isPremium)
|
||||
private val _isPremium = MutableStateFlow(false)
|
||||
override val isPremium: StateFlow<Boolean> = _isPremium
|
||||
|
||||
private val _price = MutableStateFlow(context.getString(R.string.name_your_own_price))
|
||||
override val price: StateFlow<String> = _price
|
||||
|
||||
private val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var purchaseJob: Job? = null
|
||||
|
||||
@@ -58,18 +57,21 @@ class FOSSBillingProvider(context: Context): BillingProvider {
|
||||
purchaseJob?.cancel()
|
||||
|
||||
purchaseJob = scope.launch {
|
||||
delay(5.seconds)
|
||||
delay(5_000)
|
||||
_isPremium.value = true
|
||||
appDataRepository.updateState { it.copy(isPremium = true) }
|
||||
sharedPreferences.edit { putBoolean("foss_upgraded", true) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun queryPurchases() {
|
||||
_isPremium.value = appDataRepository.state.value.isPremium
|
||||
val stored = sharedPreferences.getBoolean("foss_upgraded", false)
|
||||
if (stored != _isPremium.value) {
|
||||
_isPremium.value = stored
|
||||
}
|
||||
}
|
||||
|
||||
override fun restorePurchases() {
|
||||
_isPremium.value = true
|
||||
appDataRepository.updateState { it.copy(isPremium = true) }
|
||||
sharedPreferences.edit { putBoolean("foss_upgraded", true) }
|
||||
}
|
||||
}
|
||||
+1
-5
@@ -41,7 +41,6 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.LibrePodsApplication
|
||||
|
||||
const val TAG = "PlayBillingProvider"
|
||||
|
||||
@@ -50,11 +49,10 @@ private const val PREMIUM_PRODUCT_ID = "librepods.advanced_features.v2"
|
||||
class PlayBillingProvider(
|
||||
context: Context
|
||||
) : BillingProvider, PurchasesUpdatedListener {
|
||||
private val appDataRepository = (context.applicationContext as LibrePodsApplication).appDataRepository
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
private val _isPremium = MutableStateFlow(appDataRepository.state.value.isPremium)
|
||||
private val _isPremium = MutableStateFlow(false)
|
||||
override val isPremium: StateFlow<Boolean> = _isPremium
|
||||
|
||||
private val _price = MutableStateFlow("unknown")
|
||||
@@ -179,8 +177,6 @@ class PlayBillingProvider(
|
||||
|
||||
_isPremium.value = hasPremium
|
||||
|
||||
appDataRepository.updateState { it.copy(isPremium = hasPremium) }
|
||||
|
||||
scope.launch {
|
||||
purchases
|
||||
.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED && !it.isAcknowledged }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
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.bluetooth
|
||||
|
||||
import android.util.Log
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
private const val TAG = "ATTManager"
|
||||
|
||||
enum class ATTHandles(val value: Int) {
|
||||
TRANSPARENCY(0x18),
|
||||
LOUD_SOUND_REDUCTION(0x1B),
|
||||
HEARING_AID(0x2A)
|
||||
}
|
||||
|
||||
enum class ATTCCCDHandles(val value: Int) {
|
||||
TRANSPARENCY(ATTHandles.TRANSPARENCY.value + 1),
|
||||
// LOUD_SOUND_REDUCTION(ATTHandles.LOUD_SOUND_REDUCTION.value + 1), // doesn't work
|
||||
HEARING_AID(ATTHandles.HEARING_AID.value + 1)
|
||||
}
|
||||
|
||||
class ATTManagerv2 {
|
||||
val characteristicList = mutableMapOf<ATTHandles, ByteArray>()
|
||||
|
||||
private val responseQueues = ConcurrentHashMap<Byte, LinkedBlockingQueue<ByteArray>>()
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
fun stopReader() {
|
||||
readerRunning.set(false)
|
||||
readerThread?.interrupt()
|
||||
readerThread = null
|
||||
}
|
||||
|
||||
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 {
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
Log.d(TAG, "write respose: ${resp.joinToString(" ") { String.format("%02X", it) }}")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "error writing characteristic: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnected() {
|
||||
characteristicList.clear()
|
||||
stopReader()
|
||||
val socket = BluetoothConnectionManager.attSocket?: return
|
||||
try {
|
||||
socket.close()
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "error closing socket: ${e.message}")
|
||||
}
|
||||
Log.d(TAG, "ATT disconnected")
|
||||
}
|
||||
|
||||
private fun runReaderLoop() {
|
||||
val socket = BluetoothConnectionManager.attSocket ?: run {
|
||||
Log.w(TAG, "ATT socket not available. stopping reader")
|
||||
readerRunning.set(false)
|
||||
return
|
||||
}
|
||||
|
||||
val input = socket.inputStream
|
||||
val buffer = ByteArray(512)
|
||||
|
||||
while (readerRunning.get()) {
|
||||
try {
|
||||
val len = input.read(buffer)
|
||||
if (len == -1) {
|
||||
Log.w(TAG, "ATT input stream ended")
|
||||
break
|
||||
}
|
||||
val data = buffer.copyOfRange(0, len)
|
||||
if (data.isEmpty()) continue
|
||||
|
||||
val opcode = data[0]
|
||||
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 {
|
||||
onNotificationReceived?.invoke(handle, value)
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "onNotificationReceived threw: ${t.message}", t)
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "notification PDU too short: ${data.joinToString(" ") { String.format("%02X", it) }}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "error in reader loop: ${e.message}", e)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
readerRunning.set(false)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
+104
-85
@@ -1,4 +1,22 @@
|
||||
package me.kavishdevar.librepods.bluetooth.ble
|
||||
/*
|
||||
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.bluetooth
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothManager
|
||||
@@ -12,9 +30,10 @@ import android.content.SharedPreferences
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.MagicKeyType
|
||||
import me.kavishdevar.librepods.utils.BluetoothCryptography
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import kotlin.collections.iterator
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@@ -22,7 +41,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
* Manager for Bluetooth Low Energy scanning operations specifically for AirPods
|
||||
*/
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
class BLEManagerold(private val context: Context) {
|
||||
class BLEManager(private val context: Context) {
|
||||
|
||||
data class AirPodsStatus(
|
||||
val address: String,
|
||||
@@ -193,7 +212,7 @@ class BLEManagerold(private val context: Context) {
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
private fun getEncryptionKeyFromPreferences(): ByteArray? {
|
||||
val keyBase64 = sharedPreferences.getString(MagicKeyType.ENC_KEY.name, null)
|
||||
val keyBase64 = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.ENC_KEY.name, null)
|
||||
return if (keyBase64 != null) {
|
||||
try {
|
||||
Base64.decode(keyBase64)
|
||||
@@ -231,86 +250,86 @@ class BLEManagerold(private val context: Context) {
|
||||
}
|
||||
|
||||
private fun processScanResult(result: ScanResult) {
|
||||
// try {
|
||||
// val scanRecord = result.scanRecord ?: return
|
||||
// val address = result.device.address
|
||||
//
|
||||
// if (processedAddresses.contains(address)) {
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// val manufacturerData = scanRecord.getManufacturerSpecificData(76) ?: return
|
||||
// if (manufacturerData.size <= 20) return
|
||||
//
|
||||
// if (!verifiedAddresses.contains(address)) {
|
||||
// val irk = getIrkFromPreferences()
|
||||
// if (irk == null || !BluetoothCryptography.verifyRPA(address, irk)) {
|
||||
// return
|
||||
// }
|
||||
// verifiedAddresses.add(address)
|
||||
// Log.d(TAG, "RPA verified and added to trusted list: $address")
|
||||
// }
|
||||
//
|
||||
// processedAddresses.add(address)
|
||||
// lastBroadcastTime = System.currentTimeMillis()
|
||||
//
|
||||
// val encryptionKey = getEncryptionKeyFromPreferences()
|
||||
// val decryptedData = if (encryptionKey != null) decryptLastBytes(manufacturerData, encryptionKey) else null
|
||||
// val parsedStatus = if (decryptedData != null && decryptedData.size == 16) {
|
||||
// parseProximityMessageWithDecryption(address, manufacturerData, decryptedData)
|
||||
// } else {
|
||||
// parseProximityMessage(address, manufacturerData)
|
||||
// }
|
||||
//
|
||||
// val previousStatus = deviceStatusMap[address]
|
||||
// deviceStatusMap[address] = parsedStatus
|
||||
//
|
||||
// airPodsStatusListener?.let { listener ->
|
||||
// if (previousStatus == null) {
|
||||
// listener.onBroadcastFromNewAddress(parsedStatus)
|
||||
// Log.d(TAG, "New AirPods device detected: $address")
|
||||
//
|
||||
// if (currentGlobalLidState == null || currentGlobalLidState != parsedStatus.lidOpen) {
|
||||
// currentGlobalLidState = parsedStatus.lidOpen
|
||||
// listener.onLidStateChanged(parsedStatus.lidOpen)
|
||||
// Log.d(TAG, "Lid state ${if (parsedStatus.lidOpen) "opened" else "closed"} (detected from new device)")
|
||||
// }
|
||||
// } else {
|
||||
// if (parsedStatus != previousStatus) {
|
||||
// listener.onDeviceStatusChanged(parsedStatus, previousStatus)
|
||||
// }
|
||||
//
|
||||
// if (parsedStatus.lidOpen != previousStatus.lidOpen) {
|
||||
// val previousGlobalState = currentGlobalLidState
|
||||
// currentGlobalLidState = parsedStatus.lidOpen
|
||||
//
|
||||
// if (previousGlobalState != parsedStatus.lidOpen) {
|
||||
// listener.onLidStateChanged(parsedStatus.lidOpen)
|
||||
// Log.d(TAG, "Lid state changed from $previousGlobalState to ${parsedStatus.lidOpen}")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if (parsedStatus.isLeftInEar != previousStatus.isLeftInEar ||
|
||||
// parsedStatus.isRightInEar != previousStatus.isRightInEar) {
|
||||
// listener.onEarStateChanged(
|
||||
// parsedStatus,
|
||||
// parsedStatus.isLeftInEar,
|
||||
// parsedStatus.isRightInEar
|
||||
// )
|
||||
// Log.d(TAG, "Ear state changed - Left: ${parsedStatus.isLeftInEar}, Right: ${parsedStatus.isRightInEar}")
|
||||
// }
|
||||
//
|
||||
// if (parsedStatus.leftBattery != previousStatus.leftBattery ||
|
||||
// parsedStatus.rightBattery != previousStatus.rightBattery ||
|
||||
// parsedStatus.caseBattery != previousStatus.caseBattery) {
|
||||
// listener.onBatteryChanged(parsedStatus)
|
||||
// Log.d(TAG, "Battery changed - Left: ${parsedStatus.leftBattery}, Right: ${parsedStatus.rightBattery}, Case: ${parsedStatus.caseBattery}")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// } catch (t: Throwable) {
|
||||
// Log.e(TAG, "Error processing scan result", t)
|
||||
// }
|
||||
try {
|
||||
val scanRecord = result.scanRecord ?: return
|
||||
val address = result.device.address
|
||||
|
||||
if (processedAddresses.contains(address)) {
|
||||
return
|
||||
}
|
||||
|
||||
val manufacturerData = scanRecord.getManufacturerSpecificData(76) ?: return
|
||||
if (manufacturerData.size <= 20) return
|
||||
|
||||
if (!verifiedAddresses.contains(address)) {
|
||||
val irk = getIrkFromPreferences()
|
||||
if (irk == null || !BluetoothCryptography.verifyRPA(address, irk)) {
|
||||
return
|
||||
}
|
||||
verifiedAddresses.add(address)
|
||||
Log.d(TAG, "RPA verified and added to trusted list: $address")
|
||||
}
|
||||
|
||||
processedAddresses.add(address)
|
||||
lastBroadcastTime = System.currentTimeMillis()
|
||||
|
||||
val encryptionKey = getEncryptionKeyFromPreferences()
|
||||
val decryptedData = if (encryptionKey != null) decryptLastBytes(manufacturerData, encryptionKey) else null
|
||||
val parsedStatus = if (decryptedData != null && decryptedData.size == 16) {
|
||||
parseProximityMessageWithDecryption(address, manufacturerData, decryptedData)
|
||||
} else {
|
||||
parseProximityMessage(address, manufacturerData)
|
||||
}
|
||||
|
||||
val previousStatus = deviceStatusMap[address]
|
||||
deviceStatusMap[address] = parsedStatus
|
||||
|
||||
airPodsStatusListener?.let { listener ->
|
||||
if (previousStatus == null) {
|
||||
listener.onBroadcastFromNewAddress(parsedStatus)
|
||||
Log.d(TAG, "New AirPods device detected: $address")
|
||||
|
||||
if (currentGlobalLidState == null || currentGlobalLidState != parsedStatus.lidOpen) {
|
||||
currentGlobalLidState = parsedStatus.lidOpen
|
||||
listener.onLidStateChanged(parsedStatus.lidOpen)
|
||||
Log.d(TAG, "Lid state ${if (parsedStatus.lidOpen) "opened" else "closed"} (detected from new device)")
|
||||
}
|
||||
} else {
|
||||
if (parsedStatus != previousStatus) {
|
||||
listener.onDeviceStatusChanged(parsedStatus, previousStatus)
|
||||
}
|
||||
|
||||
if (parsedStatus.lidOpen != previousStatus.lidOpen) {
|
||||
val previousGlobalState = currentGlobalLidState
|
||||
currentGlobalLidState = parsedStatus.lidOpen
|
||||
|
||||
if (previousGlobalState != parsedStatus.lidOpen) {
|
||||
listener.onLidStateChanged(parsedStatus.lidOpen)
|
||||
Log.d(TAG, "Lid state changed from $previousGlobalState to ${parsedStatus.lidOpen}")
|
||||
}
|
||||
}
|
||||
|
||||
if (parsedStatus.isLeftInEar != previousStatus.isLeftInEar ||
|
||||
parsedStatus.isRightInEar != previousStatus.isRightInEar) {
|
||||
listener.onEarStateChanged(
|
||||
parsedStatus,
|
||||
parsedStatus.isLeftInEar,
|
||||
parsedStatus.isRightInEar
|
||||
)
|
||||
Log.d(TAG, "Ear state changed - Left: ${parsedStatus.isLeftInEar}, Right: ${parsedStatus.isRightInEar}")
|
||||
}
|
||||
|
||||
if (parsedStatus.leftBattery != previousStatus.leftBattery ||
|
||||
parsedStatus.rightBattery != previousStatus.rightBattery ||
|
||||
parsedStatus.caseBattery != previousStatus.caseBattery) {
|
||||
listener.onBatteryChanged(parsedStatus)
|
||||
Log.d(TAG, "Battery changed - Left: ${parsedStatus.leftBattery}, Right: ${parsedStatus.rightBattery}, Case: ${parsedStatus.caseBattery}")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "Error processing scan result", t)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseProximityMessageWithDecryption(address: String, data: ByteArray, decrypted: ByteArray): AirPodsStatus {
|
||||
@@ -398,7 +417,7 @@ class BLEManagerold(private val context: Context) {
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
private fun getIrkFromPreferences(): ByteArray? {
|
||||
val irkBase64 = sharedPreferences.getString(MagicKeyType.IRK.name, null)
|
||||
val irkBase64 = sharedPreferences.getString(AACPManager.Companion.ProximityKeyType.IRK.name, null)
|
||||
return if (irkBase64 != null) {
|
||||
try {
|
||||
Base64.decode(irkBase64)
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
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.bluetooth
|
||||
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothSocket
|
||||
import android.os.ParcelUuid
|
||||
import android.util.Log
|
||||
|
||||
object BluetoothConnectionManager {
|
||||
var aacpSocket: BluetoothSocket? = null
|
||||
var attSocket: BluetoothSocket? = null
|
||||
}
|
||||
|
||||
fun createBluetoothSocket(
|
||||
adapter: BluetoothAdapter, device: BluetoothDevice, uuid: ParcelUuid, psm: Int
|
||||
): BluetoothSocket {
|
||||
val type = 3 // L2CAP
|
||||
val constructorSpecs = listOf(
|
||||
arrayOf(adapter, device, type, true, true, psm, uuid), // A16QPR3
|
||||
arrayOf(device, type, true, true, psm, uuid),
|
||||
arrayOf(device, type, 1, true, true, psm, uuid),
|
||||
arrayOf(type, 1, true, true, device, psm, uuid),
|
||||
arrayOf(type, true, true, device, psm, uuid)
|
||||
)
|
||||
|
||||
val constructors = BluetoothSocket::class.java.declaredConstructors
|
||||
Log.d("createSocket<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,277 @@
|
||||
/*
|
||||
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.data
|
||||
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
open class AirPodsBase(
|
||||
val modelNumber: List<String>,
|
||||
val name: String,
|
||||
val displayName: String = "AirPods",
|
||||
val manufacturer: String = "Apple Inc.",
|
||||
val budCaseRes: Int,
|
||||
val budsRes: Int,
|
||||
val leftBudsRes: Int,
|
||||
val rightBudsRes: Int,
|
||||
val caseRes: Int,
|
||||
val capabilities: Set<Capability>
|
||||
)
|
||||
enum class Capability {
|
||||
LISTENING_MODE,
|
||||
CONVERSATION_AWARENESS,
|
||||
STEM_CONFIG,
|
||||
HEAD_GESTURES,
|
||||
LOUD_SOUND_REDUCTION,
|
||||
PPE,
|
||||
SLEEP_DETECTION,
|
||||
HEARING_AID,
|
||||
ADAPTIVE_AUDIO,
|
||||
ADAPTIVE_VOLUME,
|
||||
SWIPE_FOR_VOLUME,
|
||||
HRM
|
||||
}
|
||||
|
||||
class AirPods: AirPodsBase(
|
||||
modelNumber = listOf("A1523", "A1722"),
|
||||
name = "AirPods 1",
|
||||
// budCaseRes = R.drawable.airpods_1
|
||||
budCaseRes = R.drawable.airpods_pro_2,
|
||||
// budsRes = R.drawable.airpods_1_buds
|
||||
budsRes = R.drawable.airpods_pro_2_buds,
|
||||
// leftBudsRes = R.drawable.airpods_1_left
|
||||
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||
// rightBudsRes = R.drawable.airpods_1_right
|
||||
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||
// caseRes = R.drawable.airpods_1_case
|
||||
caseRes = R.drawable.airpods_pro_2_case,
|
||||
capabilities = emptySet()
|
||||
)
|
||||
|
||||
class AirPods2: AirPodsBase(
|
||||
modelNumber = listOf("A2032", "A2031"),
|
||||
name = "AirPods 2",
|
||||
// budCaseRes = R.drawable.airpods_2
|
||||
budCaseRes = R.drawable.airpods_pro_2,
|
||||
// budsRes = R.drawable.airpods_2_buds
|
||||
budsRes = R.drawable.airpods_pro_2_buds,
|
||||
// leftBudsRes = R.drawable.airpods_2_left
|
||||
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||
// rightBudsRes = R.drawable.airpods_2_right
|
||||
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||
// caseRes = R.drawable.airpods_2_case
|
||||
caseRes = R.drawable.airpods_pro_2_case,
|
||||
capabilities = emptySet()
|
||||
)
|
||||
|
||||
class AirPods3: AirPodsBase(
|
||||
modelNumber = listOf("A2565", "A2564"),
|
||||
name = "AirPods 3",
|
||||
// budCaseRes = R.drawable.airpods_3
|
||||
budCaseRes = R.drawable.airpods_pro_2,
|
||||
// budsRes = R.drawable.airpods_3_buds
|
||||
budsRes = R.drawable.airpods_pro_2_buds,
|
||||
// leftBudsRes = R.drawable.airpods_3_left
|
||||
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||
// rightBudsRes = R.drawable.airpods_3_right
|
||||
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||
// caseRes = R.drawable.airpods_3_case
|
||||
caseRes = R.drawable.airpods_pro_2_case,
|
||||
capabilities = setOf(
|
||||
Capability.HEAD_GESTURES
|
||||
)
|
||||
)
|
||||
|
||||
class AirPods4: AirPodsBase(
|
||||
modelNumber = listOf("A3053", "A3050", "A3054"),
|
||||
name = "AirPods 4",
|
||||
// budCaseRes = R.drawable.airpods_4
|
||||
budCaseRes = R.drawable.airpods_pro_2,
|
||||
// budsRes = R.drawable.airpods_4_buds
|
||||
budsRes = R.drawable.airpods_pro_2_buds,
|
||||
// leftBudsRes = R.drawable.airpods_4_left
|
||||
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||
// rightBudsRes = R.drawable.airpods_4_right
|
||||
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||
// caseRes = R.drawable.airpods_4_case
|
||||
caseRes = R.drawable.airpods_pro_2_case,
|
||||
capabilities = setOf(
|
||||
Capability.HEAD_GESTURES,
|
||||
Capability.SLEEP_DETECTION,
|
||||
Capability.ADAPTIVE_VOLUME
|
||||
)
|
||||
)
|
||||
|
||||
class AirPods4ANC: AirPodsBase(
|
||||
modelNumber = listOf("A3056", "A3055", "A3057"),
|
||||
name = "AirPods 4 (ANC)",
|
||||
// budCaseRes = R.drawable.airpods_4
|
||||
budCaseRes = R.drawable.airpods_pro_2,
|
||||
// budsRes = R.drawable.airpods_4_buds
|
||||
budsRes = R.drawable.airpods_pro_2_buds,
|
||||
// leftBudsRes = R.drawable.airpods_4_left
|
||||
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||
// rightBudsRes = R.drawable.airpods_4_right
|
||||
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||
// caseRes = R.drawable.airpods_4_case
|
||||
caseRes = R.drawable.airpods_pro_2_case,
|
||||
capabilities = setOf(
|
||||
Capability.LISTENING_MODE,
|
||||
Capability.CONVERSATION_AWARENESS,
|
||||
Capability.HEAD_GESTURES,
|
||||
Capability.ADAPTIVE_AUDIO,
|
||||
Capability.SLEEP_DETECTION,
|
||||
Capability.ADAPTIVE_VOLUME,
|
||||
Capability.STEM_CONFIG
|
||||
)
|
||||
)
|
||||
|
||||
class AirPodsPro1: AirPodsBase(
|
||||
modelNumber = listOf("A2084", "A2083"),
|
||||
name = "AirPods Pro 1",
|
||||
displayName = "AirPods Pro",
|
||||
// budCaseRes = R.drawable.airpods_pro_1
|
||||
budCaseRes = R.drawable.airpods_pro_2,
|
||||
// budsRes = R.drawable.airpods_pro_1_buds
|
||||
budsRes = R.drawable.airpods_pro_2_buds,
|
||||
// leftBudsRes = R.drawable.airpods_pro_1_left
|
||||
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||
// rightBudsRes = R.drawable.airpods_pro_1_right
|
||||
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||
// caseRes = R.drawable.airpods_pro_1_case
|
||||
caseRes = R.drawable.airpods_pro_2_case,
|
||||
capabilities = setOf(
|
||||
Capability.LISTENING_MODE
|
||||
)
|
||||
)
|
||||
|
||||
class AirPodsPro2Lightning: AirPodsBase(
|
||||
modelNumber = listOf("A2931", "A2699", "A2698"),
|
||||
name = "AirPods Pro 2 with Magsafe Charging Case (Lightning)",
|
||||
displayName = "AirPods Pro",
|
||||
// budCaseRes = R.drawable.airpods_pro_2
|
||||
budCaseRes = R.drawable.airpods_pro_2,
|
||||
// budsRes = R.drawable.airpods_pro_2_buds
|
||||
budsRes = R.drawable.airpods_pro_2_buds,
|
||||
// leftBudsRes = R.drawable.airpods_pro_2_left
|
||||
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||
// rightBudsRes = R.drawable.airpods_pro_2_right
|
||||
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||
// caseRes = R.drawable.airpods_pro_2_case
|
||||
caseRes = R.drawable.airpods_pro_2_case,
|
||||
capabilities = setOf(
|
||||
Capability.LISTENING_MODE,
|
||||
Capability.CONVERSATION_AWARENESS,
|
||||
Capability.STEM_CONFIG,
|
||||
Capability.LOUD_SOUND_REDUCTION,
|
||||
Capability.SLEEP_DETECTION,
|
||||
Capability.HEARING_AID,
|
||||
Capability.ADAPTIVE_AUDIO,
|
||||
Capability.ADAPTIVE_VOLUME,
|
||||
Capability.SWIPE_FOR_VOLUME,
|
||||
Capability.HEAD_GESTURES
|
||||
)
|
||||
)
|
||||
|
||||
class AirPodsPro2USBC: AirPodsBase(
|
||||
modelNumber = listOf("A3047", "A3048", "A3049"),
|
||||
name = "AirPods Pro 2 with Magsafe Charging Case (USB-C)",
|
||||
displayName = "AirPods Pro",
|
||||
// budCaseRes = R.drawable.airpods_pro_2
|
||||
budCaseRes = R.drawable.airpods_pro_2,
|
||||
// budsRes = R.drawable.airpods_pro_2_buds
|
||||
budsRes = R.drawable.airpods_pro_2_buds,
|
||||
// leftBudsRes = R.drawable.airpods_pro_2_left
|
||||
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||
// rightBudsRes = R.drawable.airpods_pro_2_right
|
||||
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||
// caseRes = R.drawable.airpods_pro_2_case
|
||||
caseRes = R.drawable.airpods_pro_2_case,
|
||||
capabilities = setOf(
|
||||
Capability.LISTENING_MODE,
|
||||
Capability.CONVERSATION_AWARENESS,
|
||||
Capability.STEM_CONFIG,
|
||||
Capability.LOUD_SOUND_REDUCTION,
|
||||
Capability.SLEEP_DETECTION,
|
||||
Capability.HEARING_AID,
|
||||
Capability.ADAPTIVE_AUDIO,
|
||||
Capability.ADAPTIVE_VOLUME,
|
||||
Capability.SWIPE_FOR_VOLUME,
|
||||
Capability.HEAD_GESTURES
|
||||
)
|
||||
)
|
||||
|
||||
class AirPodsPro3: AirPodsBase(
|
||||
modelNumber = listOf("A3063", "A3064", "A3065"),
|
||||
name = "AirPods Pro 3",
|
||||
displayName = "AirPods Pro",
|
||||
// budCaseRes = R.drawable.airpods_pro_3
|
||||
budCaseRes = R.drawable.airpods_pro_2,
|
||||
// budsRes = R.drawable.airpods_pro_3_buds
|
||||
budsRes = R.drawable.airpods_pro_2_buds,
|
||||
// leftBudsRes = R.drawable.airpods_pro_3_left
|
||||
leftBudsRes = R.drawable.airpods_pro_2_left,
|
||||
// rightBudsRes = R.drawable.airpods_pro_3_right
|
||||
rightBudsRes = R.drawable.airpods_pro_2_right,
|
||||
// caseRes = R.drawable.airpods_pro_3_case
|
||||
caseRes = R.drawable.airpods_pro_2_case,
|
||||
capabilities = setOf(
|
||||
Capability.LISTENING_MODE,
|
||||
Capability.CONVERSATION_AWARENESS,
|
||||
Capability.HEAD_GESTURES,
|
||||
Capability.STEM_CONFIG,
|
||||
Capability.LOUD_SOUND_REDUCTION,
|
||||
Capability.PPE,
|
||||
Capability.SLEEP_DETECTION,
|
||||
Capability.HEARING_AID,
|
||||
Capability.ADAPTIVE_AUDIO,
|
||||
Capability.ADAPTIVE_VOLUME,
|
||||
Capability.SWIPE_FOR_VOLUME,
|
||||
Capability.HRM
|
||||
)
|
||||
)
|
||||
|
||||
data class AirPodsInstance(
|
||||
val name: String,
|
||||
val model: AirPodsBase,
|
||||
val actualModelNumber: String,
|
||||
val serialNumber: String?,
|
||||
val leftSerialNumber: String?,
|
||||
val rightSerialNumber: String?,
|
||||
val version1: String?,
|
||||
val version2: String?,
|
||||
val version3: String?,
|
||||
)
|
||||
|
||||
object AirPodsModels {
|
||||
val models: List<AirPodsBase> = listOf(
|
||||
AirPods(),
|
||||
AirPods2(),
|
||||
AirPods3(),
|
||||
AirPods4(),
|
||||
AirPods4ANC(),
|
||||
AirPodsPro1(),
|
||||
AirPodsPro2Lightning(),
|
||||
AirPodsPro2USBC(),
|
||||
AirPodsPro3()
|
||||
)
|
||||
|
||||
fun getModelByModelNumber(modelNumber: String): AirPodsBase? {
|
||||
return models.find { modelNumber in it.modelNumber }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
LibrePods - AirPods liberated from Apple’s ecosystem
|
||||
Copyright (C) 2025 LibrePods contributors
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.data
|
||||
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager.Companion.ControlCommandIdentifiers
|
||||
|
||||
class ControlCommandRepository(
|
||||
private val aacpManager: AACPManager
|
||||
) {
|
||||
fun getValue(
|
||||
identifier: ControlCommandIdentifiers
|
||||
): ByteArray? {
|
||||
return aacpManager.controlCommandStatusList
|
||||
.find { it.identifier == identifier }
|
||||
?.value
|
||||
}
|
||||
|
||||
fun setValue(
|
||||
id: ControlCommandIdentifiers,
|
||||
value: ByteArray
|
||||
) {
|
||||
aacpManager.sendControlCommand(id.value, value)
|
||||
}
|
||||
|
||||
|
||||
fun observe(
|
||||
identifier: ControlCommandIdentifiers,
|
||||
onChange: (ByteArray) -> Unit
|
||||
): AACPManager.ControlCommandListener {
|
||||
|
||||
val listener = object : AACPManager.ControlCommandListener {
|
||||
override fun onControlCommandReceived(controlCommand: AACPManager.ControlCommand) {
|
||||
onChange(controlCommand.value)
|
||||
}
|
||||
}
|
||||
|
||||
aacpManager.registerControlCommandListener(identifier, listener)
|
||||
return listener
|
||||
}
|
||||
|
||||
fun remove(
|
||||
identifier: ControlCommandIdentifiers,
|
||||
listener: AACPManager.ControlCommandListener
|
||||
) {
|
||||
aacpManager.unregisterControlCommandListener(identifier, listener)
|
||||
}
|
||||
|
||||
fun getMap(): Map<ControlCommandIdentifiers, ByteArray> {
|
||||
return aacpManager.controlCommandStatusList.associate {
|
||||
it.identifier to it.value
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-10
@@ -1,21 +1,18 @@
|
||||
package me.kavishdevar.librepods.bluetooth.aacp.types
|
||||
package me.kavishdevar.librepods.data
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
|
||||
@Serializable
|
||||
data class CustomEq(
|
||||
val state: Int,
|
||||
val low: Int,
|
||||
val mid: Int,
|
||||
val high: Int
|
||||
) {
|
||||
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 toAACPPayload(): ByteArray {
|
||||
fun toPacket(): ByteArray {
|
||||
return byteArrayOf(
|
||||
AACPManager.Companion.Opcodes.CUSTOM_EQ, 0x00,
|
||||
0x05, 0x00, // length (LE)
|
||||
0x01, state.toByte(),
|
||||
low.toByte(), mid.toByte(), high.toByte()
|
||||
+4
-4
@@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.bluetooth.att.types
|
||||
package me.kavishdevar.librepods.data
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.MutableState
|
||||
@@ -25,7 +25,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.bluetooth.att.ATTHandle
|
||||
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||
import java.io.IOException
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
@@ -141,7 +141,7 @@ fun sendHearingAidSettings(
|
||||
currentData: ByteArray,
|
||||
hearingAidSettings: HearingAidSettings,
|
||||
debounceJob: MutableState<Job?>,
|
||||
sender: (ATTHandle, ByteArray) -> Unit
|
||||
sender: (ATTHandles, ByteArray) -> Unit
|
||||
) {
|
||||
debounceJob.value?.cancel()
|
||||
debounceJob.value = CoroutineScope(Dispatchers.IO).launch {
|
||||
@@ -184,7 +184,7 @@ fun sendHearingAidSettings(
|
||||
|
||||
Log.d(TAG, "Sending updated settings: ${currentData.joinToString(" ") { String.format("%02X", it) }}")
|
||||
|
||||
sender(ATTHandle.HEARING_AID, currentData)
|
||||
sender(ATTHandles.HEARING_AID, currentData)
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/*
|
||||
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.data
|
||||
|
||||
import android.os.Parcelable
|
||||
import android.util.Log
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
// TODO: Remove everything but Battery-related stuff
|
||||
|
||||
enum class Enums(val value: ByteArray) {
|
||||
NOISE_CANCELLATION(byteArrayOf(0x0d)),
|
||||
PREFIX(byteArrayOf(0x04, 0x00, 0x04, 0x00)),
|
||||
SETTINGS(byteArrayOf(0x09, 0x00)),
|
||||
NOISE_CANCELLATION_PREFIX(PREFIX.value + SETTINGS.value + NOISE_CANCELLATION.value),
|
||||
CONVERSATION_AWARENESS_RECEIVE_PREFIX(PREFIX.value + byteArrayOf(0x4b, 0x00, 0x02, 0x00)),
|
||||
}
|
||||
|
||||
object BatteryComponent {
|
||||
const val LEFT = 4
|
||||
const val RIGHT = 2
|
||||
const val CASE = 8
|
||||
}
|
||||
|
||||
object BatteryStatus {
|
||||
const val CHARGING = 1
|
||||
const val NOT_CHARGING = 2
|
||||
const val DISCONNECTED = 4
|
||||
const val OPTIMIZED_CHARGING = 5
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class Battery(val component: Int, val level: Int, val status: Int) : Parcelable {
|
||||
fun getComponentName(): String? {
|
||||
return when (component) {
|
||||
BatteryComponent.LEFT -> "LEFT"
|
||||
BatteryComponent.RIGHT -> "RIGHT"
|
||||
BatteryComponent.CASE -> "CASE"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun getStatusName(): String? {
|
||||
return when (status) {
|
||||
BatteryStatus.CHARGING -> "CHARGING"
|
||||
BatteryStatus.NOT_CHARGING -> "NOT_CHARGING"
|
||||
BatteryStatus.DISCONNECTED -> "DISCONNECTED"
|
||||
BatteryStatus.OPTIMIZED_CHARGING -> "OPTIMIZED_CHARGING"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class NoiseControlMode {
|
||||
OFF, NOISE_CANCELLATION, TRANSPARENCY, ADAPTIVE
|
||||
}
|
||||
|
||||
class AirPodsNotifications {
|
||||
companion object {
|
||||
const val AIRPODS_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED"
|
||||
const val AIRPODS_L2CAP_CONNECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTED"
|
||||
const val AIRPODS_DATA = "me.kavishdevar.librepods.AIRPODS_DATA"
|
||||
const val EAR_DETECTION_DATA = "me.kavishdevar.librepods.EAR_DETECTION_DATA"
|
||||
const val ANC_DATA = "me.kavishdevar.librepods.ANC_DATA"
|
||||
const val BATTERY_DATA = "me.kavishdevar.librepods.BATTERY_DATA"
|
||||
const val CA_DATA = "me.kavishdevar.librepods.CA_DATA"
|
||||
const val AIRPODS_DISCONNECTED = "me.kavishdevar.librepods.AIRPODS_DISCONNECTED"
|
||||
const val AIRPODS_CONNECTION_DETECTED = "me.kavishdevar.librepods.AIRPODS_CONNECTION_DETECTED"
|
||||
const val DISCONNECT_RECEIVERS = "me.kavishdevar.librepods.DISCONNECT_RECEIVERS"
|
||||
const val EQ_DATA = "me.kavishdevar.librepods.HEADPHONE_ACCOMMODATION"
|
||||
const val AIRPODS_INFORMATION_UPDATED = "me.kavishdevar.librepods.AIRPODS_INFORMATION_UPDATED"
|
||||
}
|
||||
|
||||
class EarDetection {
|
||||
private val notificationBit = 6.toByte()
|
||||
private val notificationPrefix = Enums.PREFIX.value + notificationBit
|
||||
|
||||
var status: List<Byte> = listOf(0x01, 0x01)
|
||||
|
||||
fun setStatus(data: ByteArray) {
|
||||
status = listOf(data[6], data[7])
|
||||
}
|
||||
|
||||
fun isEarDetectionData(data: ByteArray): Boolean {
|
||||
if (data.size != 8) {
|
||||
return false
|
||||
}
|
||||
val prefixHex = notificationPrefix.joinToString("") { "%02x".format(it) }
|
||||
val dataHex = data.joinToString("") { "%02x".format(it) }
|
||||
return dataHex.startsWith(prefixHex)
|
||||
}
|
||||
}
|
||||
|
||||
class ANC {
|
||||
private val notificationPrefix = Enums.NOISE_CANCELLATION_PREFIX.value
|
||||
|
||||
var status: Int = 1
|
||||
private set
|
||||
|
||||
fun isANCData(data: ByteArray): Boolean {
|
||||
if (data.size != 11) {
|
||||
return false
|
||||
}
|
||||
val prefixHex = notificationPrefix.joinToString("") { "%02x".format(it) }
|
||||
val dataHex = data.joinToString("") { "%02x".format(it) }
|
||||
return dataHex.startsWith(prefixHex)
|
||||
}
|
||||
|
||||
fun setStatus(data: ByteArray) {
|
||||
when (data.size) {
|
||||
// if the whole packet is given
|
||||
11 -> {
|
||||
status = data[7].toInt()
|
||||
}
|
||||
// if only the data is given
|
||||
1 -> {
|
||||
status = data[0].toInt()
|
||||
}
|
||||
// if the value of control command is given
|
||||
4 -> {
|
||||
status = data[0].toInt()
|
||||
}
|
||||
else -> {
|
||||
Log.d("ANC", "Invalid ANC data size: ${data.size}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val name: String =
|
||||
when (status) {
|
||||
1 -> "OFF"
|
||||
2 -> "ON"
|
||||
3 -> "TRANSPARENCY"
|
||||
4 -> "ADAPTIVE"
|
||||
else -> "UNKNOWN"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class BatteryNotification {
|
||||
private var first: Battery = Battery(BatteryComponent.LEFT, 0, BatteryStatus.DISCONNECTED)
|
||||
private var second: Battery = Battery(BatteryComponent.RIGHT, 0, BatteryStatus.DISCONNECTED)
|
||||
private var case: Battery = Battery(BatteryComponent.CASE, 0, BatteryStatus.DISCONNECTED)
|
||||
|
||||
fun isBatteryData(data: ByteArray): Boolean {
|
||||
if (data.joinToString("") { "%02x".format(it) }.startsWith("040004000400")) {
|
||||
Log.d("BatteryNotification", "Battery data starts with 040004000400. Most likely is a battery packet.")
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
if (data.size != 22) {
|
||||
Log.d("BatteryNotification", "Battery data size is not 22, probably being used with Airpods with fewer or more battery count.")
|
||||
return false
|
||||
}
|
||||
Log.d("BatteryNotification", data.joinToString("") { "%02x".format(it) }.startsWith("040004000400").toString())
|
||||
return data.joinToString("") { "%02x".format(it) }.startsWith("040004000400")
|
||||
}
|
||||
|
||||
fun setBatteryDirect(
|
||||
leftLevel: Int,
|
||||
leftCharging: Boolean,
|
||||
rightLevel: Int,
|
||||
rightCharging: Boolean,
|
||||
caseLevel: Int,
|
||||
caseCharging: Boolean
|
||||
) {
|
||||
first = Battery(BatteryComponent.LEFT, leftLevel, if (leftCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING)
|
||||
second = Battery(BatteryComponent.RIGHT, rightLevel, if (rightCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING)
|
||||
case = Battery(BatteryComponent.CASE, caseLevel, if (caseCharging) BatteryStatus.CHARGING else BatteryStatus.NOT_CHARGING)
|
||||
}
|
||||
|
||||
fun setBattery(data: ByteArray) {
|
||||
if (data.size != 22) {
|
||||
return
|
||||
}
|
||||
// first = if (data[10].toInt() == BatteryStatus.DISCONNECTED) {
|
||||
// Battery(first.component, first.level, data[10].toInt())
|
||||
// } else {
|
||||
// Battery(data[7].toInt(), data[9].toInt(), data[10].toInt())
|
||||
// }
|
||||
// second = if (data[15].toInt() == BatteryStatus.DISCONNECTED) {
|
||||
// Battery(second.component, second.level, data[15].toInt())
|
||||
// } else {
|
||||
// Battery(data[12].toInt(), data[14].toInt(), data[15].toInt())
|
||||
// }
|
||||
// case = if (data[20].toInt() == BatteryStatus.DISCONNECTED && case.status != BatteryStatus.DISCONNECTED) {
|
||||
// Battery(case.component, case.level, data[20].toInt())
|
||||
// } else {
|
||||
// Battery(data[17].toInt(), data[19].toInt(), data[20].toInt())
|
||||
// }
|
||||
// sometimes it shows battery as -1%, just skip all that and set it normally
|
||||
first = Battery(
|
||||
data[7].toInt(), data[9].toInt(), data[10].toInt()
|
||||
)
|
||||
second = Battery(
|
||||
data[12].toInt(), data[14].toInt(), data[15].toInt()
|
||||
)
|
||||
case = Battery(
|
||||
data[17].toInt(), data[19].toInt(), data[20].toInt()
|
||||
)
|
||||
}
|
||||
|
||||
fun getBattery(): List<Battery> {
|
||||
val left = if (first.component == BatteryComponent.LEFT) first else second
|
||||
val right = if (first.component == BatteryComponent.LEFT) second else first
|
||||
return listOf(left, right, case)
|
||||
}
|
||||
}
|
||||
|
||||
class ConversationalAwarenessNotification {
|
||||
@Suppress("PrivatePropertyName")
|
||||
private val NOTIFICATION_PREFIX = Enums.CONVERSATION_AWARENESS_RECEIVE_PREFIX.value
|
||||
|
||||
var status: Byte = 0
|
||||
private set
|
||||
|
||||
fun isConversationalAwarenessData(data: ByteArray): Boolean {
|
||||
if (data.size != 10) {
|
||||
return false
|
||||
}
|
||||
val prefixHex = NOTIFICATION_PREFIX.joinToString("") { "%02x".format(it) }
|
||||
val dataHex = data.joinToString("") { "%02x".format(it) }
|
||||
return dataHex.startsWith(prefixHex)
|
||||
}
|
||||
|
||||
fun setData(data: ByteArray) {
|
||||
status = data[9]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isHeadTrackingData(data: ByteArray): Boolean {
|
||||
if (data.size <= 60) return false
|
||||
|
||||
val prefixPattern = byteArrayOf(
|
||||
0x04, 0x00, 0x04, 0x00, 0x17, 0x00, 0x00, 0x00,
|
||||
0x10, 0x00
|
||||
)
|
||||
|
||||
for (i in prefixPattern.indices) {
|
||||
if (data[i] != prefixPattern[i]) return false
|
||||
}
|
||||
|
||||
if (data[10] != 0x44.toByte() && data[10] != 0x45.toByte()) return false
|
||||
|
||||
if (data[11] != 0x00.toByte()) return false
|
||||
|
||||
return true
|
||||
}
|
||||
+6
-8
@@ -18,10 +18,8 @@
|
||||
|
||||
package me.kavishdevar.librepods.data
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.StemPressType
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
|
||||
@Serializable
|
||||
enum class StemAction {
|
||||
PLAY_PAUSE,
|
||||
PREVIOUS_TRACK,
|
||||
@@ -32,11 +30,11 @@ enum class StemAction {
|
||||
fun fromString(action: String): StemAction? {
|
||||
return entries.find { it.name == action }
|
||||
}
|
||||
val defaultActions: Map<StemPressType, StemAction> = mapOf(
|
||||
StemPressType.SINGLE_PRESS to PLAY_PAUSE,
|
||||
StemPressType.DOUBLE_PRESS to NEXT_TRACK,
|
||||
StemPressType.TRIPLE_PRESS to PREVIOUS_TRACK,
|
||||
StemPressType.LONG_PRESS to CYCLE_NOISE_CONTROL_MODES,
|
||||
val defaultActions: Map<AACPManager.Companion.StemPressType, StemAction> = mapOf(
|
||||
AACPManager.Companion.StemPressType.SINGLE_PRESS to PLAY_PAUSE,
|
||||
AACPManager.Companion.StemPressType.DOUBLE_PRESS to NEXT_TRACK,
|
||||
AACPManager.Companion.StemPressType.TRIPLE_PRESS to PREVIOUS_TRACK,
|
||||
AACPManager.Companion.StemPressType.LONG_PRESS to CYCLE_NOISE_CONTROL_MODES,
|
||||
)
|
||||
}
|
||||
}
|
||||
+5
-6
@@ -16,18 +16,17 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.bluetooth.att.types
|
||||
package me.kavishdevar.librepods.data
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.bluetooth.att.ATTHandle
|
||||
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||
import java.io.IOException
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
data class TransparencySettings(
|
||||
val enabled: Boolean,
|
||||
@@ -142,10 +141,10 @@ fun parseTransparencySettingsResponse(data: ByteArray): TransparencySettings? {
|
||||
|
||||
private var debounceJob: Job? = null
|
||||
|
||||
fun sendTransparencySettings(writer: (ATTHandle, ByteArray) -> Unit, transparencySettings: TransparencySettings) {
|
||||
fun sendTransparencySettings(writer: (ATTHandles, ByteArray) -> Unit, transparencySettings: TransparencySettings) {
|
||||
debounceJob?.cancel()
|
||||
debounceJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
delay(100.milliseconds)
|
||||
delay(100)
|
||||
try {
|
||||
val buffer = ByteBuffer.allocate(
|
||||
if (transparencySettings.ownVoiceAmplification != null) 104 else 100
|
||||
@@ -174,7 +173,7 @@ fun sendTransparencySettings(writer: (ATTHandle, ByteArray) -> Unit, transparenc
|
||||
}
|
||||
|
||||
val data = buffer.array()
|
||||
writer(ATTHandle.TRANSPARENCY, data)
|
||||
writer(ATTHandles.TRANSPARENCY, data)
|
||||
} catch (e: IOException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package me.kavishdevar.librepods.data.xposed
|
||||
package me.kavishdevar.librepods.data
|
||||
|
||||
interface XposedRemotePref {
|
||||
fun isAvailable(): Boolean
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package me.kavishdevar.librepods.data.xposed
|
||||
package me.kavishdevar.librepods.data
|
||||
|
||||
import androidx.core.content.edit
|
||||
import me.kavishdevar.librepods.utils.XposedServiceHolder
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package me.kavishdevar.librepods.data.xposed
|
||||
package me.kavishdevar.librepods.data
|
||||
|
||||
object XposedRemotePrefProvider {
|
||||
fun create(): XposedRemotePref = XposedRemotePrefImpl()
|
||||
+4
-4
@@ -2,9 +2,9 @@ package me.kavishdevar.librepods.data.updates
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.screens.apple.AppleSettingsScreenPreviewMaterial
|
||||
import me.kavishdevar.librepods.presentation.screens.apple.EqualizerScreenPreviewApple
|
||||
import me.kavishdevar.librepods.presentation.screens.apple.EqualizerScreenPreviewMaterial
|
||||
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
|
||||
|
||||
@@ -13,7 +13,7 @@ val update1_0_0 = listOf(
|
||||
titleRes = R.string.material3e,
|
||||
descriptionRes = R.string.update_m3e_description,
|
||||
demoComposeable = @Composable {
|
||||
AppleSettingsScreenPreviewMaterial()
|
||||
AirPodsSettingsScreenPreviewMaterial()
|
||||
}
|
||||
),
|
||||
UpdateItem(
|
||||
@@ -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
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun AboutCard(
|
||||
modelName: String,
|
||||
actualModel: String,
|
||||
serialNumbers: List<String>,
|
||||
version: String?,
|
||||
navigateToVersion: () -> Unit
|
||||
) {
|
||||
val serialNumbers = when (LocalDesignSystem.current) {
|
||||
DesignSystem.Apple -> listOf(
|
||||
serialNumbers[0],
|
||||
" ${serialNumbers[1]}",
|
||||
" ${serialNumbers[2]}"
|
||||
)
|
||||
|
||||
DesignSystem.Material -> listOf(
|
||||
serialNumbers[0],
|
||||
stringResource(R.string.left) + " " + serialNumbers[1],
|
||||
stringResource(R.string.right) + " " + serialNumbers[2],
|
||||
)
|
||||
}
|
||||
|
||||
val serialNumber = remember { mutableIntStateOf(0) }
|
||||
|
||||
StyledList (title = stringResource(R.string.about)) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.model_name),
|
||||
description = modelName
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.model_number),
|
||||
description = actualModel
|
||||
)
|
||||
|
||||
StyledListItem (
|
||||
name = stringResource(R.string.serial_number),
|
||||
description = serialNumbers[serialNumber.intValue],
|
||||
onClick = { serialNumber.intValue = (serialNumber.intValue + 1) % serialNumbers.size }
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.version),
|
||||
description = version,
|
||||
onClick = navigateToVersion,
|
||||
)
|
||||
}
|
||||
}
|
||||
+9
-11
@@ -16,14 +16,12 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components.common
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import me.kavishdevar.librepods.BuildConfig
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.components.primitives.StyledList
|
||||
import me.kavishdevar.librepods.presentation.components.primitives.StyledListItem
|
||||
|
||||
@Composable
|
||||
fun AppInfoCard(
|
||||
@@ -31,24 +29,24 @@ fun AppInfoCard(
|
||||
) {
|
||||
StyledList(title = stringResource(R.string.about)) {
|
||||
StyledListItem(
|
||||
contentText = stringResource(R.string.version),
|
||||
supportingText = BuildConfig.VERSION_NAME,
|
||||
name = stringResource(R.string.version),
|
||||
description = BuildConfig.VERSION_NAME,
|
||||
onClick = navigateToReleaseNotesScreen
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
contentText = stringResource(R.string.version_code),
|
||||
supportingText = BuildConfig.VERSION_CODE.toString(),
|
||||
name = stringResource(R.string.version_code),
|
||||
description = BuildConfig.VERSION_CODE.toString(),
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
contentText = stringResource(R.string.flavor),
|
||||
supportingText = BuildConfig.FLAVOR,
|
||||
name = stringResource(R.string.flavor),
|
||||
description = BuildConfig.FLAVOR,
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
contentText = stringResource(R.string.build_type),
|
||||
supportingText = BuildConfig.BUILD_TYPE,
|
||||
name = stringResource(R.string.build_type),
|
||||
description = BuildConfig.BUILD_TYPE,
|
||||
)
|
||||
}
|
||||
}
|
||||
+12
-6
@@ -18,14 +18,11 @@
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components.apple
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.components.primitives.StyledList
|
||||
import me.kavishdevar.librepods.presentation.components.primitives.StyledListItem
|
||||
import me.kavishdevar.librepods.presentation.components.primitives.StyledToggle
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
@@ -33,6 +30,7 @@ fun AudioSettings(
|
||||
adaptiveVolumeCapability: Boolean,
|
||||
conversationalAwarenessCapability: Boolean,
|
||||
loudSoundReductionCapability: Boolean,
|
||||
adaptiveAudioCapability: Boolean,
|
||||
customEqCapability: Boolean,
|
||||
|
||||
adaptiveVolumeChecked: Boolean,
|
||||
@@ -44,12 +42,13 @@ fun AudioSettings(
|
||||
loudSoundReductionChecked: Boolean,
|
||||
onLoudSoundReductionCheckedChange: (Boolean) -> Unit,
|
||||
|
||||
navigateToAdaptiveStrength: () -> Unit,
|
||||
navigateToEqualizer: () -> Unit,
|
||||
|
||||
vendorIdHook: Boolean,
|
||||
isPremium: Boolean
|
||||
) {
|
||||
if (adaptiveVolumeCapability || conversationalAwarenessCapability || loudSoundReductionCapability) {
|
||||
if (adaptiveVolumeCapability || conversationalAwarenessCapability || loudSoundReductionCapability || adaptiveAudioCapability) {
|
||||
StyledList(title = stringResource(R.string.audio)) {
|
||||
if (adaptiveVolumeCapability) {
|
||||
StyledToggle(
|
||||
@@ -81,9 +80,16 @@ fun AudioSettings(
|
||||
)
|
||||
}
|
||||
|
||||
if (adaptiveAudioCapability) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.adaptive_audio),
|
||||
onClick = navigateToAdaptiveStrength,
|
||||
)
|
||||
}
|
||||
|
||||
if (customEqCapability) {
|
||||
StyledListItem(
|
||||
contentText = stringResource(R.string.equalizer),
|
||||
name = stringResource(R.string.equalizer),
|
||||
onClick = navigateToEqualizer,
|
||||
)
|
||||
}
|
||||
+32
-37
@@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components.apple
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
|
||||
import android.content.res.Configuration
|
||||
@@ -24,14 +24,13 @@ import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -45,15 +44,16 @@ import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.librepods.devices.BatteryStatus
|
||||
import me.kavishdevar.librepods.presentation.icons.LocalIcons
|
||||
import me.kavishdevar.librepods.presentation.icons.richText
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.data.BatteryStatus
|
||||
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.min
|
||||
@@ -63,11 +63,12 @@ import kotlin.math.sqrt
|
||||
@Composable
|
||||
fun BatteryIndicator(
|
||||
batteryPercentage: Int,
|
||||
status: BatteryStatus,
|
||||
imageVectorName: String? = null,
|
||||
status: Int,
|
||||
prefix: String = "",
|
||||
previousCharging: Boolean = false,
|
||||
) {
|
||||
val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val batteryTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val batteryFillColor =
|
||||
if (batteryPercentage > 25) if (isDarkTheme) Color(0xFF2ED158) else Color(0xFF35C759)
|
||||
else if (isDarkTheme) Color(0xFFFC4244) else Color(0xFFfe373C)
|
||||
@@ -91,7 +92,7 @@ fun BatteryIndicator(
|
||||
val strokeWidthPx = with(LocalDensity.current) { 4.dp.toPx() }
|
||||
val gapFromCenterPx = with(LocalDensity.current) { 8.sp.toPx() }
|
||||
|
||||
val trackColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
val trackColor = if (isDarkTheme) Color(0xFF272728) else Color(0xFFE3E3E8)
|
||||
val optimizedLimit = 0.8f
|
||||
val progress = batteryPercentage / 100f
|
||||
|
||||
@@ -173,45 +174,39 @@ fun BatteryIndicator(
|
||||
}
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = LocalIcons.current.Bolt,
|
||||
contentDescription = null,
|
||||
tint = batteryFillColor,
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.scale(scaleAnim.value)
|
||||
Text(
|
||||
text = "\uDBC0\uDEE6", style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = batteryFillColor,
|
||||
textAlign = TextAlign.Center
|
||||
), modifier = Modifier.scale(scaleAnim.value)
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Row {
|
||||
val richText = if (imageVectorName != null) {
|
||||
richText("\\icon{$imageVectorName,onSurface} $batteryPercentage%")
|
||||
} else {
|
||||
richText("$batteryPercentage%")
|
||||
}
|
||||
|
||||
Text(
|
||||
text = richText.text,
|
||||
inlineContent = richText.inlineContent,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "$prefix $batteryPercentage%",
|
||||
color = batteryTextColor,
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun BatteryIndicatorPreview() {
|
||||
LibrePodsTheme(designSystem = DesignSystem.Material) {
|
||||
LibrePodsTheme(m3eEnabled = false) {
|
||||
BatteryIndicator(
|
||||
batteryPercentage = 50,
|
||||
status = BatteryStatus.OPTIMIZED_CHARGING,
|
||||
previousCharging = false,
|
||||
imageVectorName = "LeftCircleFill"
|
||||
prefix = "\uDBC6\uDCE5",
|
||||
previousCharging = false
|
||||
)
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
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 android.content.res.Configuration
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.res.imageResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.data.Battery
|
||||
import me.kavishdevar.librepods.data.BatteryComponent
|
||||
import me.kavishdevar.librepods.data.BatteryStatus
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun BatteryView(
|
||||
batteryList: List<Battery>,
|
||||
budsRes: Int,
|
||||
caseRes: Int
|
||||
) {
|
||||
val left = batteryList.find { it.component == BatteryComponent.LEFT }
|
||||
val right = batteryList.find { it.component == BatteryComponent.RIGHT }
|
||||
val case = batteryList.find { it.component == BatteryComponent.CASE }
|
||||
|
||||
val leftLevel = left?.level ?: 0
|
||||
val rightLevel = right?.level ?: 0
|
||||
val caseLevel = case?.level ?: 0
|
||||
|
||||
val singleDisplayed = remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.widthIn(max = 500.dp),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Image(
|
||||
bitmap = ImageBitmap.imageResource(budsRes),
|
||||
contentDescription = stringResource(R.string.buds),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
)
|
||||
|
||||
if (
|
||||
left?.status == right?.status &&
|
||||
(leftLevel - rightLevel) in -3..3
|
||||
) {
|
||||
BatteryIndicator(
|
||||
leftLevel.coerceAtMost(rightLevel),
|
||||
left?.status ?: BatteryStatus.NOT_CHARGING
|
||||
)
|
||||
singleDisplayed.value = true
|
||||
} else {
|
||||
singleDisplayed.value = false
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
if (leftLevel > 0 || left?.status != BatteryStatus.DISCONNECTED) {
|
||||
BatteryIndicator(
|
||||
leftLevel,
|
||||
left?.status ?: BatteryStatus.NOT_CHARGING,
|
||||
"\uDBC6\uDCE5"
|
||||
)
|
||||
}
|
||||
|
||||
if (leftLevel > 0 && rightLevel > 0) {
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
}
|
||||
|
||||
if (rightLevel > 0 || right?.status != BatteryStatus.DISCONNECTED) {
|
||||
BatteryIndicator(
|
||||
rightLevel,
|
||||
right?.status ?: BatteryStatus.NOT_CHARGING,
|
||||
"\uDBC6\uDCE8"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Image(
|
||||
bitmap = ImageBitmap.imageResource(caseRes),
|
||||
contentDescription = stringResource(R.string.case_alt),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
)
|
||||
|
||||
if (caseLevel > 0 || case?.status != BatteryStatus.DISCONNECTED) {
|
||||
BatteryIndicator(
|
||||
caseLevel,
|
||||
case?.status ?: BatteryStatus.NOT_CHARGING,
|
||||
prefix = if (!singleDisplayed.value) "\uDBC3\uDE6C" else ""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun BatteryViewPreview() {
|
||||
val fakeBattery = listOf(
|
||||
Battery(BatteryComponent.LEFT, 85, BatteryStatus.CHARGING),
|
||||
Battery(BatteryComponent.RIGHT, 40, BatteryStatus.OPTIMIZED_CHARGING),
|
||||
Battery(BatteryComponent.CASE, 60, BatteryStatus.NOT_CHARGING)
|
||||
)
|
||||
|
||||
val bg = if (isSystemInDarkTheme()) Color.Black else Color(0xFFF2F2F7)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(bg)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
BatteryView(
|
||||
batteryList = fakeBattery,
|
||||
budsRes = R.drawable.airpods_pro_2_buds,
|
||||
caseRes = R.drawable.airpods_pro_2_case
|
||||
)
|
||||
}
|
||||
}
|
||||
+10
-12
@@ -18,7 +18,7 @@
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components.apple
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -28,8 +28,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.components.primitives.StyledList
|
||||
import me.kavishdevar.librepods.presentation.components.primitives.StyledListItem
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@@ -49,25 +47,25 @@ fun CallControlSettings(
|
||||
|
||||
StyledList(title = stringResource(R.string.call_controls)) {
|
||||
StyledListItem(
|
||||
contentText = stringResource(R.string.answer_call),
|
||||
supportingText = stringResource(R.string.press_once),
|
||||
name = stringResource(R.string.answer_call),
|
||||
description = stringResource(R.string.press_once),
|
||||
enabled = false
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
contentText = muteUnmuteText,
|
||||
supportingText = singlePressAction,
|
||||
onClick = { navigateToCallControlScreen(muteUnmuteText) },
|
||||
name = muteUnmuteText,
|
||||
description = singlePressAction,
|
||||
onClick = { navigateToCallControlScreen(muteUnmuteText) } ,
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
contentText = hangUpText,
|
||||
supportingText = doublePressAction,
|
||||
name = hangUpText,
|
||||
description = doublePressAction,
|
||||
onClick = { navigateToCallControlScreen(hangUpText) }
|
||||
)
|
||||
|
||||
// StyledListItem(
|
||||
// contentText = pressOnceText,
|
||||
// name = pressOnceText,
|
||||
// selected = doublePressAction == pressOnceText,
|
||||
// onClick = {
|
||||
// doublePressAction = pressOnceText
|
||||
@@ -78,7 +76,7 @@ fun CallControlSettings(
|
||||
// )
|
||||
//
|
||||
// StyledListItem(
|
||||
// contentText = pressTwiceText,
|
||||
// name = pressTwiceText,
|
||||
// selected = doublePressAction == pressTwiceText,
|
||||
// onClick = {
|
||||
// doublePressAction = pressTwiceText
|
||||
+13
-7
@@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components.primitives
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
@@ -25,6 +25,7 @@ import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -47,9 +48,13 @@ 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.luminance
|
||||
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.compose.ui.window.DialogProperties
|
||||
import com.kyant.backdrop.backdrops.LayerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
@@ -57,12 +62,15 @@ import com.kyant.backdrop.drawBackdrop
|
||||
import com.kyant.backdrop.effects.blur
|
||||
import com.kyant.backdrop.effects.lens
|
||||
import com.kyant.backdrop.effects.vibrancy
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@Composable
|
||||
fun StyledConfirmationDialog(
|
||||
fun ConfirmationDialog(
|
||||
showDialog: MutableState<Boolean>,
|
||||
title: String,
|
||||
message: String,
|
||||
@@ -84,9 +92,7 @@ fun StyledConfirmationDialog(
|
||||
properties = DialogProperties(
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = false
|
||||
),
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest, RoundedCornerShape(28.dp))
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
@@ -134,7 +140,7 @@ fun StyledConfirmationDialog(
|
||||
.clickable(enabled = false, onClick = {}),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.requiredWidthIn(min = 200.dp, max = 360.dp)
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
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.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import me.kavishdevar.librepods.R
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
fun ConnectionSettings(
|
||||
automaticEarDetectionEnabled: Boolean,
|
||||
onAutomaticEarDetectionChanged: (Boolean) -> Unit,
|
||||
automaticConnectionEnabled: Boolean,
|
||||
onAutomaticConnectionChanged: (Boolean) -> Unit,
|
||||
) {
|
||||
StyledList {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.ear_detection),
|
||||
checked = automaticEarDetectionEnabled,
|
||||
onCheckedChange = onAutomaticEarDetectionChanged
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.automatically_connect),
|
||||
description = stringResource(R.string.automatically_connect_description),
|
||||
checked = automaticConnectionEnabled,
|
||||
onCheckedChange = onAutomaticConnectionChanged
|
||||
)
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
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:Suppress("unused")
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
private val SelectedColorBlue = Color(0xFF0A84FF)
|
||||
private val UnselectedColor = Color(0x593C3C3E)
|
||||
private val TextColor = Color.White
|
||||
private val IconTint = Color.White
|
||||
|
||||
@Composable
|
||||
fun ControlCenterButton(
|
||||
label: String,
|
||||
icon: Painter,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
iconAreaSize: Dp,
|
||||
isSelected: Boolean,
|
||||
backgroundBrush: Brush? = null
|
||||
) {
|
||||
val targetBackgroundColor = if (isSelected) SelectedColorBlue else UnselectedColor
|
||||
val backgroundColor by animateColorAsState(
|
||||
targetValue = targetBackgroundColor,
|
||||
label = "ButtonBackground"
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(iconAreaSize)
|
||||
.clip(CircleShape)
|
||||
.background(backgroundBrush ?: Brush.linearGradient(colors=listOf(backgroundColor, backgroundColor)))
|
||||
.clickable(
|
||||
onClick = onClick,
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = icon,
|
||||
contentDescription = null,
|
||||
tint = IconTint,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = label,
|
||||
color = TextColor,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
}
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
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.core.Spring
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.data.NoiseControlMode
|
||||
|
||||
private val ContainerColor = Color(0x593C3C3E)
|
||||
private val SelectedIndicatorColorGray = Color(0xFF6C6C6E)
|
||||
private val SelectedIndicatorColorBlue = Color(0xFF0A84FF)
|
||||
private val TextColor = Color.White
|
||||
private val IconTintUnselected = Color.White
|
||||
private val IconTintSelected = Color.White
|
||||
|
||||
internal val AdaptiveRainbowBrush = Brush.sweepGradient(
|
||||
colors = listOf(
|
||||
Color(0xFFB03A2F), Color(0xFFB07A2F), Color(0xFFB0A22F), Color(0xFF6AB02F),
|
||||
Color(0xFF2FAAB0), Color(0xFF2F5EB0), Color(0xFF7D2FB0), Color(0xFFB02F7D),
|
||||
Color(0xFFB03A2F)
|
||||
)
|
||||
)
|
||||
|
||||
internal val IconAreaSize = 72.dp
|
||||
private val IconSize = 42.dp
|
||||
private val IconRowHeight = IconAreaSize + 12.dp
|
||||
private val TextRowHeight = 24.dp
|
||||
private val TextSize = 12.sp
|
||||
|
||||
@Composable
|
||||
fun ControlCenterNoiseControlSegmentedButton(
|
||||
modifier: Modifier = Modifier,
|
||||
availableModes: List<NoiseControlMode>,
|
||||
selectedMode: NoiseControlMode,
|
||||
onModeSelected: (NoiseControlMode) -> Unit
|
||||
) {
|
||||
val selectedIndex = availableModes.indexOf(selectedMode).coerceAtLeast(0)
|
||||
val density = LocalDensity.current
|
||||
var iconRowWidthPx by remember { mutableFloatStateOf(0f) }
|
||||
val itemCount = availableModes.size
|
||||
|
||||
val itemSlotWidthPx = remember(iconRowWidthPx, itemCount) {
|
||||
if (itemCount > 0 && iconRowWidthPx > 0) {
|
||||
iconRowWidthPx / itemCount
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
}
|
||||
val itemSlotWidthDp = remember(itemSlotWidthPx) { with(density) { itemSlotWidthPx.toDp() } }
|
||||
val iconAreaSizePx = remember { with(density) { IconAreaSize.toPx() } }
|
||||
|
||||
val targetIndicatorStartPx = remember(selectedIndex, itemSlotWidthPx, iconAreaSizePx) {
|
||||
if (itemSlotWidthPx > 0) {
|
||||
val slotCenterPx = (selectedIndex + 0.5f) * itemSlotWidthPx
|
||||
slotCenterPx - (iconAreaSizePx / 2f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
}
|
||||
|
||||
val indicatorOffset: Dp by animateDpAsState(
|
||||
targetValue = with(density) { targetIndicatorStartPx.toDp() },
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessMedium
|
||||
),
|
||||
label = "IndicatorOffset"
|
||||
)
|
||||
|
||||
val indicatorBackground = remember(selectedMode) {
|
||||
when (selectedMode) {
|
||||
NoiseControlMode.ADAPTIVE -> AdaptiveRainbowBrush
|
||||
NoiseControlMode.OFF -> Brush.linearGradient(colors=listOf(SelectedIndicatorColorGray, SelectedIndicatorColorGray))
|
||||
NoiseControlMode.TRANSPARENCY,
|
||||
NoiseControlMode.NOISE_CANCELLATION -> Brush.linearGradient(colors=listOf(SelectedIndicatorColorBlue, SelectedIndicatorColorBlue))
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(IconRowHeight)
|
||||
.clip(CircleShape)
|
||||
.background(ContainerColor)
|
||||
.onSizeChanged { iconRowWidthPx = it.width.toFloat() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.offset(x = indicatorOffset)
|
||||
.size(IconAreaSize)
|
||||
.clip(CircleShape)
|
||||
.background(indicatorBackground)
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().align(Alignment.Center),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceAround
|
||||
) {
|
||||
availableModes.forEach { mode ->
|
||||
val isSelected = selectedMode == mode
|
||||
NoiseControlIconItem(
|
||||
modifier = Modifier.size(IconAreaSize),
|
||||
mode = mode,
|
||||
isSelected = isSelected,
|
||||
onClick = { onModeSelected(mode) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(TextRowHeight),
|
||||
horizontalArrangement = Arrangement.SpaceAround,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
availableModes.forEach { mode ->
|
||||
val isSelected = selectedMode == mode
|
||||
Text(
|
||||
text = getModeLabel(mode),
|
||||
color = TextColor,
|
||||
fontSize = TextSize,
|
||||
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.width(itemSlotWidthDp.coerceAtLeast(1.dp))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoiseControlIconItem(
|
||||
modifier: Modifier = Modifier,
|
||||
mode: NoiseControlMode,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val iconRes = remember(mode) { getModeIconRes(mode) }
|
||||
|
||||
val tint = IconTintUnselected
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(CircleShape)
|
||||
.clickable(
|
||||
onClick = onClick,
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = iconRes),
|
||||
contentDescription = getModeLabel(mode),
|
||||
tint = if (isSelected && mode == NoiseControlMode.ADAPTIVE) IconTintSelected else tint,
|
||||
modifier = Modifier.size(IconSize)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun getModeIconRes(mode: NoiseControlMode): Int {
|
||||
return when (mode) {
|
||||
NoiseControlMode.OFF -> R.drawable.noise_cancellation
|
||||
NoiseControlMode.TRANSPARENCY -> R.drawable.transparency
|
||||
NoiseControlMode.ADAPTIVE -> R.drawable.adaptive
|
||||
NoiseControlMode.NOISE_CANCELLATION -> R.drawable.noise_cancellation
|
||||
}
|
||||
}
|
||||
|
||||
private fun getModeLabel(mode: NoiseControlMode): String {
|
||||
return when (mode) {
|
||||
NoiseControlMode.OFF -> "Off"
|
||||
NoiseControlMode.TRANSPARENCY -> "Transparency"
|
||||
NoiseControlMode.ADAPTIVE -> "Adaptive"
|
||||
NoiseControlMode.NOISE_CANCELLATION -> "Noise Cancellation"
|
||||
}
|
||||
}
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.utils.XposedState
|
||||
|
||||
@Composable
|
||||
fun DeviceInfoCard() {
|
||||
StyledList(title = stringResource(R.string.device_info)) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.manufacturer),
|
||||
description = Build.MANUFACTURER,
|
||||
enabled = false
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.model_number),
|
||||
description = Build.MODEL,
|
||||
enabled = false
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.build_id),
|
||||
description = Build.DISPLAY,
|
||||
enabled = false
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.version),
|
||||
description = "${Build.ID} (${Build.VERSION.SDK_INT_FULL})",
|
||||
enabled = false
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.xposed_available),
|
||||
description = if (XposedState.isAvailable) {
|
||||
stringResource(R.string.yes)
|
||||
} else {
|
||||
stringResource(R.string.no)
|
||||
},
|
||||
enabled = false
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.app_enabled_in_xposed),
|
||||
description = if (XposedState.bluetoothScopeEnabled) {
|
||||
stringResource(R.string.yes)
|
||||
} else {
|
||||
stringResource(R.string.no)
|
||||
},
|
||||
enabled = false
|
||||
)
|
||||
}
|
||||
}
|
||||
+5
-7
@@ -18,13 +18,11 @@
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components.apple
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.components.primitives.StyledList
|
||||
import me.kavishdevar.librepods.presentation.components.primitives.StyledListItem
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@Composable
|
||||
@@ -40,24 +38,24 @@ fun HearingHealthSettings(
|
||||
if (hasPPECapability && shouldShowHearingAid) {
|
||||
StyledList(title = stringResource(R.string.hearing_health)) {
|
||||
StyledListItem(
|
||||
contentText = stringResource(R.string.hearing_protection),
|
||||
name = stringResource(R.string.hearing_protection),
|
||||
onClick = navigateToHearingProtection
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
contentText = stringResource(R.string.hearing_aid),
|
||||
name = stringResource(R.string.hearing_aid),
|
||||
onClick = navigateToHearingAid
|
||||
)
|
||||
}
|
||||
} else if (shouldShowHearingAid) {
|
||||
StyledListItem(
|
||||
contentText = stringResource(R.string.hearing_aid),
|
||||
name = stringResource(R.string.hearing_aid),
|
||||
onClick = navigateToHearingAid
|
||||
)
|
||||
} else if (hasPPECapability) {
|
||||
StyledListItem(
|
||||
title = stringResource(R.string.hearing_health),
|
||||
contentText = stringResource(R.string.hearing_protection),
|
||||
name = stringResource(R.string.hearing_protection),
|
||||
onClick = navigateToHearingProtection
|
||||
)
|
||||
}
|
||||
+2
-2
@@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components.apple
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
@@ -70,7 +70,7 @@ fun NoiseControlButton(
|
||||
@Composable
|
||||
fun NoiseControlButtonPreview() {
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.ic_noise_cancellation),
|
||||
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
||||
onClick = {},
|
||||
textColor = Color.White,
|
||||
)
|
||||
+66
-71
@@ -18,7 +18,7 @@
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components.apple
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
@@ -29,12 +29,12 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -63,7 +63,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.imageResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -75,7 +74,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.devices.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
|
||||
@@ -89,8 +88,7 @@ import kotlin.math.roundToInt
|
||||
fun NoiseControlSettings(
|
||||
showOffListeningMode: Boolean,
|
||||
noiseControlModeValue: Int,
|
||||
onNoiseControlModeChanged: (Int) -> Unit,
|
||||
showLabels: Boolean = true
|
||||
onNoiseControlModeChanged: (Int) -> Unit
|
||||
) {
|
||||
when (LocalDesignSystem.current) {
|
||||
DesignSystem.Material -> {
|
||||
@@ -100,7 +98,7 @@ fun NoiseControlSettings(
|
||||
Triple(
|
||||
NoiseControlMode.OFF,
|
||||
R.string.off,
|
||||
R.drawable.ic_noise_cancellation
|
||||
R.drawable.noise_cancellation
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -109,21 +107,21 @@ fun NoiseControlSettings(
|
||||
Triple(
|
||||
NoiseControlMode.TRANSPARENCY,
|
||||
R.string.transparency,
|
||||
R.drawable.ic_transparency
|
||||
R.drawable.transparency
|
||||
)
|
||||
)
|
||||
add(
|
||||
Triple(
|
||||
NoiseControlMode.ADAPTIVE,
|
||||
R.string.adaptive,
|
||||
R.drawable.ic_adaptive
|
||||
R.drawable.adaptive
|
||||
)
|
||||
)
|
||||
add(
|
||||
Triple(
|
||||
NoiseControlMode.NOISE_CANCELLATION,
|
||||
R.string.noise_cancellation,
|
||||
R.drawable.ic_noise_cancellation
|
||||
R.drawable.noise_cancellation
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -131,14 +129,15 @@ fun NoiseControlSettings(
|
||||
val selectedMode = NoiseControlMode.entries[(noiseControlModeValue - 1).coerceIn(0, NoiseControlMode.entries.lastIndex)]
|
||||
|
||||
Column {
|
||||
if (showLabels) {
|
||||
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,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 4.dp, bottom = 12.dp)
|
||||
style = MaterialTheme.typography.labelSmallEmphasized
|
||||
)
|
||||
}
|
||||
Row(
|
||||
@@ -163,7 +162,8 @@ fun NoiseControlSettings(
|
||||
options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
|
||||
else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
|
||||
},
|
||||
colors = ToggleButtonDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
colors = ToggleButtonDefaults.toggleButtonColors()
|
||||
.copy(containerColor = MaterialTheme.colorScheme.surface),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
@@ -173,27 +173,22 @@ fun NoiseControlSettings(
|
||||
)
|
||||
}
|
||||
|
||||
if (showLabels) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(labelRes),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
Text(
|
||||
text = stringResource(labelRes),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: make it draggable (?)
|
||||
DesignSystem.Apple -> {
|
||||
val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f
|
||||
val backgroundColor = if (isDarkTheme) (if (showLabels) Color(0xFF1C1C1E) else MaterialTheme.colorScheme.surfaceContainerLow) else Color(0xFFE3E3E8)
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFE3E3E8)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val textColorSelected = if (isDarkTheme) Color.White else Color.Black
|
||||
val selectedBackground = if (isDarkTheme) Color(0xBF5C5A5F) else Color(0xFFFFFFFF)
|
||||
@@ -249,14 +244,16 @@ fun NoiseControlSettings(
|
||||
|
||||
onModeSelected(noiseControlMode.value, received = true)
|
||||
|
||||
if (showLabels) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 4.dp, bottom = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.noise_control),
|
||||
color = MaterialTheme.colorScheme.sectionHeader,
|
||||
style = MaterialTheme.typography.labelSmallEmphasized,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 4.dp, bottom = 12.dp)
|
||||
style = MaterialTheme.typography.labelSmallEmphasized
|
||||
)
|
||||
}
|
||||
BoxWithConstraints(
|
||||
@@ -317,7 +314,7 @@ fun NoiseControlSettings(
|
||||
) {
|
||||
if (showOffListeningMode) {
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.ic_noise_cancellation),
|
||||
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
||||
onClick = { onModeSelected(NoiseControlMode.OFF) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -332,7 +329,7 @@ fun NoiseControlSettings(
|
||||
)
|
||||
}
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.ic_transparency),
|
||||
icon = ImageBitmap.imageResource(R.drawable.transparency),
|
||||
onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -346,7 +343,7 @@ fun NoiseControlSettings(
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
|
||||
)
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.ic_adaptive),
|
||||
icon = ImageBitmap.imageResource(R.drawable.adaptive),
|
||||
onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -360,7 +357,7 @@ fun NoiseControlSettings(
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
|
||||
)
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.ic_noise_cancellation),
|
||||
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
||||
onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -414,7 +411,7 @@ fun NoiseControlSettings(
|
||||
) {
|
||||
if (showOffListeningMode) {
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.ic_noise_cancellation),
|
||||
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
||||
onClick = { onModeSelected(NoiseControlMode.OFF) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.OFF) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -429,7 +426,7 @@ fun NoiseControlSettings(
|
||||
)
|
||||
}
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.ic_transparency),
|
||||
icon = ImageBitmap.imageResource(R.drawable.transparency),
|
||||
onClick = { onModeSelected(NoiseControlMode.TRANSPARENCY) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.TRANSPARENCY) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -443,7 +440,7 @@ fun NoiseControlSettings(
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
|
||||
)
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.ic_adaptive),
|
||||
icon = ImageBitmap.imageResource(R.drawable.adaptive),
|
||||
onClick = { onModeSelected(NoiseControlMode.ADAPTIVE) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.ADAPTIVE) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -457,7 +454,7 @@ fun NoiseControlSettings(
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
|
||||
)
|
||||
NoiseControlButton(
|
||||
icon = ImageBitmap.imageResource(R.drawable.ic_noise_cancellation),
|
||||
icon = ImageBitmap.imageResource(R.drawable.noise_cancellation),
|
||||
onClick = { onModeSelected(NoiseControlMode.NOISE_CANCELLATION) },
|
||||
textColor = if (noiseControlMode.value == NoiseControlMode.NOISE_CANCELLATION) textColorSelected else textColor,
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -466,39 +463,37 @@ fun NoiseControlSettings(
|
||||
}
|
||||
}
|
||||
|
||||
if (showLabels) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp)
|
||||
) {
|
||||
if (showOffListeningMode) {
|
||||
Text(
|
||||
text = stringResource(R.string.off),
|
||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp)
|
||||
) {
|
||||
if (showOffListeningMode) {
|
||||
Text(
|
||||
text = stringResource(R.string.transparency),
|
||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.adaptive),
|
||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.noise_cancellation),
|
||||
text = stringResource(R.string.off),
|
||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.transparency),
|
||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.adaptive),
|
||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.noise_cancellation),
|
||||
style = TextStyle(fontSize = 12.sp, color = textColor),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -511,7 +506,7 @@ fun NoiseControlSettings(
|
||||
@Composable
|
||||
fun NoiseControlSettingsPreview() {
|
||||
LibrePodsTheme(
|
||||
designSystem = DesignSystem.Material
|
||||
m3eEnabled = true
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
+5
-7
@@ -16,14 +16,12 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components.apple
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.data.StemAction
|
||||
import me.kavishdevar.librepods.presentation.components.primitives.StyledList
|
||||
import me.kavishdevar.librepods.presentation.components.primitives.StyledListItem
|
||||
|
||||
@Composable
|
||||
fun PressAndHoldSettings(
|
||||
@@ -48,13 +46,13 @@ fun PressAndHoldSettings(
|
||||
title = stringResource(R.string.press_and_hold_airpods)
|
||||
) {
|
||||
StyledListItem(
|
||||
contentText = stringResource(R.string.left),
|
||||
supportingText = leftActionText,
|
||||
name = stringResource(R.string.left),
|
||||
description = leftActionText,
|
||||
onClick = navigateToLeftLongPress
|
||||
)
|
||||
StyledListItem(
|
||||
contentText = stringResource(R.string.right),
|
||||
supportingText = rightActionText,
|
||||
name = stringResource(R.string.right),
|
||||
description = rightActionText,
|
||||
onClick = navigateToRightLongPress,
|
||||
)
|
||||
}
|
||||
+6
-5
@@ -1,12 +1,12 @@
|
||||
package me.kavishdevar.librepods.presentation.components.primitives
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
@@ -15,7 +15,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.lerp
|
||||
import com.kyant.backdrop.backdrops.LayerBackdrop
|
||||
@@ -36,7 +35,7 @@ fun StyledBottomSheet(
|
||||
) {
|
||||
if (!visible) return
|
||||
|
||||
val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val sheetState = rememberModalBottomSheetState(false) // move this to parent composable
|
||||
|
||||
val isExpanded = sheetState.targetValue == SheetValue.Expanded
|
||||
@@ -73,7 +72,9 @@ fun StyledBottomSheet(
|
||||
},
|
||||
onDrawSurface = {
|
||||
drawRect(
|
||||
if (isDarkTheme) Color.DarkGray.copy(alpha = 0.3f) else Color(0xFFE0E0E0).copy(alpha = 0.45f)
|
||||
if (isDarkTheme) Color.DarkGray.copy(alpha = 0.3f) else Color(
|
||||
0xFFE0E0E0
|
||||
).copy(alpha = 0.45f)
|
||||
)
|
||||
}
|
||||
)
|
||||
+6
-16
@@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components.primitives
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import android.graphics.RuntimeShader
|
||||
import android.os.Build
|
||||
@@ -35,7 +35,6 @@ 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.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -61,7 +60,6 @@ import androidx.compose.ui.util.fastCoerceAtMost
|
||||
import androidx.compose.ui.util.fastCoerceIn
|
||||
import androidx.compose.ui.util.lerp
|
||||
import com.kyant.backdrop.Backdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import com.kyant.backdrop.drawBackdrop
|
||||
import com.kyant.backdrop.effects.blur
|
||||
import com.kyant.backdrop.effects.lens
|
||||
@@ -86,9 +84,9 @@ enum class MaterialButtonStyle {
|
||||
|
||||
@Composable
|
||||
fun StyledButton(
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
backdrop: Backdrop = rememberLayerBackdrop(),
|
||||
backdrop: Backdrop,
|
||||
modifier: Modifier = Modifier,
|
||||
isInteractive: Boolean = true,
|
||||
tint: Color = Color.Unspecified,
|
||||
surfaceColor: Color = Color.Unspecified,
|
||||
@@ -104,8 +102,7 @@ fun StyledButton(
|
||||
Button(
|
||||
modifier = modifier.height(48.dp),
|
||||
onClick = onClick,
|
||||
content = content,
|
||||
enabled = enabled,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
MaterialButtonStyle.Tonal -> {
|
||||
@@ -113,7 +110,6 @@ fun StyledButton(
|
||||
modifier = modifier.height(48.dp),
|
||||
onClick = onClick,
|
||||
content = content,
|
||||
enabled = enabled,
|
||||
colors = ButtonDefaults.filledTonalButtonColors(containerColor = surfaceColor)
|
||||
)
|
||||
}
|
||||
@@ -122,8 +118,7 @@ fun StyledButton(
|
||||
OutlinedButton(
|
||||
modifier = modifier.height(48.dp),
|
||||
onClick = onClick,
|
||||
content = content,
|
||||
enabled = enabled,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@@ -131,14 +126,12 @@ fun StyledButton(
|
||||
TextButton(
|
||||
modifier = modifier.height(48.dp),
|
||||
onClick = onClick,
|
||||
content = content,
|
||||
enabled = enabled,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
DesignSystem.Apple -> {
|
||||
val defaultButtonColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
val isInteractive = enabled && isInteractive
|
||||
val scope = rememberCoroutineScope()
|
||||
val haptics = LocalHapticFeedback.current
|
||||
@@ -202,9 +195,6 @@ half4 main(float2 coord) {
|
||||
if (isPressed && enabled) {
|
||||
drawRect(Color.Black.copy(alpha = 0.4f))
|
||||
drawRect(Color.White.copy(alpha = 0.2f))
|
||||
} else {
|
||||
|
||||
drawRect(defaultButtonColor)
|
||||
}
|
||||
}
|
||||
},
|
||||
+85
-40
@@ -16,16 +16,18 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components.primitives
|
||||
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.os.Build
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.VisibilityThreshold
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -34,10 +36,9 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedIconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -59,13 +60,16 @@ import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.graphics.isSpecified
|
||||
import androidx.compose.ui.graphics.layer.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.layer.drawLayer
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.rememberGraphicsLayer
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -80,7 +84,7 @@ import com.kyant.backdrop.effects.lens
|
||||
import com.kyant.backdrop.highlight.Highlight
|
||||
import com.kyant.backdrop.shadow.InnerShadow
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.presentation.icons.LocalIcons
|
||||
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
|
||||
@@ -93,12 +97,13 @@ import kotlin.math.tanh
|
||||
@Composable
|
||||
fun StyledIconButton(
|
||||
modifier: Modifier = Modifier,
|
||||
icon: String,
|
||||
iconTint: Color = Color.Unspecified,
|
||||
surfaceColor: Color = Color.Unspecified,
|
||||
backdrop: LayerBackdrop = rememberLayerBackdrop(),
|
||||
onClick: () -> Unit,
|
||||
enabled: Boolean = true,
|
||||
materialButtonStyle: MaterialButtonStyle = MaterialButtonStyle.Normal,
|
||||
content: @Composable () -> Unit,
|
||||
materialButtonStyle: MaterialButtonStyle = MaterialButtonStyle.Normal
|
||||
) {
|
||||
when (LocalDesignSystem.current) {
|
||||
DesignSystem.Material -> {
|
||||
@@ -109,7 +114,13 @@ fun StyledIconButton(
|
||||
enabled = enabled,
|
||||
modifier = Modifier.size(52.dp)
|
||||
) {
|
||||
content()
|
||||
Text(
|
||||
text = icon,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
MaterialButtonStyle.Filled -> {
|
||||
@@ -118,7 +129,13 @@ fun StyledIconButton(
|
||||
enabled = enabled,
|
||||
modifier = Modifier.size(52.dp)
|
||||
) {
|
||||
content()
|
||||
Text(
|
||||
text = icon,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
MaterialButtonStyle.Outlined -> {
|
||||
@@ -127,7 +144,13 @@ fun StyledIconButton(
|
||||
enabled = enabled,
|
||||
modifier = Modifier.size(52.dp)
|
||||
) {
|
||||
content()
|
||||
Text(
|
||||
text = icon,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
MaterialButtonStyle.Normal -> {
|
||||
@@ -136,13 +159,20 @@ fun StyledIconButton(
|
||||
enabled = enabled,
|
||||
modifier = Modifier.size(52.dp)
|
||||
) {
|
||||
content()
|
||||
Text(
|
||||
text = icon,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
DesignSystem.Apple -> {
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val darkMode = isSystemInDarkTheme()
|
||||
val scope = rememberCoroutineScope()
|
||||
val progressAnimationSpec = spring(0.5f, 300f, 0.001f)
|
||||
val offsetAnimationSpec = spring(1f, 300f, Offset.VisibilityThreshold)
|
||||
@@ -155,7 +185,8 @@ fun StyledIconButton(
|
||||
val density = LocalDensity.current
|
||||
|
||||
val interactiveHighlightShader = remember {
|
||||
RuntimeShader(
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
RuntimeShader(
|
||||
"""
|
||||
uniform float2 size;
|
||||
layout(color) uniform half4 color;
|
||||
@@ -169,8 +200,11 @@ half4 main(float2 coord) {
|
||||
return color * intensity;
|
||||
}"""
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
TextButton(
|
||||
onClick = {
|
||||
if (enabled) {
|
||||
@@ -279,28 +313,35 @@ half4 main(float2 coord) {
|
||||
if (!enabled) return@drawBackdrop
|
||||
val progress = progressAnimation.value.fastCoerceIn(0f, 1f)
|
||||
if (progress > 0f) {
|
||||
drawRect(
|
||||
Color.White.copy(0.1f * progress),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
interactiveHighlightShader.apply {
|
||||
val offset = pressStartPosition + offsetAnimation.value
|
||||
setFloatUniform("size", size.width, size.height)
|
||||
setColorUniform(
|
||||
"color",
|
||||
Color.White.copy(0.15f * progress).toArgb()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && interactiveHighlightShader != null) {
|
||||
drawRect(
|
||||
Color.White.copy(0.1f * progress),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
setFloatUniform("radius", size.maxDimension)
|
||||
setFloatUniform(
|
||||
"offset",
|
||||
offset.x.fastCoerceIn(0f, size.width),
|
||||
offset.y.fastCoerceIn(0f, size.height)
|
||||
interactiveHighlightShader.apply {
|
||||
val offset = pressStartPosition + offsetAnimation.value
|
||||
setFloatUniform("size", size.width, size.height)
|
||||
setColorUniform(
|
||||
"color",
|
||||
Color.White.copy(0.15f * progress).toArgb()
|
||||
)
|
||||
setFloatUniform("radius", size.maxDimension)
|
||||
setFloatUniform(
|
||||
"offset",
|
||||
offset.x.fastCoerceIn(0f, size.width),
|
||||
offset.y.fastCoerceIn(0f, size.height)
|
||||
)
|
||||
}
|
||||
drawRect(
|
||||
ShaderBrush(interactiveHighlightShader),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
} else {
|
||||
drawRect(
|
||||
Color.White.copy(0.25f * progress),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
}
|
||||
drawRect(
|
||||
ShaderBrush(interactiveHighlightShader),
|
||||
blendMode = BlendMode.Plus
|
||||
)
|
||||
}
|
||||
},
|
||||
effects = {
|
||||
@@ -363,7 +404,15 @@ half4 main(float2 coord) {
|
||||
}
|
||||
.size(with(density) { 48.sp.toDp() }),
|
||||
) {
|
||||
content()
|
||||
Text(
|
||||
text = icon,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = if (iconTint.isSpecified) iconTint else if (darkMode) Color.White else Color.Black,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -377,16 +426,12 @@ fun StyledIconButtonPreview() {
|
||||
.height(120.dp)
|
||||
.width(200.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceContainer,
|
||||
if (isSystemInDarkTheme()) Color(0xFF000000) else Color(0xFFF2F2F7),
|
||||
RoundedCornerShape(28.dp)
|
||||
), contentAlignment = Alignment.Center) {
|
||||
StyledIconButton(
|
||||
icon = "",
|
||||
onClick = { }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = LocalIcons.current.Settings,
|
||||
contentDescription = "Settings",
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
+34
-22
@@ -1,10 +1,11 @@
|
||||
package me.kavishdevar.librepods.presentation.components.primitives
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import androidx.compose.animation.core.animateDp
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.updateTransition
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -13,16 +14,12 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItemColors
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
@@ -32,12 +29,17 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.librepods.presentation.icons.LocalIcons
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
|
||||
@@ -48,12 +50,7 @@ fun StyledInputField(
|
||||
focusRequester: FocusRequester,
|
||||
placeholder: String = "",
|
||||
singleLine: Boolean = true,
|
||||
forceApple: Boolean = false,
|
||||
colors: ListItemColors = ListItemDefaults.segmentedColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
forceApple: Boolean = false
|
||||
) {
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material && !forceApple
|
||||
|
||||
@@ -74,6 +71,9 @@ fun StyledInputField(
|
||||
)
|
||||
}
|
||||
else {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val minHeight = if (singleLine) 58.dp else 120.dp
|
||||
val verticalAlignment = if (singleLine) Alignment.CenterVertically else Alignment.Top
|
||||
val hasText = inputState.text.isNotEmpty()
|
||||
@@ -99,7 +99,7 @@ fun StyledInputField(
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = minHeight)
|
||||
.background(
|
||||
colors.containerColor,
|
||||
backgroundColor,
|
||||
RoundedCornerShape(28.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
@@ -112,8 +112,12 @@ fun StyledInputField(
|
||||
BasicTextField(
|
||||
state = inputState,
|
||||
lineLimits = if (singleLine) TextFieldLineLimits.SingleLine else TextFieldLineLimits.Default,
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.onBackground),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.onBackground),
|
||||
textStyle = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
cursorBrush = SolidColor(textColor),
|
||||
decorator = { innerTextField ->
|
||||
Row(
|
||||
modifier = Modifier.padding(top = if (singleLine) 0.dp else 16.dp),
|
||||
@@ -130,8 +134,12 @@ fun StyledInputField(
|
||||
) {
|
||||
Text(
|
||||
text = placeholder,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.8f),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor.copy(alpha = 0.8f)
|
||||
),
|
||||
modifier = Modifier
|
||||
.offset(y = yOffset)
|
||||
)
|
||||
@@ -145,11 +153,15 @@ fun StyledInputField(
|
||||
inputState.clearText()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = LocalIcons.current.CloseCircle,
|
||||
contentDescription = "Clear text",
|
||||
tint = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
|
||||
modifier = Modifier.size(16.dp)
|
||||
Text(
|
||||
text = "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = if (isDarkTheme) Color.White.copy(alpha = 0.6f) else Color.Black.copy(
|
||||
alpha = 0.6f
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+10
-31
@@ -1,4 +1,4 @@
|
||||
package me.kavishdevar.librepods.presentation.components.primitives
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -7,15 +7,10 @@ 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.ListItemColors
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -33,23 +28,12 @@ import me.kavishdevar.librepods.presentation.theme.sectionHeader
|
||||
@Composable
|
||||
fun StyledList(
|
||||
modifier: Modifier = Modifier,
|
||||
scrollEnabled: Boolean = false,
|
||||
title: String? = null,
|
||||
description: String? = null,
|
||||
colors: ListItemColors = ListItemDefaults.segmentedColors().run {
|
||||
copy(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
},
|
||||
key: Any? = null,
|
||||
content: @Composable StyledListScope.() -> Unit
|
||||
) {
|
||||
val scope = StyledListScope()
|
||||
key(key) {
|
||||
scope.content()
|
||||
}
|
||||
scope.content()
|
||||
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
|
||||
@@ -68,13 +52,11 @@ fun StyledList(
|
||||
)
|
||||
}
|
||||
}
|
||||
val scrollState = rememberScrollState()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(if (m3eEnabled) Color.Transparent else colors.containerColor, RoundedCornerShape(if (m3eEnabled) 24.dp else 28.dp))
|
||||
.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))
|
||||
.then(if (scrollEnabled) Modifier.verticalScroll(scrollState) else Modifier)
|
||||
) {
|
||||
if (m3eEnabled && description != null) {
|
||||
Text(
|
||||
@@ -92,10 +74,9 @@ fun StyledList(
|
||||
}
|
||||
}
|
||||
if (!m3eEnabled && description != null) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
style = MaterialTheme.typography.bodySmallEmphasized,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(0.6f),
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
@@ -118,23 +99,22 @@ class StyledListScope {
|
||||
@Composable
|
||||
fun StyledListDemo() {
|
||||
LibrePodsTheme(
|
||||
designSystem = DesignSystem.Apple,
|
||||
darkTheme = false
|
||||
m3eEnabled = true
|
||||
) {
|
||||
val backgroundC = MaterialTheme.colorScheme.background
|
||||
StyledScaffold(
|
||||
title = "StyledListTest",
|
||||
navigateBack = null
|
||||
) { topPadding, bottomPadding ->
|
||||
title = "${backgroundC.red}, ${backgroundC.green}, ${backgroundC.blue}"
|
||||
) {
|
||||
Column (
|
||||
modifier = Modifier.padding(horizontal = 12.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
Spacer(modifier = Modifier.height(56.dp))
|
||||
StyledList(
|
||||
title = "hello"
|
||||
) {
|
||||
for (i in 0..2) {
|
||||
StyledListItem(
|
||||
contentText = i.toString(),
|
||||
name = i.toString(),
|
||||
onClick = {}
|
||||
)
|
||||
}
|
||||
@@ -146,7 +126,6 @@ fun StyledListDemo() {
|
||||
onCheckedChange = { checked.value = it },
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
}
|
||||
+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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+52
-132
@@ -16,10 +16,8 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components.primitives
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import android.graphics.RenderEffect
|
||||
import android.graphics.Shader
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
@@ -29,13 +27,11 @@ import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
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.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -64,15 +60,15 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asComposeRenderEffect
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.kyant.backdrop.backdrops.LayerBackdrop
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
@@ -81,10 +77,7 @@ import dev.chrisbanes.haze.HazeTint
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.rememberHazeState
|
||||
import me.kavishdevar.librepods.presentation.icons.LocalIcons
|
||||
import me.kavishdevar.librepods.presentation.navigation.LocalIsCurrentEntry
|
||||
import me.kavishdevar.librepods.presentation.navigation.LocalSharedTransitionScope
|
||||
import me.kavishdevar.librepods.presentation.navigation.LocalTransitionProgress
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
|
||||
@@ -94,11 +87,13 @@ fun StyledScaffold(
|
||||
modifier: Modifier = Modifier,
|
||||
visible: Boolean = true,
|
||||
title: String,
|
||||
navigateBack: (() -> Unit)?,
|
||||
showBackButton: Boolean = false,
|
||||
onNavigateBack: () -> Unit = {},
|
||||
actionButtons: List<@Composable (backdrop: LayerBackdrop) -> Unit> = emptyList(),
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||
content: @Composable (topPadding: Dp, bottomPadding: Dp) -> Unit
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val hazeState = rememberHazeState(blurEnabled = true)
|
||||
|
||||
when (LocalDesignSystem.current) {
|
||||
@@ -114,18 +109,14 @@ fun StyledScaffold(
|
||||
) {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
if (navigateBack != null) {
|
||||
if (showBackButton) {
|
||||
Row {
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
FilledTonalIconButton(
|
||||
onClick = navigateBack,
|
||||
onClick = onNavigateBack,
|
||||
modifier = Modifier
|
||||
.minimumInteractiveComponentSize()
|
||||
.size(
|
||||
IconButtonDefaults.mediumContainerSize(
|
||||
IconButtonDefaults.IconButtonWidthOption.Narrow
|
||||
)
|
||||
),
|
||||
.size(IconButtonDefaults.mediumContainerSize(IconButtonDefaults.IconButtonWidthOption.Narrow)),
|
||||
shape = IconButtonDefaults.mediumRoundShape
|
||||
) {
|
||||
Icon(
|
||||
@@ -143,7 +134,7 @@ fun StyledScaffold(
|
||||
text = it,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(start = if (navigateBack != null) 8.dp else 12.dp, end = 12.dp),
|
||||
modifier = Modifier.padding(start = if (showBackButton) 8.dp else 12.dp, end = 12.dp),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
}
|
||||
@@ -161,18 +152,11 @@ fun StyledScaffold(
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier = modifier
|
||||
.then(
|
||||
if (visible) Modifier.padding(
|
||||
start = paddingValues.calculateStartPadding(
|
||||
LocalLayoutDirection.current
|
||||
),
|
||||
end = paddingValues.calculateEndPadding(LocalLayoutDirection.current)
|
||||
) else Modifier
|
||||
)
|
||||
.then(if (visible) Modifier.padding(paddingValues) else Modifier)
|
||||
.fillMaxSize()
|
||||
.hazeSource(hazeState)
|
||||
) {
|
||||
content(paddingValues.calculateTopPadding(), paddingValues.calculateBottomPadding())
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,102 +166,46 @@ fun StyledScaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (MaterialTheme.colorScheme.surface.luminance() > 0.5) Modifier.shadow(
|
||||
if (!isDarkTheme) Modifier.shadow(
|
||||
elevation = 36.dp,
|
||||
shape = RoundedCornerShape(52.dp),
|
||||
ambientColor = Color.Black,
|
||||
spotColor = Color.Black
|
||||
) else Modifier
|
||||
)
|
||||
.clip(RoundedCornerShape(52.dp))
|
||||
) { paddingValues ->
|
||||
val topPadding = paddingValues.calculateTopPadding()
|
||||
val bottomPadding = paddingValues.calculateBottomPadding()
|
||||
val startPadding = paddingValues.calculateLeftPadding(LocalLayoutDirection.current)
|
||||
val endPadding = paddingValues.calculateRightPadding(LocalLayoutDirection.current)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(52.dp))
|
||||
.padding(start = startPadding, end = endPadding)
|
||||
) {
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
val bgColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
|
||||
// val density = LocalDensity.current
|
||||
// val screenWidthPx = with(density) {
|
||||
// LocalWindowInfo.current.containerDpSize.width.toPx()
|
||||
// }
|
||||
val isCurrentEntry = LocalIsCurrentEntry.current
|
||||
val transitionProgress = LocalTransitionProgress.current
|
||||
val sharedTransitionScope = LocalSharedTransitionScope.current
|
||||
|
||||
val showBackButton = if (transitionProgress == 0f) navigateBack != null else !isCurrentEntry
|
||||
|
||||
if (showBackButton) {
|
||||
with(sharedTransitionScope) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.zIndex(3f)
|
||||
.padding(top = topPadding, start = 8.dp)
|
||||
.align(Alignment.TopStart)
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitFirstDown(requireUnconsumed = false)
|
||||
|
||||
do {
|
||||
val event = awaitPointerEvent()
|
||||
|
||||
event.changes.forEach { change ->
|
||||
change.consume()
|
||||
}
|
||||
} while (event.changes.any { it.pressed })
|
||||
}
|
||||
}
|
||||
}
|
||||
.renderInSharedTransitionScopeOverlay(
|
||||
zIndexInOverlay = 3f,
|
||||
renderInOverlay = {
|
||||
!isCurrentEntry && transitionProgress != 0f
|
||||
}
|
||||
)
|
||||
.graphicsLayer { // AI generated
|
||||
if (!isCurrentEntry && navigateBack == null && transitionProgress < 0f) {
|
||||
val progress = (-transitionProgress).coerceIn(0f, 1f)
|
||||
|
||||
val eased = progress * progress * (3f - 2f * progress)
|
||||
|
||||
val scale = 1f - 0.18f * eased
|
||||
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
|
||||
alpha = 1f - 0.28f * eased
|
||||
|
||||
val blur = 8f * progress
|
||||
|
||||
renderEffect = RenderEffect.createBlurEffect(
|
||||
blur,
|
||||
blur,
|
||||
Shader.TileMode.DECAL
|
||||
).asComposeRenderEffect()
|
||||
}
|
||||
}
|
||||
) {
|
||||
StyledIconButton(
|
||||
onClick = { navigateBack?.invoke() },
|
||||
backdrop = backdrop // i know, this doesn't capture what's actually beneath it. but it's going to matter just in the transition.
|
||||
) {
|
||||
Icon(
|
||||
imageVector = LocalIcons.current.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
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(
|
||||
@@ -296,15 +224,17 @@ fun StyledScaffold(
|
||||
.fillMaxWidth()
|
||||
.layerBackdrop(backdrop)
|
||||
){
|
||||
val scrimColor = MaterialTheme.colorScheme.scrim
|
||||
|
||||
Box(
|
||||
modifier = Modifier.hazeEffect(
|
||||
state = hazeState,
|
||||
) {
|
||||
backgroundColor = bgColor
|
||||
tints = listOf(
|
||||
HazeTint(scrimColor)
|
||||
HazeTint(
|
||||
if (isDarkTheme) Color.Black.copy(0.55f) else Color(
|
||||
0xFFF2F2F7
|
||||
).copy(alpha = 0.85f)
|
||||
)
|
||||
)
|
||||
blurRadius = 6.dp
|
||||
}
|
||||
@@ -315,7 +245,12 @@ fun StyledScaffold(
|
||||
Crossfade(targetState = title) {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (isDarkTheme) Color.White else Color.Black,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
@@ -338,21 +273,6 @@ fun StyledScaffold(
|
||||
.zIndex(3f)
|
||||
.padding(top = topPadding, end = 8.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitFirstDown(requireUnconsumed = false)
|
||||
|
||||
do {
|
||||
val event = awaitPointerEvent()
|
||||
|
||||
event.changes.forEach { change ->
|
||||
change.consume()
|
||||
}
|
||||
} while (event.changes.any { it.pressed })
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
Row{
|
||||
actionButtons.forEach { actionButton ->
|
||||
@@ -366,7 +286,7 @@ fun StyledScaffold(
|
||||
.hazeSource(hazeState)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
content(topPadding + 64.dp, bottomPadding)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
-85
@@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components.primitives
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.res.Configuration
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -41,8 +42,6 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItemColors
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SegmentedListItem
|
||||
@@ -61,8 +60,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.input.pointer.util.VelocityTracker
|
||||
@@ -73,11 +70,15 @@ import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.layout.positionInParent
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.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.tooling.preview.Wallpapers.GREEN_DOMINATED_EXAMPLE
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.util.fastCoerceIn
|
||||
import androidx.compose.ui.util.fastRoundToInt
|
||||
import androidx.compose.ui.util.lerp
|
||||
@@ -94,7 +95,6 @@ import com.kyant.backdrop.shadow.Shadow
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.icons.LocalIcons
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
@@ -223,20 +223,15 @@ fun StyledSlider(
|
||||
backdrop: Backdrop = rememberLayerBackdrop(),
|
||||
snapPoints: List<Float> = emptyList(),
|
||||
snapThreshold: Float = 0.05f,
|
||||
startImageVector: ImageVector? = null,
|
||||
endImageVector: ImageVector? = null,
|
||||
startIcon: String? = null,
|
||||
endIcon: String? = null,
|
||||
startLabel: String? = null,
|
||||
endLabel: String? = null,
|
||||
independent: Boolean = false,
|
||||
description: String? = null,
|
||||
enabled: Boolean = true,
|
||||
index: Int = 0,
|
||||
count: Int = 1,
|
||||
colors: ListItemColors = ListItemDefaults.segmentedColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
count: Int = 1
|
||||
) {
|
||||
when (LocalDesignSystem.current) {
|
||||
DesignSystem.Material -> {
|
||||
@@ -322,13 +317,8 @@ fun StyledSlider(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
|
||||
startImageVector?.let {
|
||||
Icon(
|
||||
imageVector = it,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
startIcon?.let {
|
||||
Text(it, fontFamily = FontFamily(Font(R.font.sf_pro)))
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
|
||||
@@ -353,19 +343,13 @@ fun StyledSlider(
|
||||
enabled = enabled
|
||||
)
|
||||
|
||||
endImageVector?.let {
|
||||
endIcon?.let {
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Icon(
|
||||
imageVector = it,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Text(it, fontFamily = FontFamily(Font(R.font.sf_pro)))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = colors
|
||||
}
|
||||
)
|
||||
|
||||
if (index + 1 != count) {
|
||||
@@ -377,16 +361,20 @@ fun StyledSlider(
|
||||
}
|
||||
|
||||
DesignSystem.Apple -> {
|
||||
val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f
|
||||
val backgroundColor =
|
||||
if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val trackColor =
|
||||
if (isDarkTheme) Color(0xFF787880).copy(0.36f)
|
||||
else Color(0xFF787878).copy(0.2f)
|
||||
val accentColor =
|
||||
if (enabled) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
if (isDarkTheme) Color(0xFF0091FF)
|
||||
else Color(0xFF0088FF)
|
||||
} else {
|
||||
trackColor
|
||||
}
|
||||
val labelTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
val fraction by derivedStateOf {
|
||||
((value - valueRange.start) / (valueRange.endInclusive - valueRange.start))
|
||||
@@ -407,7 +395,7 @@ fun StyledSlider(
|
||||
val content = @Composable {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(if (startImageVector == null && endImageVector == null) 0.95f else 1f)
|
||||
.fillMaxWidth(if (startIcon == null && endIcon == null) 0.95f else 1f)
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
@@ -430,13 +418,21 @@ fun StyledSlider(
|
||||
) {
|
||||
Text(
|
||||
text = startLabel ?: "",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = labelTextColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text = endLabel ?: "",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = labelTextColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
@@ -446,7 +442,7 @@ fun StyledSlider(
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.then(
|
||||
if (startImageVector == null && endImageVector == null) Modifier.padding(
|
||||
if (startIcon == null && endIcon == null) Modifier.padding(
|
||||
horizontal = 8.dp
|
||||
) else Modifier
|
||||
),
|
||||
@@ -455,17 +451,20 @@ fun StyledSlider(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(0.dp)
|
||||
) {
|
||||
startImageVector?.let{
|
||||
Icon(
|
||||
imageVector = it,
|
||||
contentDescription = null,
|
||||
tint = accentColor,
|
||||
if (startIcon != null) {
|
||||
Text(
|
||||
text = startIcon,
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = accentColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.size(24.dp)
|
||||
.onGloballyPositioned { coordinates ->
|
||||
.onGloballyPositioned {
|
||||
startIconWidthState.floatValue =
|
||||
coordinates.size.width.toFloat()
|
||||
it.size.width.toFloat()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -504,14 +503,17 @@ fun StyledSlider(
|
||||
}
|
||||
)
|
||||
}
|
||||
if (endImageVector != null) {
|
||||
Icon(
|
||||
imageVector = endImageVector,
|
||||
contentDescription = null,
|
||||
tint = accentColor,
|
||||
if (endIcon != null) {
|
||||
Text(
|
||||
text = endIcon,
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = accentColor,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.size(24.dp)
|
||||
.onGloballyPositioned {
|
||||
endIconWidthState.floatValue =
|
||||
it.size.width.toFloat()
|
||||
@@ -529,13 +531,13 @@ fun StyledSlider(
|
||||
) {
|
||||
if (snapPoints.isNotEmpty()) {
|
||||
val trackWidth =
|
||||
if (startImageVector != null && endImageVector != null) trackWidthState.floatValue - with(
|
||||
if (startIcon != null && endIcon != null) trackWidthState.floatValue - with(
|
||||
density
|
||||
) { 6.dp.toPx() } * 2 else trackWidthState.floatValue - with(
|
||||
density
|
||||
) { 22.dp.toPx() }
|
||||
val startOffset =
|
||||
if (startImageVector != null) startIconWidthState.floatValue + with(
|
||||
if (startIcon != null) startIconWidthState.floatValue + with(
|
||||
density
|
||||
) { 34.dp.toPx() } else with(density) { 14.dp.toPx() }
|
||||
Box(
|
||||
@@ -570,7 +572,7 @@ fun StyledSlider(
|
||||
Modifier
|
||||
.graphicsLayer {
|
||||
val startOffset =
|
||||
if (startImageVector != null)
|
||||
if (startIcon != null)
|
||||
startIconWidthState.floatValue + with(density) { 24.dp.toPx() }
|
||||
else
|
||||
with(density) { 8.dp.toPx() }
|
||||
@@ -702,8 +704,12 @@ fun StyledSlider(
|
||||
if (label != null) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmallEmphasized,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = labelTextColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
@@ -711,7 +717,7 @@ fun StyledSlider(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(colors.containerColor, RoundedCornerShape(28.dp))
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 0.dp)
|
||||
.heightIn(min = 58.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
@@ -722,10 +728,16 @@ fun StyledSlider(
|
||||
if (description != null) {
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Light),
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.6f),
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(
|
||||
alpha = 0.6f
|
||||
),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 18.dp, vertical = 8.dp)
|
||||
.padding(horizontal = 18.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -749,23 +761,21 @@ private fun snapIfClose(value: Float, points: List<Float>, threshold: Float = 0.
|
||||
return if (abs(nearest - value) <= threshold) nearest else value
|
||||
}
|
||||
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, wallpaper = GREEN_DOMINATED_EXAMPLE)
|
||||
@Composable
|
||||
fun StyledSliderPreview() {
|
||||
val a = remember { mutableFloatStateOf(0.5f) }
|
||||
LibrePodsTheme(
|
||||
designSystem = DesignSystem.Apple
|
||||
m3eEnabled = true
|
||||
) {
|
||||
StyledScaffold(
|
||||
title = "test",
|
||||
navigateBack = null
|
||||
) { topPadding, bottomPadding ->
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
Spacer(modifier = Modifier.height(72.dp))
|
||||
StyledSlider(
|
||||
value = a.floatValue,
|
||||
onValueChange = {
|
||||
@@ -775,8 +785,8 @@ fun StyledSliderPreview() {
|
||||
snapPoints = listOf(1f),
|
||||
snapThreshold = 0.1f,
|
||||
independent = true,
|
||||
startImageVector = LocalIcons.current.LeftCircleFill,
|
||||
endImageVector = LocalIcons.current.RightCircleFill,
|
||||
startIcon = "A",
|
||||
endIcon = "B",
|
||||
)
|
||||
StyledSlider(
|
||||
label = "Small label",
|
||||
@@ -789,22 +799,9 @@ fun StyledSliderPreview() {
|
||||
snapPoints = listOf(1f),
|
||||
snapThreshold = 0.1f,
|
||||
independent = true,
|
||||
startImageVector = LocalIcons.current.SpeakerMin,
|
||||
endImageVector = LocalIcons.current.SpeakerMax,
|
||||
startIcon = "A",
|
||||
endIcon = "B",
|
||||
)
|
||||
val sliderValue = remember { mutableFloatStateOf(50f) }
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.customize_adaptive_audio),
|
||||
value = sliderValue.floatValue,
|
||||
onValueChange = { sliderValue.floatValue = it },
|
||||
valueRange = 0f..100f,
|
||||
snapPoints = listOf(0f, 50f, 100f),
|
||||
startImageVector = LocalIcons.current.SpeakerMin,
|
||||
endImageVector = LocalIcons.current.SpeakerMax,
|
||||
independent = true,
|
||||
description = stringResource(R.string.adaptive_audio_description),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-9
@@ -16,7 +16,7 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components.primitives
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
@@ -28,12 +28,12 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
@@ -57,7 +57,6 @@ import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.layer.CompositingStrategy
|
||||
import androidx.compose.ui.graphics.layer.drawLayer
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.rememberGraphicsLayer
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
@@ -83,9 +82,8 @@ fun StyledSwitch(
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
enabled: Boolean = true,
|
||||
backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
) {
|
||||
val isDarkTheme = MaterialTheme.colorScheme.surface.luminance() < 0.5f
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val haptics = LocalHapticFeedback.current
|
||||
|
||||
val onColor = if (enabled) Color(0xFF34C759) else if (isDarkTheme) Color(0xFF5B5B5E) else Color(0xFFD1D1D6)
|
||||
@@ -237,7 +235,7 @@ fun StyledSwitch(
|
||||
right = size.width,
|
||||
bottom = size.height,
|
||||
paint = Paint().apply {
|
||||
color = backgroundColor
|
||||
color = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7)
|
||||
}
|
||||
)
|
||||
scale(0.7f) {
|
||||
@@ -292,9 +290,11 @@ fun StyledSwitch(
|
||||
@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO)
|
||||
@Composable
|
||||
fun StyledSwitchPreview() {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.background(backgroundColor)
|
||||
.width(100.dp)
|
||||
.height(150.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
@@ -308,9 +308,9 @@ fun StyledSwitchPreview() {
|
||||
enabled = true,
|
||||
)
|
||||
// LaunchedEffect(Unit) {
|
||||
// delay(1.seconds)
|
||||
// delay(1000)
|
||||
// checked.value = false
|
||||
// delay(1.seconds)
|
||||
// delay(1000)
|
||||
// checked.value = true
|
||||
// }
|
||||
}
|
||||
+39
-51
@@ -18,12 +18,14 @@
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.presentation.components.primitives
|
||||
package me.kavishdevar.librepods.presentation.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -34,7 +36,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.ListItemColors
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SegmentedListItem
|
||||
@@ -53,10 +54,14 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
@@ -71,33 +76,29 @@ fun StyledToggle(
|
||||
checked: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
header: Boolean = false,
|
||||
colors: ListItemColors = if (header) ListItemDefaults.segmentedColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
) else ListItemDefaults.segmentedColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
header: Boolean = false
|
||||
) {
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
Column(modifier = Modifier.padding(vertical = 12.dp)) {
|
||||
title?.let {
|
||||
Text(
|
||||
text = it,
|
||||
color = if (m3eEnabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.sectionHeader,
|
||||
style = MaterialTheme.typography.labelSmallEmphasized,
|
||||
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.surfaceContainerHigh,
|
||||
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))
|
||||
@@ -111,8 +112,7 @@ fun StyledToggle(
|
||||
onCheckedChange = onCheckedChange,
|
||||
index = 0,
|
||||
count = 1,
|
||||
header = header,
|
||||
colors = colors
|
||||
header = header
|
||||
)
|
||||
} else {
|
||||
StyledToggleContent(
|
||||
@@ -121,18 +121,18 @@ fun StyledToggle(
|
||||
enabled = enabled,
|
||||
onCheckedChange = onCheckedChange,
|
||||
index = 0,
|
||||
count = 1,
|
||||
colors = colors
|
||||
count = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
if (description != null && !m3eEnabled) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp),
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(0.6f),
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -145,11 +145,6 @@ fun StyledListScope.StyledToggle(
|
||||
checked: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
colors: ListItemColors = ListItemDefaults.segmentedColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
) {
|
||||
item { index, count ->
|
||||
StyledToggleContent(
|
||||
@@ -159,8 +154,7 @@ fun StyledListScope.StyledToggle(
|
||||
enabled = enabled,
|
||||
onCheckedChange = onCheckedChange,
|
||||
index = index,
|
||||
count = count,
|
||||
colors = colors
|
||||
count = count
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -175,18 +169,13 @@ private fun StyledToggleContent(
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
index: Int,
|
||||
count: Int,
|
||||
header: Boolean = false,
|
||||
colors: ListItemColors = if (header) ListItemDefaults.segmentedColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
) else ListItemDefaults.segmentedColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
selectedContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
header: Boolean = false
|
||||
) {
|
||||
val currentChecked by rememberUpdatedState(checked)
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -257,7 +246,7 @@ private fun StyledToggleContent(
|
||||
enabled = enabled,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.heightIn(min = 64.dp),
|
||||
colors = colors
|
||||
colors = if (header) ListItemDefaults.segmentedColors(containerColor = MaterialTheme.colorScheme.primaryContainer) else ListItemDefaults.segmentedColors()
|
||||
)
|
||||
if (index+1 != count) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
@@ -297,12 +286,12 @@ private fun StyledToggleContent(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 12.dp)
|
||||
.padding(end = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
color = textColor,
|
||||
)
|
||||
|
||||
if (description != null) {
|
||||
@@ -310,7 +299,7 @@ private fun StyledToggleContent(
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(0.8f)
|
||||
color = textColor.copy(0.8f)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -322,8 +311,7 @@ private fun StyledToggleContent(
|
||||
if (enabled) {
|
||||
onCheckedChange(it)
|
||||
}
|
||||
},
|
||||
backgroundColor = colors.containerColor
|
||||
}
|
||||
)
|
||||
}
|
||||
if (index+1 != count) {
|
||||
@@ -342,7 +330,7 @@ private fun StyledToggleContent(
|
||||
@Composable
|
||||
fun StyledToggleAppleListPreview() {
|
||||
val checked = remember { mutableStateOf(false) }
|
||||
LibrePodsTheme(designSystem = DesignSystem.Apple) {
|
||||
LibrePodsTheme(m3eEnabled = false) {
|
||||
StyledList {
|
||||
StyledToggle(
|
||||
label = "Apple Styled List",
|
||||
@@ -358,7 +346,7 @@ fun StyledToggleAppleListPreview() {
|
||||
@Composable
|
||||
fun StyledToggleApplePreview() {
|
||||
val checked = remember { mutableStateOf(false) }
|
||||
LibrePodsTheme(designSystem = DesignSystem.Apple) {
|
||||
LibrePodsTheme(m3eEnabled = false) {
|
||||
StyledToggle(
|
||||
label = "Apple",
|
||||
description = "This is an example description for the styled toggle.",
|
||||
@@ -372,7 +360,7 @@ fun StyledToggleApplePreview() {
|
||||
@Composable
|
||||
fun StyledToggleM3EListPreview() {
|
||||
val checked = remember { mutableStateOf(false) }
|
||||
LibrePodsTheme(designSystem = DesignSystem.Material) {
|
||||
LibrePodsTheme(m3eEnabled = true) {
|
||||
StyledList {
|
||||
StyledToggle(
|
||||
label = "Apple Styled List",
|
||||
@@ -388,7 +376,7 @@ fun StyledToggleM3EListPreview() {
|
||||
@Composable
|
||||
fun StyledToggleM3EPreview() {
|
||||
val checked = remember { mutableStateOf(false) }
|
||||
LibrePodsTheme(designSystem = DesignSystem.Material) {
|
||||
LibrePodsTheme(m3eEnabled = true) {
|
||||
StyledToggle(
|
||||
label = "Material",
|
||||
description = "This is an example description for the styled toggle.",
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
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.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.math.sign
|
||||
|
||||
@Composable
|
||||
fun VerticalVolumeSlider(
|
||||
displayFraction: Float,
|
||||
maxVolume: Int,
|
||||
onVolumeChange: (Int) -> Unit,
|
||||
initialFraction: Float,
|
||||
onDragStateChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
baseSliderHeight: Dp = 400.dp,
|
||||
baseSliderWidth: Dp = 145.dp,
|
||||
baseCornerRadius: Dp = 45.dp,
|
||||
maxStretchFactor: Float = 1.15f,
|
||||
minCompressionFactor: Float = 0.875f,
|
||||
stretchSensitivity: Float = 1.0f,
|
||||
compressionSensitivity: Float = 1.0f,
|
||||
cornerRadiusChangeFactor: Float = 0.2f,
|
||||
directionalStretchRatio: Float = 0.75f
|
||||
) {
|
||||
val trackColor = Color(0x593C3C3E)
|
||||
val progressColor = Color.White
|
||||
|
||||
var dragFraction by remember { mutableFloatStateOf(initialFraction) }
|
||||
var isDragging by remember { mutableStateOf(false) }
|
||||
|
||||
var rawDragPosition by remember { mutableFloatStateOf(initialFraction) }
|
||||
var overscrollAmount by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
val baseHeightPx = with(LocalDensity.current) { baseSliderHeight.toPx() }
|
||||
|
||||
val animatedProgress by animateFloatAsState(
|
||||
targetValue = dragFraction.coerceIn(0f, 1f),
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioLowBouncy,
|
||||
stiffness = Spring.StiffnessMedium
|
||||
),
|
||||
label = "ProgressAnimation"
|
||||
)
|
||||
|
||||
val animatedOverscroll by animateFloatAsState(
|
||||
targetValue = overscrollAmount,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow
|
||||
),
|
||||
label = "OverscrollAnimation"
|
||||
)
|
||||
|
||||
val maxOverscrollEffect = (maxStretchFactor - 1f).coerceAtLeast(0f)
|
||||
|
||||
val stretchMultiplier = stretchSensitivity
|
||||
val compressionMultiplier = compressionSensitivity
|
||||
|
||||
val overscrollDirection = sign(animatedOverscroll)
|
||||
|
||||
val totalStretchAmount = (min(maxOverscrollEffect, abs(animatedOverscroll) * stretchMultiplier) * baseSliderHeight.value).dp
|
||||
|
||||
val offsetY = if (abs(animatedOverscroll) > 0.001f) {
|
||||
val asymmetricOffset = totalStretchAmount * (directionalStretchRatio - 0.5f)
|
||||
(-overscrollDirection * asymmetricOffset.value).dp
|
||||
} else {
|
||||
0.dp
|
||||
}
|
||||
|
||||
val heightStretch = baseSliderHeight + totalStretchAmount
|
||||
|
||||
val widthCompression = baseSliderWidth * max(
|
||||
minCompressionFactor,
|
||||
1f - min(1f - minCompressionFactor, abs(animatedOverscroll) * compressionMultiplier)
|
||||
)
|
||||
|
||||
val dynamicCornerRadius = baseCornerRadius * (1f - min(cornerRadiusChangeFactor, abs(animatedOverscroll) * cornerRadiusChangeFactor * 2f))
|
||||
|
||||
Box(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(heightStretch)
|
||||
.width(widthCompression)
|
||||
.offset(y = offsetY)
|
||||
.clip(RoundedCornerShape(dynamicCornerRadius))
|
||||
.background(trackColor)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { offset ->
|
||||
val newFraction = 1f - (offset.y / size.height).coerceIn(0f, 1f)
|
||||
dragFraction = newFraction
|
||||
rawDragPosition = newFraction
|
||||
overscrollAmount = 0f
|
||||
|
||||
val newVolume = (newFraction * maxVolume).roundToInt()
|
||||
onVolumeChange(newVolume)
|
||||
}
|
||||
}
|
||||
.draggable(
|
||||
orientation = Orientation.Vertical,
|
||||
state = rememberDraggableState { delta ->
|
||||
rawDragPosition -= (delta / baseHeightPx)
|
||||
|
||||
dragFraction = rawDragPosition.coerceIn(0f, 1f)
|
||||
|
||||
overscrollAmount = when {
|
||||
rawDragPosition > 1f -> min(1.0f, (rawDragPosition - 1f) * 2.0f)
|
||||
rawDragPosition < 0f -> max(-1.0f, rawDragPosition * 2.0f)
|
||||
else -> 0f
|
||||
}
|
||||
|
||||
val newVolume = (dragFraction * maxVolume).roundToInt()
|
||||
onVolumeChange(newVolume)
|
||||
},
|
||||
onDragStarted = {
|
||||
isDragging = true
|
||||
dragFraction = displayFraction
|
||||
rawDragPosition = displayFraction
|
||||
overscrollAmount = 0f
|
||||
onDragStateChange(true)
|
||||
},
|
||||
onDragStopped = {
|
||||
isDragging = false
|
||||
overscrollAmount = 0f
|
||||
rawDragPosition = dragFraction
|
||||
onDragStateChange(false)
|
||||
}
|
||||
),
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(animatedProgress)
|
||||
.background(progressColor)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+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
|
||||
}
|
||||
}
|
||||
+277
-57
@@ -26,11 +26,15 @@ import android.animation.ObjectAnimator
|
||||
import android.animation.PropertyValuesHolder
|
||||
import android.animation.ValueAnimator
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.res.Resources
|
||||
import android.graphics.PixelFormat
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log.e
|
||||
@@ -42,6 +46,8 @@ import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.view.animation.AccelerateInterpolator
|
||||
import android.view.animation.AnticipateOvershootInterpolator
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import android.view.animation.OvershootInterpolator
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageButton
|
||||
import android.widget.LinearLayout
|
||||
@@ -53,9 +59,11 @@ import androidx.dynamicanimation.animation.DynamicAnimation
|
||||
import androidx.dynamicanimation.animation.SpringAnimation
|
||||
import androidx.dynamicanimation.animation.SpringForce
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.devices.Battery
|
||||
import me.kavishdevar.librepods.devices.BatteryComponent
|
||||
import me.kavishdevar.librepods.devices.BatteryStatus
|
||||
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.data.Battery
|
||||
import me.kavishdevar.librepods.data.BatteryComponent
|
||||
import me.kavishdevar.librepods.data.BatteryStatus
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.abs
|
||||
|
||||
@@ -98,12 +106,32 @@ class IslandWindow(private val context: Context) {
|
||||
private lateinit var springAnimation: SpringAnimation
|
||||
private val flingAnimator = ValueAnimator()
|
||||
|
||||
private val batteryReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == AirPodsNotifications.BATTERY_DATA) {
|
||||
val batteryList = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableArrayListExtra("data", Battery::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getParcelableArrayListExtra("data")
|
||||
}
|
||||
updateBatteryDisplay(batteryList)
|
||||
} else if (intent?.action == AirPodsNotifications.DISCONNECT_RECEIVERS) {
|
||||
try {
|
||||
context?.unregisterReceiver(this)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val isVisible: Boolean
|
||||
get() = containerView.parent != null && containerView.visibility == View.VISIBLE
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
fun updateBattery(batteryList: Set<Battery>) {
|
||||
if (batteryList.isEmpty()) return
|
||||
private fun updateBatteryDisplay(batteryList: ArrayList<Battery>?) {
|
||||
if (batteryList == null || batteryList.isEmpty()) return
|
||||
|
||||
val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT }
|
||||
val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT }
|
||||
@@ -137,26 +165,39 @@ class IslandWindow(private val context: Context) {
|
||||
@SuppressLint("SetTextI18s", "ClickableViewAccessibility", "UnspecifiedRegisterReceiverFlag",
|
||||
"SetTextI18n"
|
||||
)
|
||||
fun show(
|
||||
name: String,
|
||||
batteryPercentage: Int,
|
||||
context: Context,
|
||||
type: IslandType = IslandType.CONNECTED,
|
||||
reversed: Boolean = false,
|
||||
otherDeviceName: String? = null,
|
||||
onReverseAction: () -> Unit = {}
|
||||
) {
|
||||
fun show(name: String, batteryPercentage: Int, context: Context, type: IslandType = IslandType.CONNECTED, reversed: Boolean = false, otherDeviceName: String? = null) {
|
||||
if (ServiceManager.getService()?.islandOpen == true) return
|
||||
else ServiceManager.getService()?.islandOpen = true
|
||||
|
||||
val displayMetrics = Resources.getSystem().displayMetrics
|
||||
val width = (displayMetrics.widthPixels * 0.95).toInt()
|
||||
screenHeight = displayMetrics.heightPixels
|
||||
|
||||
val batteryList = ServiceManager.getService()?.getBattery()
|
||||
val batteryText = islandView.findViewById<TextView>(R.id.island_battery_text)
|
||||
val batteryProgressBar = islandView.findViewById<ProgressBar>(R.id.island_battery_progress)
|
||||
|
||||
if (batteryPercentage != 0) {
|
||||
batteryText.text = "$batteryPercentage%"
|
||||
batteryProgressBar.progress = batteryPercentage
|
||||
val displayBatteryLevel = if (batteryList != null) {
|
||||
val leftBattery = batteryList.find { it.component == BatteryComponent.LEFT }
|
||||
val rightBattery = batteryList.find { it.component == BatteryComponent.RIGHT }
|
||||
|
||||
when {
|
||||
(leftBattery?.level ?: 0) > 0 && (rightBattery?.level ?: 0) > 0 ->
|
||||
minOf(leftBattery!!.level, rightBattery!!.level)
|
||||
(leftBattery?.level ?: 0) > 0 -> leftBattery!!.level
|
||||
(rightBattery?.level ?: 0) > 0 -> rightBattery!!.level
|
||||
batteryPercentage > 0 -> batteryPercentage
|
||||
else -> null
|
||||
}
|
||||
} else if (batteryPercentage > 0) {
|
||||
batteryPercentage
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
if (displayBatteryLevel != null) {
|
||||
batteryText.text = "$displayBatteryLevel%"
|
||||
batteryProgressBar.progress = displayBatteryLevel
|
||||
} else {
|
||||
batteryText.text = "?"
|
||||
batteryProgressBar.progress = 0
|
||||
@@ -170,7 +211,9 @@ class IslandWindow(private val context: Context) {
|
||||
if (type == IslandType.MOVED_TO_OTHER_DEVICE && !reversed) {
|
||||
actionButton.visibility = View.VISIBLE
|
||||
actionButton.setOnClickListener {
|
||||
onReverseAction()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
ServiceManager.getService()?.takeOver("reverse")
|
||||
}
|
||||
close()
|
||||
}
|
||||
batteryText.visibility = View.GONE
|
||||
@@ -183,6 +226,16 @@ class IslandWindow(private val context: Context) {
|
||||
batteryBg.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
val batteryIntentFilter = IntentFilter(AirPodsNotifications.BATTERY_DATA)
|
||||
batteryIntentFilter.addAction(AirPodsNotifications.DISCONNECT_RECEIVERS)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.registerReceiver(batteryReceiver, batteryIntentFilter, Context.RECEIVER_EXPORTED)
|
||||
} else {
|
||||
context.registerReceiver(batteryReceiver, batteryIntentFilter)
|
||||
}
|
||||
|
||||
ServiceManager.getService()?.sendBatteryBroadcast()
|
||||
|
||||
containerView.removeAllViews()
|
||||
val containerParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.MATCH_PARENT,
|
||||
@@ -271,30 +324,20 @@ class IslandWindow(private val context: Context) {
|
||||
|
||||
if (isBeingDragged) {
|
||||
val currentTranslationY = containerView.translationY
|
||||
abs(yVelocity) > 800
|
||||
val significantDrag = abs(dragDistance) > 80
|
||||
|
||||
if (isDraggingDown && (currentTranslationY > 200 || yVelocity > 1000)) {
|
||||
flingAnimator.cancel()
|
||||
flingAnimator.setFloatValues(currentTranslationY, screenHeight.toFloat())
|
||||
flingAnimator.duration = 300
|
||||
flingAnimator.interpolator = AccelerateInterpolator()
|
||||
flingAnimator.addUpdateListener { animation ->
|
||||
val value = animation.animatedValue as Float
|
||||
containerView.translationY = value
|
||||
when {
|
||||
yVelocity < -1200 || (currentTranslationY < -80 && !isDraggingDown) -> {
|
||||
animateDismissWithInertia(yVelocity)
|
||||
}
|
||||
yVelocity > 1200 || (isDraggingDown && significantDrag) -> {
|
||||
animateExpandWithStretch(yVelocity)
|
||||
}
|
||||
else -> {
|
||||
springBackWithInertia(yVelocity)
|
||||
}
|
||||
flingAnimator.addListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
close()
|
||||
}
|
||||
})
|
||||
flingAnimator.start()
|
||||
} else {
|
||||
springAnimation.cancel()
|
||||
springAnimation.setStartValue(currentTranslationY)
|
||||
springAnimation.start()
|
||||
|
||||
applyCustomStretchEffect(0f)
|
||||
}
|
||||
|
||||
} else if (dragDistance < 10) {
|
||||
resetAutoCloseTimer()
|
||||
}
|
||||
@@ -359,23 +402,10 @@ class IslandWindow(private val context: Context) {
|
||||
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 0.5f, 1f)
|
||||
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 0.5f, 1f)
|
||||
val translationY = PropertyValuesHolder.ofFloat(View.TRANSLATION_Y, -200f, 0f)
|
||||
|
||||
Looper.getMainLooper().let { mainLooper ->
|
||||
if (Looper.myLooper() == mainLooper) {
|
||||
ObjectAnimator.ofPropertyValuesHolder(containerView, scaleX, scaleY, translationY).apply {
|
||||
duration = 700
|
||||
interpolator = AnticipateOvershootInterpolator()
|
||||
start()
|
||||
}
|
||||
} else {
|
||||
Handler(mainLooper).post {
|
||||
ObjectAnimator.ofPropertyValuesHolder(containerView, scaleX, scaleY, translationY).apply {
|
||||
duration = 700
|
||||
interpolator = AnticipateOvershootInterpolator()
|
||||
start()
|
||||
}
|
||||
}
|
||||
}
|
||||
ObjectAnimator.ofPropertyValuesHolder(containerView, scaleX, scaleY, translationY).apply {
|
||||
duration = 700
|
||||
interpolator = AnticipateOvershootInterpolator()
|
||||
start()
|
||||
}
|
||||
|
||||
resetAutoCloseTimer()
|
||||
@@ -446,6 +476,178 @@ class IslandWindow(private val context: Context) {
|
||||
autoCloseHandler?.postDelayed(autoCloseRunnable!!, 4500)
|
||||
}
|
||||
|
||||
private fun springBackWithInertia(velocity: Float) {
|
||||
springAnimation.cancel()
|
||||
flingAnimator.cancel()
|
||||
|
||||
springAnimation.setStartVelocity(velocity)
|
||||
|
||||
val baseStiffness = SpringForce.STIFFNESS_MEDIUM
|
||||
val dynamicStiffness = baseStiffness * (1f + (abs(velocity) / 3000f))
|
||||
springAnimation.spring = SpringForce(0f)
|
||||
.setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY)
|
||||
.setStiffness(dynamicStiffness)
|
||||
|
||||
resetStretchEffects()
|
||||
|
||||
if (params != null) {
|
||||
params!!.height = WindowManager.LayoutParams.WRAP_CONTENT
|
||||
try {
|
||||
windowManager.updateViewLayout(containerView, params)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
springAnimation.start()
|
||||
}
|
||||
|
||||
private fun resetStretchEffects() {
|
||||
try {
|
||||
val mainLayout = islandView.findViewById<LinearLayout>(R.id.island_window_layout)
|
||||
val deviceText = islandView.findViewById<TextView>(R.id.island_device_name)
|
||||
|
||||
val heightAnimator = ValueAnimator.ofInt(mainLayout.minimumHeight, initialHeight)
|
||||
heightAnimator.duration = 300
|
||||
heightAnimator.interpolator = OvershootInterpolator(1.5f)
|
||||
heightAnimator.addUpdateListener { animation ->
|
||||
mainLayout.minimumHeight = animation.animatedValue as Int
|
||||
}
|
||||
|
||||
val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams
|
||||
val textMarginAnimator = ValueAnimator.ofInt(deviceTextParams.topMargin, 0)
|
||||
textMarginAnimator.duration = 300
|
||||
textMarginAnimator.interpolator = OvershootInterpolator(1.5f)
|
||||
textMarginAnimator.addUpdateListener { animation ->
|
||||
deviceTextParams.topMargin = animation.animatedValue as Int
|
||||
deviceText.layoutParams = deviceTextParams
|
||||
}
|
||||
|
||||
heightAnimator.start()
|
||||
textMarginAnimator.start()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun animateDismissWithInertia(velocity: Float) {
|
||||
springAnimation.cancel()
|
||||
flingAnimator.cancel()
|
||||
|
||||
val baseDistance = -screenHeight
|
||||
val velocityFactor = (abs(velocity) / 2000f).coerceIn(0.5f, 2.0f)
|
||||
val targetDistance = baseDistance * velocityFactor
|
||||
|
||||
val baseDuration = 400L
|
||||
val velocityDurationFactor = (1500f / (abs(velocity) + 1500f))
|
||||
val duration = (baseDuration * velocityDurationFactor).toLong().coerceIn(200L, 500L)
|
||||
|
||||
flingAnimator.setFloatValues(containerView.translationY, targetDistance)
|
||||
flingAnimator.duration = duration
|
||||
flingAnimator.addUpdateListener { animation ->
|
||||
containerView.translationY = animation.animatedValue as Float
|
||||
|
||||
val progress = animation.animatedFraction
|
||||
containerView.scaleX = 1f - (progress * 0.5f)
|
||||
containerView.scaleY = 1f - (progress * 0.5f)
|
||||
|
||||
containerView.alpha = 1f - progress
|
||||
}
|
||||
flingAnimator.addListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
forceClose()
|
||||
}
|
||||
})
|
||||
|
||||
flingAnimator.interpolator = DecelerateInterpolator(1.2f)
|
||||
flingAnimator.start()
|
||||
}
|
||||
|
||||
private fun animateExpandWithStretch(velocity: Float) {
|
||||
springAnimation.cancel()
|
||||
flingAnimator.cancel()
|
||||
|
||||
val baseDuration = 600L
|
||||
val velocityFactor = (1800f / (abs(velocity) + 1800f)).coerceIn(0.5f, 1.5f)
|
||||
val expandDuration = (baseDuration * velocityFactor).toLong().coerceIn(300L, 700L)
|
||||
|
||||
if (params != null) {
|
||||
params!!.height = screenHeight
|
||||
try {
|
||||
windowManager.updateViewLayout(containerView, params)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
val containerAnimator = ValueAnimator.ofFloat(containerView.translationY, screenHeight * 0.6f)
|
||||
containerAnimator.duration = expandDuration
|
||||
containerAnimator.interpolator = DecelerateInterpolator(0.8f)
|
||||
containerAnimator.addUpdateListener { animation ->
|
||||
containerView.translationY = animation.animatedValue as Float
|
||||
}
|
||||
|
||||
val stretchAnimator = ValueAnimator.ofFloat(0f, 1f)
|
||||
stretchAnimator.duration = expandDuration
|
||||
stretchAnimator.interpolator = OvershootInterpolator(0.5f)
|
||||
stretchAnimator.addUpdateListener { animation ->
|
||||
val progress = animation.animatedValue as Float
|
||||
animateCustomStretch(progress)
|
||||
}
|
||||
|
||||
val normalizeAnimator = ValueAnimator.ofFloat(1.0f, 0.0f)
|
||||
normalizeAnimator.duration = 300
|
||||
normalizeAnimator.startDelay = expandDuration - 150
|
||||
normalizeAnimator.interpolator = AccelerateInterpolator(1.2f)
|
||||
normalizeAnimator.addUpdateListener { animation ->
|
||||
val progress = animation.animatedValue as Float
|
||||
containerView.alpha = progress
|
||||
|
||||
if (progress < 0.7f) {
|
||||
islandView.findViewById<VideoView>(R.id.island_video_view).visibility = View.GONE
|
||||
}
|
||||
}
|
||||
normalizeAnimator.addListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(animation: Animator) {
|
||||
ServiceManager.getService()?.startMainActivity()
|
||||
forceClose()
|
||||
}
|
||||
})
|
||||
|
||||
containerAnimator.start()
|
||||
stretchAnimator.start()
|
||||
normalizeAnimator.start()
|
||||
}
|
||||
|
||||
private fun animateCustomStretch(progress: Float) {
|
||||
try {
|
||||
val mainLayout = islandView.findViewById<LinearLayout>(R.id.island_window_layout)
|
||||
val connectedText = islandView.findViewById<TextView>(R.id.island_connected_text)
|
||||
val deviceText = islandView.findViewById<TextView>(R.id.island_device_name)
|
||||
|
||||
val targetHeight = (screenHeight * 0.7f).toInt()
|
||||
val currentHeight = initialHeight + ((targetHeight - initialHeight) * progress)
|
||||
mainLayout.minimumHeight = currentHeight.toInt()
|
||||
|
||||
val mainLayoutParams = mainLayout.layoutParams
|
||||
mainLayoutParams.height = LinearLayout.LayoutParams.MATCH_PARENT
|
||||
mainLayout.layoutParams = mainLayoutParams
|
||||
|
||||
val targetMargin = (400 * progress).toInt()
|
||||
val deviceTextParams = deviceText.layoutParams as LinearLayout.LayoutParams
|
||||
deviceTextParams.topMargin = targetMargin
|
||||
deviceText.layoutParams = deviceTextParams
|
||||
|
||||
val baseTextSize = 24f
|
||||
deviceText.textSize = baseTextSize + (progress * 8f)
|
||||
|
||||
val baseSubTextSize = 16f
|
||||
connectedText.textSize = baseSubTextSize + (progress * 4f)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
if (Looper.myLooper() != Looper.getMainLooper()) {
|
||||
Handler(Looper.getMainLooper()).post { close() }
|
||||
@@ -455,8 +657,17 @@ class IslandWindow(private val context: Context) {
|
||||
if (isClosing) return
|
||||
isClosing = true
|
||||
|
||||
try {
|
||||
context.unregisterReceiver(batteryReceiver)
|
||||
} catch (e: Exception) {
|
||||
// e.printStackTrace()
|
||||
}
|
||||
|
||||
ServiceManager.getService()?.islandOpen = false
|
||||
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return)
|
||||
|
||||
resetStretchEffects()
|
||||
|
||||
val videoView = islandView.findViewById<VideoView>(R.id.island_video_view)
|
||||
try {
|
||||
videoView.stopPlayback()
|
||||
@@ -502,7 +713,7 @@ class IslandWindow(private val context: Context) {
|
||||
e("IslandWindow", "Error removing view: $e")
|
||||
}
|
||||
isClosing = false
|
||||
|
||||
// Make sure all animations are canceled
|
||||
try {
|
||||
springAnimation.cancel()
|
||||
} catch (e: Exception) {
|
||||
@@ -524,11 +735,20 @@ class IslandWindow(private val context: Context) {
|
||||
if (isClosing) return
|
||||
isClosing = true
|
||||
|
||||
try {
|
||||
context.unregisterReceiver(batteryReceiver)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
ServiceManager.getService()?.islandOpen = false
|
||||
autoCloseHandler?.removeCallbacks(autoCloseRunnable ?: return)
|
||||
|
||||
// Cancel all ongoing animations
|
||||
springAnimation.cancel()
|
||||
flingAnimator.cancel()
|
||||
|
||||
// Immediately remove the view without animations
|
||||
cleanupAndRemoveView()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
+57
-10
@@ -24,9 +24,13 @@ import android.animation.AnimatorListenerAdapter
|
||||
import android.animation.ObjectAnimator
|
||||
import android.animation.PropertyValuesHolder
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.PixelFormat
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
@@ -42,9 +46,10 @@ import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.VideoView
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.devices.Battery
|
||||
import me.kavishdevar.librepods.devices.BatteryComponent
|
||||
import me.kavishdevar.librepods.devices.BatteryStatus
|
||||
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.data.Battery
|
||||
import me.kavishdevar.librepods.data.BatteryComponent
|
||||
import me.kavishdevar.librepods.data.BatteryStatus
|
||||
|
||||
@SuppressLint("InflateParams", "ClickableViewAccessibility")
|
||||
class PopupWindow(
|
||||
@@ -55,6 +60,7 @@ class PopupWindow(
|
||||
private var isClosing = false
|
||||
private var autoCloseHandler = Handler(Looper.getMainLooper())
|
||||
private var autoCloseRunnable: Runnable? = null
|
||||
private var batteryUpdateReceiver: BroadcastReceiver? = null
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private val mParams: WindowManager.LayoutParams = WindowManager.LayoutParams().apply {
|
||||
@@ -124,12 +130,12 @@ class PopupWindow(
|
||||
}
|
||||
|
||||
@SuppressLint("InlinedApi", "SetTextI18s")
|
||||
fun open(name: String = "AirPods Pro", batteries: Set<Battery>) {
|
||||
fun open(name: String = "AirPods Pro", batteryNotification: AirPodsNotifications.BatteryNotification) {
|
||||
try {
|
||||
if (mView.windowToken == null && mView.parent == null && !isClosing) {
|
||||
mView.findViewById<TextView>(R.id.name).text = name
|
||||
|
||||
updateBatteryStatus(batteries)
|
||||
updateBatteryStatus(batteryNotification)
|
||||
|
||||
val vid = mView.findViewById<VideoView>(R.id.video)
|
||||
vid.setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE)
|
||||
@@ -160,6 +166,8 @@ class PopupWindow(
|
||||
start()
|
||||
}
|
||||
|
||||
registerBatteryUpdateReceiver()
|
||||
|
||||
autoCloseRunnable = Runnable { close() }
|
||||
autoCloseHandler.postDelayed(autoCloseRunnable!!, 12000)
|
||||
}
|
||||
@@ -169,6 +177,43 @@ class PopupWindow(
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||
private fun registerBatteryUpdateReceiver() {
|
||||
batteryUpdateReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (intent?.action == AirPodsNotifications.BATTERY_DATA) {
|
||||
val batteryList = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableArrayListExtra("data", Battery::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getParcelableArrayListExtra("data")
|
||||
}
|
||||
if (batteryList != null) {
|
||||
updateBatteryStatusFromList(batteryList)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val filter = IntentFilter(AirPodsNotifications.BATTERY_DATA)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.registerReceiver(batteryUpdateReceiver, filter, Context.RECEIVER_EXPORTED)
|
||||
} else {
|
||||
context.registerReceiver(batteryUpdateReceiver, filter)
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterBatteryUpdateReceiver() {
|
||||
batteryUpdateReceiver?.let {
|
||||
try {
|
||||
context.unregisterReceiver(it)
|
||||
batteryUpdateReceiver = null
|
||||
} catch (e: Exception) {
|
||||
Log.e("PopupWindow", "Error unregistering battery receiver: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateBatteryStatusFromList(batteryList: List<Battery>) {
|
||||
val batteryLeftText = mView.findViewById<TextView>(R.id.left_battery)
|
||||
val batteryRightText = mView.findViewById<TextView>(R.id.right_battery)
|
||||
@@ -176,7 +221,7 @@ class PopupWindow(
|
||||
|
||||
batteryLeftText.text = batteryList.find { it.component == BatteryComponent.LEFT }?.let {
|
||||
if (it.status != BatteryStatus.DISCONNECTED) {
|
||||
"L ${it.level}%"
|
||||
"\uDBC3\uDC8E ${it.level}%"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
@@ -184,7 +229,7 @@ class PopupWindow(
|
||||
|
||||
batteryRightText.text = batteryList.find { it.component == BatteryComponent.RIGHT }?.let {
|
||||
if (it.status != BatteryStatus.DISCONNECTED) {
|
||||
"R ${it.level}%"
|
||||
"\uDBC3\uDC8D ${it.level}%"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
@@ -192,7 +237,7 @@ class PopupWindow(
|
||||
|
||||
batteryCaseText.text = batteryList.find { it.component == BatteryComponent.CASE }?.let {
|
||||
if (it.status != BatteryStatus.DISCONNECTED) {
|
||||
"C ${it.level}%"
|
||||
"\uDBC3\uDE6C ${it.level}%"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
@@ -200,8 +245,9 @@ class PopupWindow(
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18s")
|
||||
fun updateBatteryStatus(batteries: Set<Battery>) {
|
||||
updateBatteryStatusFromList(batteries.toList())
|
||||
fun updateBatteryStatus(batteryNotification: AirPodsNotifications.BatteryNotification) {
|
||||
val batteryStatus = batteryNotification.getBattery()
|
||||
updateBatteryStatusFromList(batteryStatus)
|
||||
}
|
||||
|
||||
fun close() {
|
||||
@@ -210,6 +256,7 @@ class PopupWindow(
|
||||
isClosing = true
|
||||
|
||||
autoCloseRunnable?.let { autoCloseHandler.removeCallbacks(it) }
|
||||
unregisterBatteryUpdateReceiver()
|
||||
|
||||
val vid = mView.findViewById<VideoView>(R.id.video)
|
||||
vid.stopPlayback()
|
||||
+261
-250
@@ -16,17 +16,26 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.presentation.screens.apple
|
||||
package me.kavishdevar.librepods.presentation.screens
|
||||
|
||||
// import me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -42,298 +51,301 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
|
||||
import me.kavishdevar.librepods.bluetooth.att.ATTHandle
|
||||
import me.kavishdevar.librepods.devices.AirPodsSpecs
|
||||
import me.kavishdevar.librepods.devices.BaseCapability
|
||||
import me.kavishdevar.librepods.presentation.components.primitives.StyledButton
|
||||
import me.kavishdevar.librepods.presentation.components.primitives.StyledList
|
||||
import me.kavishdevar.librepods.presentation.components.primitives.StyledListItem
|
||||
import me.kavishdevar.librepods.presentation.components.primitives.StyledScaffold
|
||||
import me.kavishdevar.librepods.presentation.components.primitives.StyledSlider
|
||||
import me.kavishdevar.librepods.presentation.components.primitives.StyledToggle
|
||||
import me.kavishdevar.librepods.presentation.icons.LocalIcons
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||
import me.kavishdevar.librepods.data.Capability
|
||||
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.components.StyledSlider
|
||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
//private var phoneMediaDebounceJob: Job? = null
|
||||
private var phoneMediaDebounceJob: Job? = null
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class, FlowPreview::class)
|
||||
@Composable
|
||||
fun AccessibilitySettingsScreen(
|
||||
viewModel: AppleViewModel,
|
||||
navigateBack: (() -> Unit)?,
|
||||
navigateToPurchase: () -> Unit,
|
||||
navigateToTransparencyCustomization: () -> Unit
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
val state = uiState.state
|
||||
val metadata = uiState.metadata
|
||||
fun AccessibilitySettingsScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit, navigateToTransparencyCustomization: () -> Unit) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
val hearingAidEnabled =
|
||||
state.controlStates[ControlCommandIdentifier.HEARING_AID]?.getOrNull(
|
||||
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(
|
||||
1
|
||||
)
|
||||
?.toInt() == 1 && state.controlStates[ControlCommandIdentifier.HEARING_AID]?.getOrNull(
|
||||
?.toInt() == 1 && state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(
|
||||
0
|
||||
)?.toInt() == 1
|
||||
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.accessibility),
|
||||
navigateBack = navigateBack
|
||||
) { topPadding, bottomPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
||||
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
||||
|
||||
if (!uiState.isPremium) {
|
||||
StyledButton(
|
||||
onClick = navigateToPurchase,
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
surfaceColor = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.unlock_advanced_features),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
|
||||
if (!state.isPremium) {
|
||||
StyledButton(
|
||||
onClick = navigateToPurchase,
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
surfaceColor = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.unlock_advanced_features),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
// val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
|
||||
// val phoneEQEnabled = remember { mutableStateOf(false) }
|
||||
// val mediaEQEnabled = remember { mutableStateOf(false) }
|
||||
|
||||
val pressSpeedOptions = mapOf(
|
||||
0.toByte() to stringResource(R.string.default_option),
|
||||
1.toByte() to stringResource(R.string.slower),
|
||||
2.toByte() to stringResource(R.string.slowest)
|
||||
val pressSpeedOptions = mapOf(
|
||||
0.toByte() to stringResource(R.string.default_option),
|
||||
1.toByte() to stringResource(R.string.slower),
|
||||
2.toByte() to stringResource(R.string.slowest)
|
||||
)
|
||||
|
||||
val selectedPressSpeedValue =
|
||||
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL]?.getOrNull(
|
||||
0
|
||||
)
|
||||
|
||||
val selectedPressSpeedValue =
|
||||
state.controlStates[ControlCommandIdentifier.DOUBLE_CLICK_INTERVAL]?.getOrNull(
|
||||
0
|
||||
)
|
||||
var selectedPressSpeed by remember {
|
||||
mutableStateOf(
|
||||
pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]
|
||||
)
|
||||
}
|
||||
|
||||
val pressAndHoldDurationOptions = mapOf(
|
||||
0.toByte() to stringResource(R.string.default_option),
|
||||
1.toByte() to stringResource(R.string.slower),
|
||||
2.toByte() to stringResource(R.string.slowest)
|
||||
var selectedPressSpeed by remember {
|
||||
mutableStateOf(
|
||||
pressSpeedOptions[selectedPressSpeedValue] ?: pressSpeedOptions[0]
|
||||
)
|
||||
}
|
||||
|
||||
val selectedPressAndHoldDurationValue =
|
||||
state.controlStates[ControlCommandIdentifier.CLICK_HOLD_INTERVAL]?.getOrNull(
|
||||
0
|
||||
)
|
||||
var selectedPressAndHoldDuration by remember {
|
||||
mutableStateOf(
|
||||
pressAndHoldDurationOptions[selectedPressAndHoldDurationValue]
|
||||
?: pressAndHoldDurationOptions[0]
|
||||
)
|
||||
}
|
||||
val pressAndHoldDurationOptions = mapOf(
|
||||
0.toByte() to stringResource(R.string.default_option),
|
||||
1.toByte() to stringResource(R.string.slower),
|
||||
2.toByte() to stringResource(R.string.slowest)
|
||||
)
|
||||
|
||||
val volumeSwipeSpeedOptions = mapOf(
|
||||
1.toByte() to stringResource(R.string.default_option),
|
||||
2.toByte() to stringResource(R.string.longer),
|
||||
3.toByte() to stringResource(R.string.longest)
|
||||
val selectedPressAndHoldDurationValue =
|
||||
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL]?.getOrNull(
|
||||
0
|
||||
)
|
||||
val selectedVolumeSwipeSpeedValue =
|
||||
state.controlStates[ControlCommandIdentifier.VOLUME_SWIPE_INTERVAL]?.getOrNull(
|
||||
0
|
||||
)
|
||||
var selectedVolumeSwipeSpeed by remember {
|
||||
mutableStateOf(
|
||||
volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue]
|
||||
?: volumeSwipeSpeedOptions[1]
|
||||
)
|
||||
}
|
||||
var selectedPressAndHoldDuration by remember {
|
||||
mutableStateOf(
|
||||
pressAndHoldDurationOptions[selectedPressAndHoldDurationValue]
|
||||
?: pressAndHoldDurationOptions[0]
|
||||
)
|
||||
}
|
||||
|
||||
// val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
|
||||
// val phoneEQEnabled = remember { mutableStateOf(false) }
|
||||
// val mediaEQEnabled = remember { mutableStateOf(false) }
|
||||
//
|
||||
// LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
|
||||
// phoneMediaDebounceJob?.cancel()
|
||||
// phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
// delay(150.milliseconds)
|
||||
// try {
|
||||
// val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
|
||||
// val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
|
||||
// Log.d(
|
||||
// "AccessibilitySettingsScreen",
|
||||
// "Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})"
|
||||
// )
|
||||
// viewModel.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
|
||||
// } catch (e: Exception) {
|
||||
// Log.w(
|
||||
// "AccessibilitySettingsScreen",
|
||||
// "Error sending phone/media EQ: ${e.message}"
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
val volumeSwipeSpeedOptions = mapOf(
|
||||
1.toByte() to stringResource(R.string.default_option),
|
||||
2.toByte() to stringResource(R.string.longer),
|
||||
3.toByte() to stringResource(R.string.longest)
|
||||
)
|
||||
val selectedVolumeSwipeSpeedValue =
|
||||
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL]?.getOrNull(
|
||||
0
|
||||
)
|
||||
var selectedVolumeSwipeSpeed by remember {
|
||||
mutableStateOf(
|
||||
volumeSwipeSpeedOptions[selectedVolumeSwipeSpeedValue]
|
||||
?: volumeSwipeSpeedOptions[1]
|
||||
)
|
||||
}
|
||||
|
||||
StyledList(
|
||||
title = stringResource(R.string.press_speed),
|
||||
description = stringResource(R.string.press_speed_description)
|
||||
) {
|
||||
pressSpeedOptions.forEach { (value, label) ->
|
||||
StyledListItem(
|
||||
contentText = label,
|
||||
selected = selectedPressSpeed == label,
|
||||
onClick = {
|
||||
selectedPressSpeed = label
|
||||
val phoneMediaEQ = remember { mutableStateOf(FloatArray(8) { 0.5f }) }
|
||||
val phoneEQEnabled = remember { mutableStateOf(false) }
|
||||
val mediaEQEnabled = remember { mutableStateOf(false) }
|
||||
|
||||
viewModel.setControlCommand(
|
||||
identifier = ControlCommandIdentifier.DOUBLE_CLICK_INTERVAL,
|
||||
value = value
|
||||
)
|
||||
}
|
||||
LaunchedEffect(phoneMediaEQ.value, phoneEQEnabled.value, mediaEQEnabled.value) {
|
||||
phoneMediaDebounceJob?.cancel()
|
||||
phoneMediaDebounceJob = CoroutineScope(Dispatchers.IO).launch {
|
||||
delay(150.milliseconds)
|
||||
try {
|
||||
val phoneByte = if (phoneEQEnabled.value) 0x01.toByte() else 0x02.toByte()
|
||||
val mediaByte = if (mediaEQEnabled.value) 0x01.toByte() else 0x02.toByte()
|
||||
Log.d(
|
||||
"AccessibilitySettingsScreen",
|
||||
"Sending phone/media EQ (phoneEnabled=${phoneEQEnabled.value}, mediaEnabled=${mediaEQEnabled.value})"
|
||||
)
|
||||
viewModel.sendPhoneMediaEQ(phoneMediaEQ.value, phoneByte, mediaByte)
|
||||
} catch (e: Exception) {
|
||||
Log.w(
|
||||
"AccessibilitySettingsScreen",
|
||||
"Error sending phone/media EQ: ${e.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
StyledList(
|
||||
title = stringResource(R.string.press_and_hold_duration),
|
||||
description = stringResource(R.string.press_and_hold_duration_description)
|
||||
) {
|
||||
pressAndHoldDurationOptions.forEach { (value, label) ->
|
||||
StyledListItem(
|
||||
contentText = label,
|
||||
selected = selectedPressAndHoldDuration == label,
|
||||
onClick = {
|
||||
selectedPressAndHoldDuration = label
|
||||
|
||||
viewModel.setControlCommand(
|
||||
identifier = ControlCommandIdentifier.CLICK_HOLD_INTERVAL,
|
||||
value = value
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.noise_control),
|
||||
label = stringResource(R.string.noise_cancellation_single_airpod),
|
||||
description = stringResource(R.string.noise_cancellation_single_airpod_description),
|
||||
checked = state.controlStates[ControlCommandIdentifier.ONE_BUD_ANC_MODE]?.getOrNull(
|
||||
0
|
||||
) == 0x01.toByte(),
|
||||
onCheckedChange = {
|
||||
viewModel.setControlCommand(
|
||||
ControlCommandIdentifier.ONE_BUD_ANC_MODE, it
|
||||
)
|
||||
},
|
||||
enabled = uiState.isPremium
|
||||
)
|
||||
|
||||
if (AirPodsSpecs.getSpec(metadata.model).baseCapabilities.contains(BaseCapability.LOUD_SOUND_REDUCTION) && uiState.vendorIdHook) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.loud_sound_reduction),
|
||||
description = stringResource(R.string.loud_sound_reduction_description),
|
||||
checked = state.loudSoundReductionEnabled,
|
||||
onCheckedChange = {
|
||||
viewModel.writeATTCharacteristic(
|
||||
ATTHandle.LOUD_SOUND_REDUCTION,
|
||||
if (it) byteArrayOf(0x01) else byteArrayOf(0x00)
|
||||
)
|
||||
},
|
||||
enabled = uiState.isPremium
|
||||
)
|
||||
}
|
||||
|
||||
if (!hearingAidEnabled && uiState.vendorIdHook) {
|
||||
StyledList(
|
||||
title = stringResource(R.string.press_speed),
|
||||
description = stringResource(R.string.press_speed_description)
|
||||
) {
|
||||
pressSpeedOptions.forEach { (value, label) ->
|
||||
StyledListItem(
|
||||
contentText = stringResource(R.string.customize_transparency_mode),
|
||||
onClick = navigateToTransparencyCustomization,
|
||||
enabled = uiState.isPremium
|
||||
)
|
||||
}
|
||||
name = label,
|
||||
selected = selectedPressSpeed == label,
|
||||
onClick = {
|
||||
selectedPressSpeed = label
|
||||
|
||||
val toneVolumeValue = remember { mutableFloatStateOf(state.controlStates[ControlCommandIdentifier.CHIME_VOLUME]?.getOrNull(0)?.toFloat() ?: 75f) }
|
||||
|
||||
LaunchedEffect(toneVolumeValue) {
|
||||
snapshotFlow {
|
||||
toneVolumeValue.floatValue
|
||||
}
|
||||
.debounce(100.milliseconds)
|
||||
.collect {
|
||||
viewModel.setControlCommand(
|
||||
ControlCommandIdentifier.CHIME_VOLUME,
|
||||
byteArrayOf(it.toInt().toByte(), 0x50)
|
||||
viewModel.setControlCommandByte(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
|
||||
value = value
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.tone_volume),
|
||||
description = stringResource(R.string.tone_volume_description),
|
||||
value = toneVolumeValue.floatValue,
|
||||
onValueChange = {
|
||||
toneVolumeValue.floatValue = it
|
||||
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
|
||||
|
||||
viewModel.setControlCommandByte(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
|
||||
value = value
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.noise_control),
|
||||
label = stringResource(R.string.noise_cancellation_single_airpod),
|
||||
description = stringResource(R.string.noise_cancellation_single_airpod_description),
|
||||
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE]?.getOrNull(
|
||||
0
|
||||
) == 0x01.toByte(),
|
||||
onCheckedChange = {
|
||||
viewModel.setControlCommandBoolean(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.ONE_BUD_ANC_MODE, it
|
||||
)
|
||||
},
|
||||
enabled = state.isPremium
|
||||
)
|
||||
|
||||
if (state.capabilities.contains(Capability.LOUD_SOUND_REDUCTION) && state.vendorIdHook) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.loud_sound_reduction),
|
||||
description = stringResource(R.string.loud_sound_reduction_description),
|
||||
checked = state.loudSoundReductionEnabled,
|
||||
onCheckedChange = {
|
||||
viewModel.setATTCharacteristicValue(
|
||||
ATTHandles.LOUD_SOUND_REDUCTION,
|
||||
if (it) byteArrayOf(0x01) else byteArrayOf(0x00)
|
||||
)
|
||||
},
|
||||
valueRange = 0f..100f,
|
||||
snapPoints = listOf(75f),
|
||||
startImageVector = LocalIcons.current.SpeakerMin,
|
||||
endImageVector = LocalIcons.current.SpeakerMax,
|
||||
independent = true,
|
||||
enabled = uiState.isPremium
|
||||
enabled = state.isPremium
|
||||
)
|
||||
}
|
||||
|
||||
if (!hearingAidEnabled && state.vendorIdHook) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.customize_transparency_mode),
|
||||
onClick = navigateToTransparencyCustomization,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
}
|
||||
|
||||
val toneVolumeValue = remember { mutableFloatStateOf(state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME]?.getOrNull(0)?.toFloat() ?: 75f) }
|
||||
|
||||
LaunchedEffect(toneVolumeValue) {
|
||||
snapshotFlow {
|
||||
toneVolumeValue.floatValue
|
||||
}
|
||||
.debounce(100.milliseconds)
|
||||
.collect {
|
||||
viewModel.setControlCommandValue(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.CHIME_VOLUME,
|
||||
byteArrayOf(it.toInt().toByte(), 0x50)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.tone_volume),
|
||||
description = stringResource(R.string.tone_volume_description),
|
||||
value = toneVolumeValue.floatValue,
|
||||
onValueChange = {
|
||||
toneVolumeValue.floatValue = it
|
||||
},
|
||||
valueRange = 0f..100f,
|
||||
snapPoints = listOf(75f),
|
||||
startIcon = "\uDBC0\uDEA1",
|
||||
endIcon = "\uDBC0\uDEA9",
|
||||
independent = true,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
|
||||
if (state.capabilities.contains(Capability.SWIPE_FOR_VOLUME)) {
|
||||
val volumeSwipeEnabled =
|
||||
state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE]?.getOrNull(
|
||||
0
|
||||
)?.toInt() == 0x01
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.volume_control),
|
||||
description = stringResource(R.string.volume_control_description),
|
||||
checked = volumeSwipeEnabled,
|
||||
onCheckedChange = {
|
||||
viewModel.setControlCommandBoolean(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_MODE, it
|
||||
)
|
||||
},
|
||||
enabled = state.isPremium
|
||||
)
|
||||
|
||||
if (AirPodsSpecs.getSpec(metadata.model).baseCapabilities.contains(BaseCapability.SWIPE_FOR_VOLUME)) {
|
||||
val volumeSwipeEnabled =
|
||||
state.controlStates[ControlCommandIdentifier.VOLUME_SWIPE_MODE]?.getOrNull(
|
||||
0
|
||||
)?.toInt() == 0x01
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.volume_control),
|
||||
description = stringResource(R.string.volume_control_description),
|
||||
checked = volumeSwipeEnabled,
|
||||
onCheckedChange = {
|
||||
viewModel.setControlCommand(
|
||||
ControlCommandIdentifier.VOLUME_SWIPE_MODE, it
|
||||
)
|
||||
},
|
||||
enabled = uiState.isPremium
|
||||
)
|
||||
StyledList(
|
||||
title = stringResource(R.string.volume_swipe_speed),
|
||||
description = stringResource(R.string.volume_swipe_speed_description)
|
||||
) {
|
||||
volumeSwipeSpeedOptions.forEach { (value, label) ->
|
||||
StyledListItem(
|
||||
name = label,
|
||||
selected = selectedVolumeSwipeSpeed == label,
|
||||
onClick = {
|
||||
selectedVolumeSwipeSpeed = label
|
||||
|
||||
StyledList(
|
||||
title = stringResource(R.string.volume_swipe_speed),
|
||||
description = stringResource(R.string.volume_swipe_speed_description)
|
||||
) {
|
||||
volumeSwipeSpeedOptions.forEach { (value, label) ->
|
||||
StyledListItem(
|
||||
contentText = label,
|
||||
selected = selectedVolumeSwipeSpeed == label,
|
||||
onClick = {
|
||||
selectedVolumeSwipeSpeed = label
|
||||
|
||||
viewModel.setControlCommand(
|
||||
identifier = ControlCommandIdentifier.VOLUME_SWIPE_INTERVAL,
|
||||
value = value
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
viewModel.setControlCommandByte(
|
||||
identifier = AACPManager.Companion.ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
|
||||
value = value
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if (!hearingAidEnabled && XposedState.isAvailable) {
|
||||
// Text(
|
||||
@@ -568,7 +580,6 @@ fun AccessibilitySettingsScreen(
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
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.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
|
||||
@Composable
|
||||
fun AdaptiveStrengthScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
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
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
if (!state.isPremium) {
|
||||
StyledButton(
|
||||
onClick = navigateToPurchase,
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
surfaceColor = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.unlock_advanced_features),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
val sliderValue = remember {
|
||||
mutableFloatStateOf(100f - (state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH]?.getOrNull(0)?.toFloat() ?: 50f))
|
||||
}
|
||||
var job by remember { mutableStateOf<Job?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.customize_adaptive_audio),
|
||||
value = sliderValue.floatValue,
|
||||
onValueChange = {
|
||||
sliderValue.floatValue = it
|
||||
job?.cancel()
|
||||
job = scope.launch {
|
||||
delay(150)
|
||||
viewModel.setControlCommandValue(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
|
||||
byteArrayOf((100 - it).toInt().toByte())
|
||||
)
|
||||
}
|
||||
},
|
||||
valueRange = 0f..100f,
|
||||
snapPoints = listOf(0f, 50f, 100f),
|
||||
startIcon = "",
|
||||
endIcon = "",
|
||||
independent = true,
|
||||
description = stringResource(R.string.adaptive_audio_description),
|
||||
enabled = state.isPremium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
+1042
File diff suppressed because it is too large
Load Diff
+613
@@ -0,0 +1,613 @@
|
||||
/*
|
||||
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 android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.lerp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import me.kavishdevar.librepods.BuildConfig
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.components.AppInfoCard
|
||||
import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
|
||||
import me.kavishdevar.librepods.presentation.components.StyledBottomSheet
|
||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||
import me.kavishdevar.librepods.presentation.components.StyledIconButton
|
||||
import me.kavishdevar.librepods.presentation.components.StyledInputField
|
||||
import me.kavishdevar.librepods.presentation.components.StyledList
|
||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.MaterialTypography
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AppSettingsViewModel
|
||||
import me.kavishdevar.librepods.utils.XposedState
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AppSettingsScreen(
|
||||
viewModel: AppSettingsViewModel = viewModel(),
|
||||
navigateToPurchase: () -> Unit,
|
||||
navigateToTroubleshooting: () -> Unit,
|
||||
navigateToOpenSourceLicenses: () -> Unit,
|
||||
navigateToReleaseNotesScreen: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scrollState = rememberScrollState()
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
val contactBottomSheet = remember { mutableStateOf(false) }
|
||||
val subjectState = remember { TextFieldState() }
|
||||
val descriptionState = remember { TextFieldState() }
|
||||
val subjectFocusRequester = remember { FocusRequester() }
|
||||
val descriptionFocusRequester = remember { FocusRequester() }
|
||||
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
val topPadding = if (m3eEnabled) 16.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
||||
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.layerBackdrop(backdrop)
|
||||
.verticalScroll(scrollState)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
|
||||
if (!state.isPremium && state.connectionSuccessful) {
|
||||
StyledButton(
|
||||
onClick = navigateToPurchase,
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
surfaceColor = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.unlock_advanced_features),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
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(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
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) {
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.widget),
|
||||
label = stringResource(R.string.show_phone_battery_in_widget),
|
||||
description = stringResource(R.string.show_phone_battery_in_widget_description),
|
||||
checked = state.showPhoneBatteryInWidget,
|
||||
onCheckedChange = viewModel::setShowPhoneBatteryInWidget,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
|
||||
StyledList(title = stringResource(R.string.popup_animations)) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.show_bottom_sheet_popup),
|
||||
description = stringResource(R.string.show_bottom_sheet_popup_description),
|
||||
checked = state.showBottomSheetPopup,
|
||||
onCheckedChange = viewModel::setShowBottomSheetPopup,
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.show_island_popup),
|
||||
description = stringResource(R.string.show_island_popup_description),
|
||||
checked = state.showIslandPopup,
|
||||
onCheckedChange = viewModel::setShowIslandPopup,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
StyledList (title = stringResource(R.string.conversational_awareness)) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.conversational_awareness_pause_music),
|
||||
description = stringResource(R.string.conversational_awareness_pause_music_description),
|
||||
checked = state.conversationalAwarenessPauseMusicEnabled,
|
||||
onCheckedChange = viewModel::setConversationalAwarenessPauseMusicEnabled,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.relative_conversational_awareness_volume),
|
||||
description = stringResource(R.string.relative_conversational_awareness_volume_description),
|
||||
checked = state.relativeConversationalAwarenessVolumeEnabled,
|
||||
onCheckedChange = viewModel::setRelativeConversationalAwarenessVolumeEnabled,
|
||||
enabled = state.isPremium,
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
val conversationalAwarenessVolume = state.conversationalAwarenessVolume
|
||||
LaunchedEffect(conversationalAwarenessVolume) {
|
||||
viewModel.setConversationalAwarenessVolume(conversationalAwarenessVolume)
|
||||
}
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.conversational_awareness_volume),
|
||||
value = conversationalAwarenessVolume,
|
||||
valueRange = 10f..85f,
|
||||
snapPoints = listOf(44f),
|
||||
startLabel = "10%",
|
||||
endLabel = "85%",
|
||||
onValueChange = { newValue ->
|
||||
viewModel.setConversationalAwarenessVolume(
|
||||
newValue
|
||||
)
|
||||
},
|
||||
independent = true,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
|
||||
// if (!BuildConfig.PLAY_BUILD) {
|
||||
// Spacer(modifier = Modifier.height(16.dp))
|
||||
//
|
||||
// StyledListItem(
|
||||
// to = "",
|
||||
// titleRes = stringResource(R.string.camera_control),
|
||||
// name = stringResource(R.string.set_custom_camera_package),
|
||||
// navController = navController,
|
||||
// onClick = {
|
||||
// if (state.isPremium) viewModel.setShowCameraDialog(true)
|
||||
// },
|
||||
// independent = true,
|
||||
// descriptionRes = stringResource(R.string.camera_control_app_description)
|
||||
// )
|
||||
// }
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
if (context.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") == PackageManager.PERMISSION_GRANTED) {
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.ear_detection),
|
||||
label = stringResource(R.string.disconnect_when_not_wearing),
|
||||
description = stringResource(R.string.disconnect_when_not_wearing_description),
|
||||
checked = state.disconnectWhenNotWearing,
|
||||
onCheckedChange = viewModel::setDisconnectWhenNotWearing,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
}
|
||||
|
||||
StyledList(title = stringResource(R.string.takeover_airpods_state)) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_disconnected),
|
||||
description = stringResource(R.string.takeover_disconnected_desc),
|
||||
checked = state.takeoverWhenDisconnected,
|
||||
onCheckedChange = viewModel::setTakeoverWhenDisconnected,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_idle),
|
||||
description = stringResource(R.string.takeover_idle_desc),
|
||||
checked = state.takeoverWhenIdle,
|
||||
onCheckedChange = viewModel::setTakeoverWhenIdle,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_music),
|
||||
description = stringResource(R.string.takeover_music_desc),
|
||||
checked = state.takeoverWhenMusic,
|
||||
onCheckedChange = viewModel::setTakeoverWhenMusic,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_call),
|
||||
description = stringResource(R.string.takeover_call_desc),
|
||||
checked = state.takeoverWhenCall,
|
||||
onCheckedChange = viewModel::setTakeoverWhenCall,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
StyledList(title = stringResource(R.string.takeover_phone_state)) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_ringing_call),
|
||||
description = stringResource(R.string.takeover_ringing_call_desc),
|
||||
checked = state.takeoverWhenRingingCall,
|
||||
onCheckedChange = viewModel::setTakeoverWhenRingingCall,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.takeover_media_start),
|
||||
description = stringResource(R.string.takeover_media_start_desc),
|
||||
checked = state.takeoverWhenMediaStart,
|
||||
onCheckedChange = viewModel::setTakeoverWhenMediaStart,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
}
|
||||
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.advanced_options), // shouldn't be here, but okay
|
||||
label = stringResource(R.string.use_alternate_head_tracking_packets),
|
||||
description = stringResource(R.string.use_alternate_head_tracking_packets_description),
|
||||
checked = state.useAlternateHeadTrackingPackets,
|
||||
onCheckedChange = viewModel::setUseAlternateHeadTrackingPackets,
|
||||
enabled = state.isPremium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 16.dp, bottom = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.customizations_unavailable),
|
||||
style = MaterialTypography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (XposedState.isAvailable && XposedState.bluetoothScopeEnabled) {
|
||||
val restartBluetoothText = stringResource(R.string.found_offset_restart_bluetooth)
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.act_as_an_apple_device) + " (${
|
||||
stringResource(
|
||||
R.string.requires_xposed
|
||||
)
|
||||
})",
|
||||
description = stringResource(R.string.act_as_an_apple_device_description),
|
||||
checked = state.vendorIdHook,
|
||||
onCheckedChange = { enabled ->
|
||||
Toast.makeText(context, restartBluetoothText, Toast.LENGTH_SHORT).show()
|
||||
viewModel.setVendorIdHook(enabled)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (!BuildConfig.PLAY_BUILD) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
StyledList {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.troubleshooting),
|
||||
onClick = navigateToTroubleshooting,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
StyledList(title = stringResource(R.string.contact)) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.email),
|
||||
onClick = { contactBottomSheet.value = true },
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.discord),
|
||||
onClick = {
|
||||
val intent =
|
||||
Intent(Intent.ACTION_VIEW, "https://discord.gg/Ts4wupXcmc".toUri())
|
||||
context.startActivity(intent)
|
||||
},
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.github_issues),
|
||||
onClick = {
|
||||
val appVersion =
|
||||
Uri.encode("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
|
||||
val device = Uri.encode("${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
val androidVersion = Uri.encode("${Build.ID} (${Build.DISPLAY})")
|
||||
val appSource = Uri.encode(
|
||||
when {
|
||||
BuildConfig.PLAY_BUILD -> "Play"
|
||||
else -> "GitHub"
|
||||
}
|
||||
)
|
||||
val url = "https://github.com/kavishdevar/librepods/issues/new" +
|
||||
"?template=01-bug-report-android.yml" +
|
||||
"&app-source=$appSource" +
|
||||
"&app-version=$appVersion" +
|
||||
"&device=$device" +
|
||||
"&android-version=$androidVersion"
|
||||
|
||||
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||
context.startActivity(intent)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
DeviceInfoCard()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
AppInfoCard(navigateToReleaseNotesScreen)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.open_source_licenses),
|
||||
onClick = navigateToOpenSourceLicenses,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
|
||||
if (state.showCameraDialog) {
|
||||
AlertDialog(onDismissRequest = { viewModel.setShowCameraDialog(false) }, title = {
|
||||
Text(
|
||||
stringResource(R.string.set_custom_camera_package),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}, text = {
|
||||
Column {
|
||||
Text(
|
||||
stringResource(R.string.enter_custom_camera_package),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.cameraPackageValue,
|
||||
onValueChange = {
|
||||
viewModel.setCameraPackageValue(it)
|
||||
viewModel.setCameraPackageError(null)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = state.cameraPackageError != null,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
capitalization = KeyboardCapitalization.None
|
||||
),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = if (isDarkTheme) Color(0xFF007AFF) else Color(
|
||||
0xFF3C6DF5
|
||||
),
|
||||
unfocusedBorderColor = if (isDarkTheme) Color.Gray else Color.LightGray
|
||||
),
|
||||
supportingText = {
|
||||
if (state.cameraPackageError != null) {
|
||||
Text(
|
||||
state.cameraPackageError ?: "",
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(R.string.custom_camera_package)) })
|
||||
}
|
||||
}, confirmButton = {
|
||||
val successText = stringResource(R.string.custom_camera_package_set_success)
|
||||
TextButton(
|
||||
onClick = {
|
||||
viewModel.saveCameraPackage()
|
||||
Toast.makeText(context, successText, Toast.LENGTH_SHORT).show()
|
||||
}) {
|
||||
Text(
|
||||
"Save",
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}, dismissButton = {
|
||||
TextButton(
|
||||
onClick = { viewModel.setShowCameraDialog(false) }) {
|
||||
Text(
|
||||
"Cancel",
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
StyledBottomSheet(
|
||||
visible = contactBottomSheet.value,
|
||||
onDismiss = { contactBottomSheet.value = false },
|
||||
backdrop = backdrop
|
||||
) { innerBackdrop, progress ->
|
||||
val animatedPadding = lerp(16.dp, 2.dp, progress)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = animatedPadding)
|
||||
.padding(bottom = 16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
StyledIconButton(
|
||||
icon = "\uDBC0\uDD84",
|
||||
backdrop = innerBackdrop,
|
||||
onClick = { contactBottomSheet.value = false }
|
||||
)
|
||||
Text (
|
||||
text = stringResource(R.string.describe_your_issue),
|
||||
style = TextStyle(
|
||||
fontSize = 18.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontWeight = FontWeight.Bold,
|
||||
textAlign = TextAlign.Center,
|
||||
color = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||
)
|
||||
)
|
||||
StyledIconButton(
|
||||
icon = "\uDBC0\uDE1F",
|
||||
backdrop = innerBackdrop,
|
||||
surfaceColor = if (isSystemInDarkTheme()) Color(0xFF0091FF) else Color(0xFF0088FF),
|
||||
iconTint = if (subjectState.text.isNotEmpty() && descriptionState.text.isNotEmpty()) Color.White else Color.Gray,
|
||||
enabled = subjectState.text.isNotEmpty() && descriptionState.text.isNotEmpty(),
|
||||
onClick = {
|
||||
contactBottomSheet.value = false
|
||||
val intent = Intent(Intent.ACTION_SENDTO).apply {
|
||||
data = "mailto:".toUri()
|
||||
putExtra(Intent.EXTRA_EMAIL, arrayOf("contact@kavish.xyz"))
|
||||
putExtra(Intent.EXTRA_SUBJECT, "LibrePods: ${subjectState.text}")
|
||||
putExtra(
|
||||
Intent.EXTRA_TEXT,
|
||||
"${descriptionState.text}" +
|
||||
"\n\n----------" +
|
||||
"\nPhone details:" +
|
||||
"\nMANUFACTURER: ${Build.MANUFACTURER}" +
|
||||
"\nMODEL: ${Build.MODEL} (${Build.PRODUCT})" +
|
||||
"\nDISPLAY_VERSION: ${Build.DISPLAY}" +
|
||||
"\nID: ${Build.ID} (SDK ${Build.VERSION.SDK_INT_FULL})" +
|
||||
"\nXposed enabled/active: ${XposedState.isAvailable}/${XposedState.bluetoothScopeEnabled}" +
|
||||
"\n\nApp details:" +
|
||||
"\nVERSION: ${BuildConfig.VERSION_NAME}" +
|
||||
"\nVERSION_CODE: ${BuildConfig.VERSION_CODE}" +
|
||||
"\nFLAVOR: ${BuildConfig.FLAVOR}" +
|
||||
"\nBUILD_TYPE: ${BuildConfig.BUILD_TYPE}"
|
||||
)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
subjectState.clearText()
|
||||
descriptionState.clearText()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
StyledInputField(
|
||||
inputState = subjectState,
|
||||
focusRequester = subjectFocusRequester,
|
||||
placeholder = stringResource(R.string.subject),
|
||||
forceApple = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
StyledInputField(
|
||||
inputState = descriptionState,
|
||||
focusRequester = descriptionFocusRequester,
|
||||
placeholder = stringResource(R.string.describe_your_issue),
|
||||
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))
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -16,10 +16,10 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.presentation.screens.apple
|
||||
package me.kavishdevar.librepods.presentation.screens
|
||||
|
||||
//@Composable
|
||||
//fun CameraControlScreen(viewModel: AppleViewModel) {
|
||||
//fun CameraControlScreen(viewModel: AirPodsViewModel) {
|
||||
// val context = LocalContext.current
|
||||
// val currentCameraAction by viewModel.cameraAction.collectAsState()
|
||||
//
|
||||
+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))}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+405
@@ -0,0 +1,405 @@
|
||||
/*
|
||||
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/>.
|
||||
*/
|
||||
|
||||
|
||||
// this is absolutely unnecessary, why did I make this. a simple toggle would've sufficed
|
||||
|
||||
@file:OptIn(ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.presentation.screens
|
||||
|
||||
import android.graphics.Paint
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
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.graphics.nativeCanvas
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import me.kavishdevar.librepods.utils.HeadTracking
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.math.abs
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
fun HeadTrackingScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
DisposableEffect(Unit) {
|
||||
viewModel.startHeadTracking()
|
||||
onDispose {
|
||||
viewModel.stopHeadTracking()
|
||||
}
|
||||
}
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
val 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
|
||||
|
||||
var gestureText by remember { mutableStateOf("") }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var lastClickTime by remember { mutableLongStateOf(0L) }
|
||||
var shouldExplode by remember { mutableStateOf(false) }
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.verticalScroll(scrollState),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(top = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
|
||||
if (!state.isPremium) {
|
||||
StyledButton(
|
||||
onClick = navigateToPurchase,
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
surfaceColor = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.unlock_advanced_features),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
StyledToggle(
|
||||
label = "Head Gestures",
|
||||
checked = state.headGesturesEnabled,
|
||||
onCheckedChange = { viewModel.setHeadGesturesEnabled(it) },
|
||||
enabled = state.isPremium || state.headGesturesEnabled,
|
||||
description = stringResource(R.string.head_gestures_details),
|
||||
header = true
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
"Velocity",
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(start = 16.dp, bottom = 8.dp, top = 8.dp)
|
||||
)
|
||||
Plot()
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
LaunchedEffect(gestureText) {
|
||||
if (gestureText.isNotEmpty()) {
|
||||
lastClickTime = System.currentTimeMillis()
|
||||
delay(3000)
|
||||
if (System.currentTimeMillis() - lastClickTime >= 3000) {
|
||||
shouldExplode = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val gestureTextValue = stringResource(R.string.shake_your_head_or_nod)
|
||||
StyledButton(
|
||||
onClick = {
|
||||
gestureText = gestureTextValue
|
||||
coroutineScope.launch {
|
||||
val accepted = ServiceManager.getService()?.testHeadGestures() ?: false
|
||||
gestureText = if (accepted) "\"Yes\" gesture detected." else "\"No\" gesture detected."
|
||||
}
|
||||
},
|
||||
backdrop = backdrop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
maxScale = 0.05f
|
||||
) {
|
||||
Text(
|
||||
"Test Head Gestures",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 24.dp)
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = gestureText,
|
||||
transitionSpec = {
|
||||
(fadeIn(
|
||||
animationSpec = tween(300)
|
||||
) + slideInVertically(
|
||||
initialOffsetY = { 40 },
|
||||
animationSpec = tween(300)
|
||||
)).togetherWith(fadeOut(animationSpec = tween(150)))
|
||||
}
|
||||
) { text ->
|
||||
if (shouldExplode) {
|
||||
LaunchedEffect(Unit) {
|
||||
CoroutineScope(coroutineScope.coroutineContext).launch {
|
||||
delay(750)
|
||||
gestureText = ""
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onBackground
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = text,
|
||||
style = TextStyle(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
color = textColor,
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Plot() {
|
||||
val acceleration by HeadTracking.acceleration.collectAsState()
|
||||
val maxPoints = 100
|
||||
val points = remember { mutableStateListOf<Pair<Float, Float>>() }
|
||||
val darkTheme = isSystemInDarkTheme()
|
||||
|
||||
var maxAbs by remember { mutableFloatStateOf(1000f) }
|
||||
|
||||
LaunchedEffect(acceleration) {
|
||||
points.add(Pair(acceleration.horizontal, acceleration.vertical))
|
||||
if (points.size > maxPoints) {
|
||||
points.removeAt(0)
|
||||
}
|
||||
|
||||
val currentMax = points.maxOf { maxOf(abs(it.first), abs(it.second)) }
|
||||
maxAbs = maxOf(currentMax * 1.2f, 1000f)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
shape = RoundedCornerShape(28.dp)
|
||||
) {
|
||||
val horizontalColor = MaterialTheme.colorScheme.primary
|
||||
val verticalColor = MaterialTheme.colorScheme.onPrimary
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Canvas(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
val width = size.width
|
||||
val height = size.height
|
||||
val xScale = width / maxPoints
|
||||
val yScale = (height - 40.dp.toPx()) / (maxAbs * 2)
|
||||
val zeroY = height / 2
|
||||
|
||||
val gridColor = if (darkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.1f)
|
||||
|
||||
for (i in 0..maxPoints step 10) {
|
||||
val x = i * xScale
|
||||
drawLine(
|
||||
color = gridColor,
|
||||
start = Offset(x, 0f),
|
||||
end = Offset(x, height),
|
||||
strokeWidth = 1.dp.toPx()
|
||||
)
|
||||
}
|
||||
|
||||
val gridStep = maxAbs / 4
|
||||
for (value in (-maxAbs.toInt()..maxAbs.toInt()) step gridStep.toInt()) {
|
||||
val y = zeroY - value * yScale
|
||||
drawLine(
|
||||
color = gridColor,
|
||||
start = Offset(0f, y),
|
||||
end = Offset(width, y),
|
||||
strokeWidth = 1.dp.toPx()
|
||||
)
|
||||
}
|
||||
|
||||
drawLine(
|
||||
color = if (darkTheme) Color.White.copy(alpha = 0.3f) else Color.Black.copy(alpha = 0.3f),
|
||||
start = Offset(0f, zeroY),
|
||||
end = Offset(width, zeroY),
|
||||
strokeWidth = 1.5f.dp.toPx()
|
||||
)
|
||||
|
||||
if (points.size > 1) {
|
||||
for (i in 0 until points.size - 1) {
|
||||
val x1 = i * xScale
|
||||
val x2 = (i + 1) * xScale
|
||||
|
||||
drawLine(
|
||||
color = horizontalColor,
|
||||
start = Offset(x1, zeroY - points[i].first * yScale),
|
||||
end = Offset(x2, zeroY - points[i + 1].first * yScale),
|
||||
strokeWidth = 2.dp.toPx()
|
||||
)
|
||||
|
||||
drawLine(
|
||||
color = verticalColor,
|
||||
start = Offset(x1, zeroY - points[i].second * yScale),
|
||||
end = Offset(x2, zeroY - points[i + 1].second * yScale),
|
||||
strokeWidth = 2.dp.toPx()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
drawText("${maxAbs.toInt()}", 30.dp.toPx(), 20.dp.toPx(), paint)
|
||||
drawText("0", 30.dp.toPx(), height/2, paint)
|
||||
drawText("-${maxAbs.toInt()}", 30.dp.toPx(), height - 10.dp.toPx(), paint)
|
||||
}
|
||||
|
||||
val legendY = 15.dp.toPx()
|
||||
val textOffsetY = legendY + 5.dp.toPx() / 2
|
||||
|
||||
drawCircle(horizontalColor, 5.dp.toPx(), Offset(width - 150.dp.toPx(), legendY))
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
val paint = Paint().apply {
|
||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||
textSize = 12.sp.toPx()
|
||||
textAlign = Paint.Align.LEFT
|
||||
}
|
||||
drawText("Horizontal", width - 140.dp.toPx(), textOffsetY, paint)
|
||||
}
|
||||
|
||||
drawCircle(verticalColor, 5.dp.toPx(), Offset(width - 70.dp.toPx(), legendY))
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
val paint = Paint().apply {
|
||||
color = if (darkTheme) android.graphics.Color.WHITE else android.graphics.Color.BLACK
|
||||
textSize = 12.sp.toPx()
|
||||
textAlign = Paint.Align.LEFT
|
||||
}
|
||||
drawText("Vertical", width - 60.dp.toPx(), textOffsetY, paint)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+95
-100
@@ -16,21 +16,26 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.presentation.screens.apple
|
||||
package me.kavishdevar.librepods.presentation.screens
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -41,30 +46,29 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.Job
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.bluetooth.aacp.types.ControlCommandIdentifier
|
||||
import me.kavishdevar.librepods.bluetooth.att.ATTHandle
|
||||
import me.kavishdevar.librepods.bluetooth.att.types.HearingAidSettings
|
||||
import me.kavishdevar.librepods.bluetooth.att.types.parseHearingAidSettingsResponse
|
||||
import me.kavishdevar.librepods.bluetooth.att.types.sendHearingAidSettings
|
||||
import me.kavishdevar.librepods.presentation.components.primitives.StyledScaffold
|
||||
import me.kavishdevar.librepods.presentation.components.primitives.StyledSlider
|
||||
import me.kavishdevar.librepods.presentation.components.primitives.StyledToggle
|
||||
import me.kavishdevar.librepods.presentation.icons.LocalIcons
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AppleViewModel
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import me.kavishdevar.librepods.data.HearingAidSettings
|
||||
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
|
||||
import me.kavishdevar.librepods.data.sendHearingAidSettings
|
||||
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
private const val TAG = "HearingAidAdjustments"
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun HearingAidAdjustmentsScreen(
|
||||
viewModel: AppleViewModel,
|
||||
navigateBack: (() -> Unit)?
|
||||
) {
|
||||
fun HearingAidAdjustmentsScreen(viewModel: AirPodsViewModel) {
|
||||
val verticalScrollState = rememberScrollState()
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
val state = uiState.state
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
val debounceJob = remember { mutableStateOf<Job?>(null) }
|
||||
|
||||
@@ -79,14 +83,6 @@ fun HearingAidAdjustmentsScreen(
|
||||
|
||||
val initialized = rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
viewModel.observeATTCharacteristic(ATTHandle.HEARING_AID)
|
||||
|
||||
onDispose {
|
||||
viewModel.stopObservingATTCharacteristic()
|
||||
}
|
||||
}
|
||||
|
||||
val hearingAidSettings = remember { mutableStateOf(
|
||||
HearingAidSettings(
|
||||
leftEQ = leftEQ.value,
|
||||
@@ -144,87 +140,86 @@ fun HearingAidAdjustmentsScreen(
|
||||
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
||||
)
|
||||
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
||||
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::writeATTCharacteristic)
|
||||
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, viewModel::setATTCharacteristicValue)
|
||||
}
|
||||
|
||||
StyledScaffold(
|
||||
title = stringResource(R.string.adjustments),
|
||||
navigateBack = navigateBack
|
||||
) { topPadding, bottomPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.verticalScroll(verticalScrollState)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
||||
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.amplification),
|
||||
valueRange = -1f..1f,
|
||||
value = amplificationSliderValue.floatValue,
|
||||
onValueChange = {
|
||||
amplificationSliderValue.floatValue = it
|
||||
},
|
||||
startImageVector = LocalIcons.current.SpeakerMin,
|
||||
endImageVector = LocalIcons.current.SpeakerMax,
|
||||
independent = true,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.verticalScroll(verticalScrollState)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.swipe_to_control_amplification),
|
||||
checked = state.controlStates[ControlCommandIdentifier.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),
|
||||
onCheckedChange = { viewModel.setControlCommand(ControlCommandIdentifier.HPS_GAIN_SWIPE, it) },
|
||||
description = stringResource(R.string.swipe_amplification_description)
|
||||
)
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.amplification),
|
||||
valueRange = -1f..1f,
|
||||
value = amplificationSliderValue.floatValue,
|
||||
onValueChange = {
|
||||
amplificationSliderValue.floatValue = it
|
||||
},
|
||||
startIcon = "",
|
||||
endIcon = "",
|
||||
independent = true,
|
||||
)
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.balance),
|
||||
valueRange = -1f..1f,
|
||||
value = balanceSliderValue.floatValue,
|
||||
onValueChange = {
|
||||
balanceSliderValue.floatValue = it
|
||||
},
|
||||
snapPoints = listOf(-1f, 0f, 1f),
|
||||
startLabel = stringResource(R.string.left),
|
||||
endLabel = stringResource(R.string.right),
|
||||
independent = true,
|
||||
)
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.swipe_to_control_amplification),
|
||||
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE]?.getOrNull(0) == 0x01.toByte(),
|
||||
onCheckedChange = { viewModel.setControlCommandBoolean(AACPManager.Companion.ControlCommandIdentifiers.HPS_GAIN_SWIPE, it) },
|
||||
description = stringResource(R.string.swipe_amplification_description)
|
||||
)
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.tone),
|
||||
valueRange = -1f..1f,
|
||||
value = toneSliderValue.floatValue,
|
||||
onValueChange = {
|
||||
toneSliderValue.floatValue = it
|
||||
},
|
||||
startLabel = stringResource(R.string.darker),
|
||||
endLabel = stringResource(R.string.brighter),
|
||||
independent = true,
|
||||
)
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.balance),
|
||||
valueRange = -1f..1f,
|
||||
value = balanceSliderValue.floatValue,
|
||||
onValueChange = {
|
||||
balanceSliderValue.floatValue = it
|
||||
},
|
||||
snapPoints = listOf(-1f, 0f, 1f),
|
||||
startLabel = stringResource(R.string.left),
|
||||
endLabel = stringResource(R.string.right),
|
||||
independent = true,
|
||||
)
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.ambient_noise_reduction),
|
||||
valueRange = 0f..1f,
|
||||
value = ambientNoiseReductionSliderValue.floatValue,
|
||||
onValueChange = {
|
||||
ambientNoiseReductionSliderValue.floatValue = it
|
||||
},
|
||||
startLabel = stringResource(R.string.less),
|
||||
endLabel = stringResource(R.string.more),
|
||||
independent = true,
|
||||
)
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.tone),
|
||||
valueRange = -1f..1f,
|
||||
value = toneSliderValue.floatValue,
|
||||
onValueChange = {
|
||||
toneSliderValue.floatValue = it
|
||||
},
|
||||
startLabel = stringResource(R.string.darker),
|
||||
endLabel = stringResource(R.string.brighter),
|
||||
independent = true,
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.conversation_boost),
|
||||
checked = conversationBoostEnabled.value,
|
||||
onCheckedChange = { conversationBoostEnabled.value = it },
|
||||
description = stringResource(R.string.conversation_boost_description)
|
||||
)
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.ambient_noise_reduction),
|
||||
valueRange = 0f..1f,
|
||||
value = ambientNoiseReductionSliderValue.floatValue,
|
||||
onValueChange = {
|
||||
ambientNoiseReductionSliderValue.floatValue = it
|
||||
},
|
||||
startLabel = stringResource(R.string.less),
|
||||
endLabel = stringResource(R.string.more),
|
||||
independent = true,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.conversation_boost),
|
||||
checked = conversationBoostEnabled.value,
|
||||
onCheckedChange = { conversationBoostEnabled.value = it },
|
||||
description = stringResource(R.string.conversation_boost_description)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
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 android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.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.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
|
||||
import me.kavishdevar.librepods.data.sendTransparencySettings
|
||||
import me.kavishdevar.librepods.presentation.components.ConfirmationDialog
|
||||
import me.kavishdevar.librepods.presentation.components.StyledList
|
||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
private const val TAG = "AccessibilitySettings"
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun HearingAidScreen(viewModel: AirPodsViewModel, onNavigateHearingAidAdjustments: () -> Unit, onNavigateHearingTest: () -> Unit) {
|
||||
val verticalScrollState = rememberScrollState()
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
val showDialog = remember { mutableStateOf(false) }
|
||||
val initialLoad = remember { mutableStateOf(true) }
|
||||
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
val hearingAidEnabled = remember {
|
||||
val aidStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]
|
||||
val assistStatus = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG]
|
||||
mutableStateOf((aidStatus?.getOrNull(1) == 0x01.toByte()) && (assistStatus?.getOrNull(0) == 0x01.toByte()))
|
||||
}
|
||||
|
||||
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
||||
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.layerBackdrop(backdrop)
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.verticalScroll(verticalScrollState)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
|
||||
// val mediaAssistEnabled = remember { mutableStateOf(false) }
|
||||
// val adjustMediaEnabled = remember { mutableStateOf(false) }
|
||||
// val adjustPhoneEnabled = remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(hearingAidEnabled.value) {
|
||||
if (hearingAidEnabled.value && !initialLoad.value) {
|
||||
showDialog.value = true
|
||||
} else if (!hearingAidEnabled.value && !initialLoad.value) {
|
||||
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x02))
|
||||
viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x02.toByte())
|
||||
hearingAidEnabled.value = false
|
||||
}
|
||||
initialLoad.value = false
|
||||
}
|
||||
|
||||
// fun onAdjustPhoneChange(value: Boolean) {
|
||||
// // TODO
|
||||
// }
|
||||
|
||||
// fun onAdjustMediaChange(value: Boolean) {
|
||||
// // TODO
|
||||
// }
|
||||
|
||||
StyledList (title = stringResource(R.string.hearing_aid)) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.hearing_aid),
|
||||
checked = hearingAidEnabled.value,
|
||||
onCheckedChange = { hearingAidEnabled.value = it },
|
||||
)
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.adjustments),
|
||||
onClick = onNavigateHearingAidAdjustments,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.hearing_aid_description),
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = (if (isSystemInDarkTheme()) Color.White else Color.Black).copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.update_hearing_test),
|
||||
onClick = onNavigateHearingTest,
|
||||
)
|
||||
|
||||
// not implemented yet
|
||||
|
||||
// StyledToggle(
|
||||
// titleRes = stringResource(R.string.media_assist),
|
||||
// label = stringResource(R.string.media_assist),
|
||||
// checkedState = mediaAssistEnabled,
|
||||
// independent = true,
|
||||
// descriptionRes = stringResource(R.string.media_assist_description)
|
||||
// )
|
||||
|
||||
// Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Column (
|
||||
// modifier = Modifier
|
||||
// .fillMaxWidth()
|
||||
// .background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
// ) {
|
||||
// StyledToggle(
|
||||
// label = stringResource(R.string.adjust_media),
|
||||
// checkedState = adjustMediaEnabled,
|
||||
// onCheckedChange = { onAdjustMediaChange(it) },
|
||||
// independent = false
|
||||
// )
|
||||
// HorizontalDivider(
|
||||
// thickness = 1.dp,
|
||||
// color = Color(0x40888888),
|
||||
// modifier = Modifier
|
||||
// .padding(horizontal = 12.dp)
|
||||
// )
|
||||
|
||||
// StyledToggle(
|
||||
// label = stringResource(R.string.adjust_calls),
|
||||
// checkedState = adjustPhoneEnabled,
|
||||
// onCheckedChange = { onAdjustPhoneChange(it) },
|
||||
// independent = false
|
||||
// )
|
||||
// }
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
|
||||
ConfirmationDialog(
|
||||
showDialog = showDialog,
|
||||
title = "Enable Hearing Aid",
|
||||
message = "Enabling Hearing Aid will disable Headphone Accommodation and Customized Transparency Mode.",
|
||||
confirmText = "Enable",
|
||||
dismissText = "Cancel",
|
||||
onConfirm = {
|
||||
showDialog.value = false
|
||||
val enrolled = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID]?.getOrNull(0) == 0x01.toByte()
|
||||
if (!enrolled) {
|
||||
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01))
|
||||
} else {
|
||||
viewModel.setControlCommandValue(AACPManager.Companion.ControlCommandIdentifiers.HEARING_AID, byteArrayOf(0x01, 0x01))
|
||||
}
|
||||
viewModel.setControlCommandByte(AACPManager.Companion.ControlCommandIdentifiers.HEARING_ASSIST_CONFIG, 0x01.toByte())
|
||||
hearingAidEnabled.value = true
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
if (state.hearingAidData.isEmpty()) {
|
||||
Log.w(TAG, "read failed")
|
||||
return@launch
|
||||
}
|
||||
val parsed = parseTransparencySettingsResponse(state.hearingAidData)
|
||||
if (parsed == null) {
|
||||
Log.w(TAG, "transparency parse failed")
|
||||
return@launch
|
||||
}
|
||||
val disabledSettings = parsed.copy(enabled = false)
|
||||
sendTransparencySettings(viewModel::setATTCharacteristicValue, disabledSettings)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error disabling transparency: ${e.message}")
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = {
|
||||
hearingAidEnabled.value = false
|
||||
showDialog.value = false
|
||||
},
|
||||
backdrop = backdrop
|
||||
)
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
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.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||
import me.kavishdevar.librepods.presentation.components.StyledButton
|
||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
|
||||
@Composable
|
||||
fun HearingProtectionScreen(viewModel: AirPodsViewModel, navigateToPurchase: () -> Unit) {
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
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
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.layerBackdrop(backdrop)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
if (!state.isPremium) {
|
||||
StyledButton(
|
||||
onClick = navigateToPurchase,
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
surfaceColor = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.unlock_advanced_features),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
if (state.vendorIdHook) {
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.environmental_noise),
|
||||
label = stringResource(R.string.loud_sound_reduction),
|
||||
description = stringResource(R.string.loud_sound_reduction_description),
|
||||
checked = state.loudSoundReductionEnabled,
|
||||
onCheckedChange = {
|
||||
viewModel.setATTCharacteristicValue(
|
||||
ATTHandles.LOUD_SOUND_REDUCTION,
|
||||
byteArrayOf(if (it) 1.toByte() else 0.toByte())
|
||||
)
|
||||
},
|
||||
enabled = state.isPremium
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
StyledToggle(
|
||||
title = stringResource(R.string.workspace_use),
|
||||
label = stringResource(R.string.ppe),
|
||||
description = stringResource(R.string.workspace_use_description),
|
||||
checked = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG]?.getOrNull(
|
||||
0
|
||||
)?.toInt() == 1,
|
||||
onCheckedChange = {
|
||||
viewModel.setControlCommandBoolean(
|
||||
AACPManager.Companion.ControlCommandIdentifiers.PPE_TOGGLE_CONFIG, it
|
||||
)
|
||||
},
|
||||
enabled = state.isPremium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
+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))
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
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 android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
|
||||
import com.mikepenz.aboutlibraries.ui.compose.produceLibraries
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.Job
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
private var debounceJob: Job? = null
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun OpenSourceLicensesScreen() {
|
||||
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
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
val context = LocalContext.current
|
||||
val libraries by produceLibraries {
|
||||
context.resources.openRawResource(R.raw.aboutlibraries)
|
||||
.bufferedReader()
|
||||
.use { it.readText() }
|
||||
}
|
||||
LibrariesContainer(
|
||||
libraries = libraries,
|
||||
modifier = Modifier
|
||||
.padding(0.dp)
|
||||
.fillMaxSize()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
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(ExperimentalStdlibApi::class, ExperimentalEncodingApi::class)
|
||||
|
||||
package me.kavishdevar.librepods.presentation.screens
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
import me.kavishdevar.librepods.data.StemAction
|
||||
import me.kavishdevar.librepods.presentation.components.ListItemOrientation
|
||||
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.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
import kotlin.experimental.and
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LongPress(viewModel: AirPodsViewModel, name: String, navigateToPurchase: () -> Unit) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
val modesByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0) ?: 0
|
||||
|
||||
Log.d("PressAndHoldSettingsScreen", "Current modes state: ${modesByte.toString(2)}")
|
||||
Log.d("PressAndHoldSettingsScreen", "Off mode: ${(modesByte and 0x01) != 0.toByte()}")
|
||||
Log.d("PressAndHoldSettingsScreen", "Transparency mode: ${(modesByte and 0x04) != 0.toByte()}")
|
||||
Log.d("PressAndHoldSettingsScreen", "Noise Cancellation mode: ${(modesByte and 0x02) != 0.toByte()}")
|
||||
Log.d("PressAndHoldSettingsScreen", "Adaptive mode: ${(modesByte and 0x08) != 0.toByte()}")
|
||||
|
||||
val longPressAction = if (name.lowercase() == "left") state.leftAction else state.rightAction
|
||||
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
||||
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
||||
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
Column (
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.verticalScroll(scrollState)
|
||||
.padding(top = 8.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
|
||||
StyledList {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.noise_control),
|
||||
selected = longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES,
|
||||
onClick = {
|
||||
viewModel.setLongPressAction(
|
||||
name,
|
||||
StemAction.CYCLE_NOISE_CONTROL_MODES
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.digital_assistant),
|
||||
selected = longPressAction == StemAction.DIGITAL_ASSISTANT,
|
||||
onClick = {
|
||||
viewModel.setLongPressAction(
|
||||
name,
|
||||
StemAction.DIGITAL_ASSISTANT
|
||||
)
|
||||
},
|
||||
enabled = state.isPremium
|
||||
)
|
||||
}
|
||||
|
||||
if (!state.isPremium) {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
StyledButton(
|
||||
onClick = navigateToPurchase,
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
surfaceColor = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.unlock_advanced_features),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
if (longPressAction == StemAction.CYCLE_NOISE_CONTROL_MODES) {
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
val currentByte = state.controlStates[AACPManager.Companion.ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0)?.toInt() ?: 0
|
||||
|
||||
StyledList(
|
||||
title = stringResource(R.string.noise_control),
|
||||
description = stringResource(R.string.press_and_hold_noise_control_description)
|
||||
) {
|
||||
if (state.offListeningMode) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.off),
|
||||
description = stringResource(R.string.listening_mode_off_description),
|
||||
selected = (currentByte and 0x01) != 0,
|
||||
onClick = {
|
||||
viewModel.toggleListeningMode(0x01)
|
||||
},
|
||||
orientation = ListItemOrientation.Vertical,
|
||||
leadingContent = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.noise_cancellation),
|
||||
contentDescription = "Icon",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.height(42.dp)
|
||||
.wrapContentWidth()
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.transparency),
|
||||
description = stringResource(R.string.listening_mode_transparency_description),
|
||||
selected = (currentByte and 0x04) != 0,
|
||||
onClick = {
|
||||
viewModel.toggleListeningMode(0x04)
|
||||
},
|
||||
orientation = ListItemOrientation.Vertical,
|
||||
leadingContent = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.transparency),
|
||||
contentDescription = "Icon",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.height(42.dp)
|
||||
.wrapContentWidth()
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.adaptive),
|
||||
description = stringResource(R.string.listening_mode_adaptive_description),
|
||||
selected = (currentByte and 0x08) != 0,
|
||||
onClick = {
|
||||
viewModel.toggleListeningMode(0x08)
|
||||
},
|
||||
orientation = ListItemOrientation.Vertical,
|
||||
leadingContent = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.adaptive),
|
||||
contentDescription = "Icon",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.height(42.dp)
|
||||
.wrapContentWidth()
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.noise_cancellation),
|
||||
description = stringResource(R.string.listening_mode_noise_cancellation_description),
|
||||
selected = (currentByte and 0x02) != 0,
|
||||
onClick = {
|
||||
viewModel.toggleListeningMode(0x02)
|
||||
},
|
||||
orientation = ListItemOrientation.Vertical,
|
||||
leadingContent = {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.noise_cancellation),
|
||||
contentDescription = "Icon",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.height(42.dp)
|
||||
.wrapContentWidth()
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
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.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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.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.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
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.StyledList
|
||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||
import me.kavishdevar.librepods.presentation.navigation.Screen
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.PurchaseViewModel
|
||||
import me.kavishdevar.librepods.utils.XposedState
|
||||
|
||||
@Composable
|
||||
fun PurchaseScreen(
|
||||
viewModel: PurchaseViewModel = viewModel(),
|
||||
backStack: SnapshotStateList<Screen>
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
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
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.layerBackdrop(backdrop)
|
||||
.verticalScroll(scrollState)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
|
||||
LaunchedEffect(state.isPremium) {
|
||||
if (state.isPremium) {
|
||||
if (backStack.size > 1) {
|
||||
backStack.removeAt(backStack.lastIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!state.isPremium) {
|
||||
StyledList(title = stringResource(R.string.free_features)) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.ear_detection),
|
||||
description = stringResource(R.string.ear_detection_description),
|
||||
enabled = false,
|
||||
orientation = ListItemOrientation.Vertical
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.battery),
|
||||
description = stringResource(R.string.battery_description),
|
||||
enabled = false,
|
||||
orientation = ListItemOrientation.Vertical
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.noise_control),
|
||||
description = stringResource(R.string.noise_control_description),
|
||||
enabled = false,
|
||||
orientation = ListItemOrientation.Vertical
|
||||
)
|
||||
|
||||
if (XposedState.isAvailable) {
|
||||
StyledListItem(
|
||||
name = "${stringResource(R.string.hearing_aid)} (${stringResource(R.string.requires_xposed)})",
|
||||
description = stringResource(R.string.hearing_aid_description)
|
||||
.substringBefore("\n\n"),
|
||||
enabled = false,
|
||||
orientation = ListItemOrientation.Vertical
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
StyledList(title = stringResource(R.string.advanced_features), description = stringResource(R.string.feature_availability_disclaimer)) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.conversational_awareness),
|
||||
description = stringResource(R.string.conversational_awareness_description),
|
||||
enabled = false,
|
||||
orientation = ListItemOrientation.Vertical
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.digital_assistant_on_long_press),
|
||||
description = stringResource(R.string.digital_assistant_on_long_press_description),
|
||||
enabled = false,
|
||||
orientation = ListItemOrientation.Vertical
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.head_gestures),
|
||||
description = stringResource(R.string.head_gestures_details),
|
||||
enabled = false,
|
||||
orientation = ListItemOrientation.Vertical
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.advanced_device_settings),
|
||||
description = stringResource(R.string.advanced_device_settings_description),
|
||||
enabled = false,
|
||||
orientation = ListItemOrientation.Vertical
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.automatic_connection),
|
||||
description = stringResource(R.string.automatic_connection_description),
|
||||
enabled = false,
|
||||
orientation = ListItemOrientation.Vertical
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.customizations),
|
||||
description = stringResource(R.string.customizations_description),
|
||||
enabled = false,
|
||||
orientation = ListItemOrientation.Vertical
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.support_the_development),
|
||||
description = stringResource(R.string.support_development_description),
|
||||
enabled = false,
|
||||
orientation = ListItemOrientation.Vertical
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
StyledButton(
|
||||
onClick = {
|
||||
viewModel.purchase(context)
|
||||
},
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
surfaceColor = MaterialTheme.colorScheme.primary,
|
||||
materialButtonStyle = MaterialButtonStyle.Filled
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.buy_price, state.price),
|
||||
style = MaterialTheme.typography.bodyMediumEmphasized,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
StyledButton(
|
||||
onClick = {
|
||||
viewModel.restorePurchases()
|
||||
},
|
||||
backdrop = rememberLayerBackdrop(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
maxScale = 0.05f,
|
||||
isInteractive = false,
|
||||
materialButtonStyle = MaterialButtonStyle.Outlined
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.restore_purchases),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -100,11 +100,11 @@ fun ReleaseNotesScreen(
|
||||
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
|
||||
LibrePodsTheme(designSystem = DesignSystem.Material) {
|
||||
LibrePodsTheme(m3eEnabled = true) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer, RoundedCornerShape(52.dp)),
|
||||
.background(MaterialTheme.colorScheme.background, RoundedCornerShape(52.dp)),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
@@ -372,7 +372,7 @@ fun ReleaseNotesScreen(
|
||||
@Composable
|
||||
fun ReleaseNotesScreenPreview() {
|
||||
LibrePodsTheme(
|
||||
designSystem = DesignSystem.Apple
|
||||
m3eEnabled = false
|
||||
) {
|
||||
ReleaseNotesScreen(
|
||||
updates = updates,
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
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.screens
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.edit
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.presentation.components.StyledInputField
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||
@Composable
|
||||
fun RenameScreen(viewModel: AirPodsViewModel) {
|
||||
val sharedPreferences = LocalContext.current.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
keyboardController?.show()
|
||||
}
|
||||
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
||||
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
|
||||
val name = sharedPreferences.getString("name", "")?: ""
|
||||
val textFieldState = rememberTextFieldState(initialText = name)
|
||||
|
||||
LaunchedEffect(textFieldState.text) {
|
||||
sharedPreferences.edit {putString("name", textFieldState.text as String?)}
|
||||
viewModel.setName(textFieldState.text.toString())
|
||||
}
|
||||
|
||||
StyledInputField(
|
||||
textFieldState,
|
||||
focusRequester
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
|
||||
}
|
||||
}
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
/*
|
||||
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 me.kavishdevar.librepods.utils.RadareOffsetFinder
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.data.TransparencySettings
|
||||
import me.kavishdevar.librepods.data.parseTransparencySettingsResponse
|
||||
import me.kavishdevar.librepods.data.sendTransparencySettings
|
||||
import me.kavishdevar.librepods.presentation.components.StyledSlider
|
||||
import me.kavishdevar.librepods.presentation.components.StyledToggle
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
private const val TAG = "TransparencySettings"
|
||||
|
||||
@SuppressLint("DefaultLocale")
|
||||
@ExperimentalHazeMaterialsApi
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalEncodingApi::class)
|
||||
@Composable
|
||||
fun TransparencySettingsScreen(viewModel: AirPodsViewModel) {
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val verticalScrollState = rememberScrollState()
|
||||
|
||||
val trackColor = if (isDarkTheme) Color(0xFFB3B3B3) else Color(0xFF929491)
|
||||
val activeTrackColor = if (isDarkTheme) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
||||
val thumbColor = if (isDarkTheme) Color(0xFFFFFFFF) else Color(0xFFFFFFFF)
|
||||
|
||||
val 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
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.verticalScroll(verticalScrollState)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
|
||||
val enabled = rememberSaveable { mutableStateOf(false) }
|
||||
val amplificationSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||
val balanceSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||
val toneSliderValue = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||
val ambientNoiseReductionSliderValue = rememberSaveable { mutableFloatStateOf(0.0f) }
|
||||
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
|
||||
val eq = rememberSaveable(
|
||||
saver = Saver(
|
||||
save = { it.value.toList() },
|
||||
restore = { mutableStateOf(it.toFloatArray()) }
|
||||
)
|
||||
) { mutableStateOf(FloatArray(8)) }
|
||||
val phoneMediaEQ = rememberSaveable(
|
||||
saver = Saver(
|
||||
save = { it.value.toList() },
|
||||
restore = { mutableStateOf(it.toFloatArray()) }
|
||||
)
|
||||
) { mutableStateOf(FloatArray(8) { 0.5f }) }
|
||||
|
||||
val initialized = rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val transparencySettings = remember {
|
||||
mutableStateOf(
|
||||
TransparencySettings(
|
||||
enabled = enabled.value,
|
||||
leftEQ = eq.value,
|
||||
rightEQ = eq.value,
|
||||
leftAmplification = amplificationSliderValue.floatValue + (0.5f - balanceSliderValue.floatValue) * amplificationSliderValue.floatValue * 2,
|
||||
rightAmplification = amplificationSliderValue.floatValue + (balanceSliderValue.floatValue - 0.5f) * amplificationSliderValue.floatValue * 2,
|
||||
leftTone = toneSliderValue.floatValue,
|
||||
rightTone = toneSliderValue.floatValue,
|
||||
leftConversationBoost = conversationBoostEnabled.value,
|
||||
rightConversationBoost = conversationBoostEnabled.value,
|
||||
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
||||
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
||||
netAmplification = amplificationSliderValue.floatValue,
|
||||
balance = balanceSliderValue.floatValue
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
enabled.value,
|
||||
amplificationSliderValue.floatValue,
|
||||
balanceSliderValue.floatValue,
|
||||
toneSliderValue.floatValue,
|
||||
conversationBoostEnabled.value,
|
||||
ambientNoiseReductionSliderValue.floatValue,
|
||||
eq.value
|
||||
) {
|
||||
if (!initialized.value) return@LaunchedEffect
|
||||
transparencySettings.value = TransparencySettings(
|
||||
enabled = enabled.value,
|
||||
leftEQ = eq.value,
|
||||
rightEQ = eq.value,
|
||||
leftAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue < 0) -balanceSliderValue.floatValue else 0f,
|
||||
rightAmplification = amplificationSliderValue.floatValue + if (balanceSliderValue.floatValue > 0) balanceSliderValue.floatValue else 0f,
|
||||
leftTone = toneSliderValue.floatValue,
|
||||
rightTone = toneSliderValue.floatValue,
|
||||
leftConversationBoost = conversationBoostEnabled.value,
|
||||
rightConversationBoost = conversationBoostEnabled.value,
|
||||
leftAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
||||
rightAmbientNoiseReduction = ambientNoiseReductionSliderValue.floatValue,
|
||||
netAmplification = amplificationSliderValue.floatValue,
|
||||
balance = balanceSliderValue.floatValue
|
||||
)
|
||||
Log.d("TransparencySettings", "Updated settings: ${transparencySettings.value}")
|
||||
sendTransparencySettings(viewModel::setATTCharacteristicValue, transparencySettings.value)
|
||||
}
|
||||
|
||||
LaunchedEffect(state.transparencyData) {
|
||||
val parsedSettings = parseTransparencySettingsResponse(data = state.transparencyData) ?: return@LaunchedEffect
|
||||
Log.d(TAG, "Initial transparency settings: $parsedSettings")
|
||||
enabled.value = parsedSettings.enabled
|
||||
amplificationSliderValue.floatValue = parsedSettings.netAmplification
|
||||
balanceSliderValue.floatValue = parsedSettings.balance
|
||||
toneSliderValue.floatValue = parsedSettings.leftTone
|
||||
ambientNoiseReductionSliderValue.floatValue =
|
||||
parsedSettings.leftAmbientNoiseReduction
|
||||
conversationBoostEnabled.value = parsedSettings.leftConversationBoost
|
||||
if (!eq.value.contentEquals(parsedSettings.leftEQ)) {
|
||||
eq.value = parsedSettings.leftEQ.copyOf()
|
||||
}
|
||||
initialized.value = true
|
||||
}
|
||||
|
||||
if (state.vendorIdHook) {
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.transparency_mode),
|
||||
checked = enabled.value,
|
||||
description = stringResource(R.string.customize_transparency_mode_description),
|
||||
onCheckedChange = { enabled.value = it }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.amplification),
|
||||
valueRange = -1f..1f,
|
||||
value = amplificationSliderValue.floatValue,
|
||||
onValueChange = {
|
||||
amplificationSliderValue.floatValue = it
|
||||
},
|
||||
startIcon = "",
|
||||
endIcon = "",
|
||||
independent = true
|
||||
)
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.balance),
|
||||
valueRange = -1f..1f,
|
||||
value = balanceSliderValue.floatValue,
|
||||
onValueChange = {
|
||||
balanceSliderValue.floatValue = it
|
||||
},
|
||||
snapPoints = listOf(-1f, 0f, 1f),
|
||||
startLabel = stringResource(R.string.left),
|
||||
endLabel = stringResource(R.string.right),
|
||||
independent = true,
|
||||
)
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.tone),
|
||||
valueRange = -1f..1f,
|
||||
value = toneSliderValue.floatValue,
|
||||
onValueChange = {
|
||||
toneSliderValue.floatValue = it
|
||||
},
|
||||
startLabel = stringResource(R.string.darker),
|
||||
endLabel = stringResource(R.string.brighter),
|
||||
independent = true,
|
||||
)
|
||||
|
||||
StyledSlider(
|
||||
label = stringResource(R.string.ambient_noise_reduction),
|
||||
valueRange = 0f..1f,
|
||||
value = ambientNoiseReductionSliderValue.floatValue,
|
||||
onValueChange = {
|
||||
ambientNoiseReductionSliderValue.floatValue = it
|
||||
},
|
||||
startLabel = stringResource(R.string.less),
|
||||
endLabel = stringResource(R.string.more),
|
||||
independent = true,
|
||||
)
|
||||
|
||||
StyledToggle(
|
||||
label = stringResource(R.string.conversation_boost),
|
||||
checked = conversationBoostEnabled.value,
|
||||
description = stringResource(R.string.conversation_boost_description),
|
||||
onCheckedChange = { conversationBoostEnabled.value = it }
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.equalizer),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(16.dp, bottom = 4.dp)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(backgroundColor, RoundedCornerShape(28.dp))
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
for (i in 0 until 8) {
|
||||
val eqValue = remember(eq.value[i]) { mutableFloatStateOf(eq.value[i]) }
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(38.dp)
|
||||
) {
|
||||
Text(
|
||||
text = String.format("%.2f", eqValue.floatValue),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
|
||||
Slider(
|
||||
value = eqValue.floatValue,
|
||||
onValueChange = { newVal ->
|
||||
eqValue.floatValue = newVal
|
||||
val newEQ = eq.value.copyOf()
|
||||
newEQ[i] = eqValue.floatValue
|
||||
eq.value = newEQ
|
||||
},
|
||||
valueRange = 0f..100f,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.9f)
|
||||
.height(36.dp),
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = thumbColor,
|
||||
activeTrackColor = activeTrackColor,
|
||||
inactiveTrackColor = trackColor
|
||||
),
|
||||
thumb = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.shadow(4.dp, CircleShape)
|
||||
.background(thumbColor, CircleShape)
|
||||
)
|
||||
},
|
||||
track = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(12.dp),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
)
|
||||
{
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
.background(trackColor, RoundedCornerShape(4.dp))
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(eqValue.floatValue / 100f)
|
||||
.height(4.dp)
|
||||
.background(
|
||||
activeTrackColor,
|
||||
RoundedCornerShape(4.dp)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.band_label, i + 1),
|
||||
fontSize = 12.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
+920
@@ -0,0 +1,920 @@
|
||||
/*
|
||||
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 android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.FileProvider
|
||||
import com.kyant.backdrop.backdrops.layerBackdrop
|
||||
import com.kyant.backdrop.backdrops.rememberLayerBackdrop
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.utils.LogCollector
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun CustomIconButton(
|
||||
onClick: () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onClick)
|
||||
.padding(8.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||
@Composable
|
||||
fun TroubleshootingScreen() {
|
||||
val context = LocalContext.current
|
||||
val scrollState = rememberScrollState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val logCollector = remember { LogCollector(context) }
|
||||
val savedLogs = remember { mutableStateListOf<File>() }
|
||||
|
||||
var isCollectingLogs by remember { mutableStateOf(false) }
|
||||
var showTroubleshootingSteps by remember { mutableStateOf(false) }
|
||||
var currentStep by remember { mutableIntStateOf(0) }
|
||||
var logContent by remember { mutableStateOf("") }
|
||||
var selectedLogFile by remember { mutableStateOf<File?>(null) }
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
var showDeleteAllDialog by remember { mutableStateOf(false) }
|
||||
var isLoadingLogContent by remember { mutableStateOf(false) }
|
||||
var logContentLoaded by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(isCollectingLogs) {
|
||||
while (isCollectingLogs) {
|
||||
delay(250)
|
||||
delay(250)
|
||||
}
|
||||
}
|
||||
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
||||
var showBottomSheet by remember { mutableStateOf(false) }
|
||||
|
||||
val backgroundColor = if (isSystemInDarkTheme()) Color(0xFF1C1C1E) else Color(0xFFFFFFFF)
|
||||
val textColor = if (isSystemInDarkTheme()) Color.White else Color.Black
|
||||
val accentColor = if (isSystemInDarkTheme()) Color(0xFF007AFF) else Color(0xFF3C6DF5)
|
||||
val buttonBgColor = if (isSystemInDarkTheme()) Color(0xFF333333) else Color(0xFFDDDDDD)
|
||||
|
||||
var instructionText by remember { mutableStateOf("") }
|
||||
val isDarkTheme = isSystemInDarkTheme()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val logsDir = File(context.filesDir, "logs")
|
||||
if (logsDir.exists()) {
|
||||
savedLogs.clear()
|
||||
savedLogs.addAll(logsDir.listFiles()?.filter { it.name.endsWith(".txt") }
|
||||
?.sortedByDescending { it.lastModified() } ?: emptyList())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val saveLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument("text/plain")
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||
outputStream.write(logContent.toByteArray())
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, "Log saved successfully", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Failed to save log: ${e.localizedMessage}",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(currentStep) {
|
||||
instructionText = when (currentStep) {
|
||||
0 -> "First, let's ensure Xposed module is properly configured. Tap the button below to check Xposed scope settings."
|
||||
1 -> "Please put your AirPods in the case and close it, so they disconnect completely."
|
||||
2 -> "Preparing to collect logs... Please wait."
|
||||
3 -> "Now, open the AirPods case and connect your AirPods. Logs are being collected. Connection will be detected automatically, or you can manually stop logging when you're done."
|
||||
4 -> "Log collection complete! You can now save or share the logs."
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
fun openLogBottomSheet(file: File) {
|
||||
selectedLogFile = file
|
||||
logContent = ""
|
||||
isLoadingLogContent = false
|
||||
logContentLoaded = false
|
||||
showBottomSheet = true
|
||||
}
|
||||
|
||||
val backdrop = rememberLayerBackdrop()
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
val topPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
||||
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.layerBackdrop(backdrop)
|
||||
.verticalScroll(scrollState)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.saved_logs),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(16.dp, bottom = 4.dp, top = 8.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
if (savedLogs.isEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
backgroundColor,
|
||||
RoundedCornerShape(28.dp)
|
||||
)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_logs_found),
|
||||
fontSize = 16.sp,
|
||||
color = textColor
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
backgroundColor,
|
||||
RoundedCornerShape(28.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Total Logs: ${savedLogs.size}",
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = textColor
|
||||
)
|
||||
|
||||
if (savedLogs.size > 1) {
|
||||
TextButton(
|
||||
onClick = { showDeleteAllDialog = true },
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("Delete All")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
savedLogs.forEach { logFile ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
.clickable {
|
||||
openLogBottomSheet(logFile)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = logFile.name,
|
||||
fontSize = 16.sp,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
Text(
|
||||
text = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.US)
|
||||
.format(Date(logFile.lastModified())),
|
||||
fontSize = 14.sp,
|
||||
color = textColor.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
onClick = {
|
||||
selectedLogFile = logFile
|
||||
showDeleteDialog = true
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Delete",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = !showTroubleshootingSteps,
|
||||
enter = fadeIn(animationSpec = tween(300)),
|
||||
exit = fadeOut(animationSpec = tween(300))
|
||||
) {
|
||||
Button(
|
||||
onClick = { showTroubleshootingSteps = true },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = buttonBgColor,
|
||||
contentColor = textColor
|
||||
),
|
||||
enabled = !isCollectingLogs
|
||||
) {
|
||||
Text(stringResource(R.string.collect_logs))
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showTroubleshootingSteps,
|
||||
enter = fadeIn(animationSpec = tween(300)) +
|
||||
slideInVertically(animationSpec = tween(300)) { it / 2 },
|
||||
exit = fadeOut(animationSpec = tween(300)) +
|
||||
slideOutVertically(animationSpec = tween(300)) { it / 2 }
|
||||
) {
|
||||
Column {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.troubleshooting_steps),
|
||||
style = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Light,
|
||||
color = textColor.copy(alpha = 0.6f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
modifier = Modifier.padding(16.dp, bottom = 2.dp, top = 8.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
backgroundColor,
|
||||
RoundedCornerShape(28.dp)
|
||||
)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
val textAlpha = animateFloatAsState(
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(durationMillis = 300),
|
||||
label = "textAlpha"
|
||||
)
|
||||
|
||||
Text(
|
||||
text = instructionText,
|
||||
fontSize = 16.sp,
|
||||
color = textColor.copy(alpha = textAlpha.value),
|
||||
lineHeight = 22.sp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
when (currentStep) {
|
||||
0 -> {
|
||||
Button(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
logCollector.openXposedSettings(context)
|
||||
delay(2000)
|
||||
currentStep = 1
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = buttonBgColor,
|
||||
contentColor = textColor
|
||||
)
|
||||
) {
|
||||
Text("Open Xposed Settings")
|
||||
}
|
||||
}
|
||||
|
||||
1 -> {
|
||||
Button(
|
||||
onClick = {
|
||||
currentStep = 2
|
||||
isCollectingLogs = true
|
||||
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
logCollector.clearLogs()
|
||||
|
||||
logCollector.addLogMarker(LogCollector.LogMarkerType.START)
|
||||
|
||||
logCollector.killBluetoothService()
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
delay(500)
|
||||
currentStep = 3
|
||||
}
|
||||
|
||||
val timestamp = SimpleDateFormat(
|
||||
"yyyyMMdd_HHmmss",
|
||||
Locale.US
|
||||
).format(Date())
|
||||
|
||||
logContent =
|
||||
logCollector.startLogCollection(
|
||||
listener = { /* Removed live log display */ },
|
||||
connectionDetectedCallback = {
|
||||
launch {
|
||||
delay(5000)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (isCollectingLogs) {
|
||||
logCollector.stopLogCollection()
|
||||
currentStep = 4
|
||||
isCollectingLogs =
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
val logFile =
|
||||
logCollector.saveLogToInternalStorage(
|
||||
"airpods_log_$timestamp.txt",
|
||||
logContent
|
||||
)
|
||||
logFile?.let {
|
||||
withContext(Dispatchers.Main) {
|
||||
savedLogs.add(0, it)
|
||||
selectedLogFile = it
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Log saved: ${it.name}",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Error collecting logs: ${e.message}",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
isCollectingLogs = false
|
||||
currentStep = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = buttonBgColor,
|
||||
contentColor = textColor
|
||||
)
|
||||
) {
|
||||
Text("Continue")
|
||||
}
|
||||
}
|
||||
|
||||
2, 3 -> {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = accentColor
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = if (currentStep == 2) "Preparing..." else "Collecting logs...",
|
||||
fontSize = 14.sp,
|
||||
color = textColor
|
||||
)
|
||||
|
||||
if (currentStep == 3) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
logCollector.addLogMarker(
|
||||
LogCollector.LogMarkerType.CUSTOM,
|
||||
"Manual stop requested by user"
|
||||
)
|
||||
delay(1000)
|
||||
logCollector.stopLogCollection()
|
||||
delay(500)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
currentStep = 4
|
||||
isCollectingLogs = false
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Log collection stopped",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = buttonBgColor,
|
||||
contentColor = textColor
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Text("Stop Collection")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
4 -> {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
selectedLogFile?.let { file ->
|
||||
val fileUri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.provider",
|
||||
file
|
||||
)
|
||||
val shareIntent =
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(
|
||||
Intent.EXTRA_STREAM,
|
||||
fileUri
|
||||
)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(
|
||||
Intent.createChooser(
|
||||
shareIntent,
|
||||
"Share log file"
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = buttonBgColor,
|
||||
contentColor = textColor
|
||||
),
|
||||
modifier = Modifier.width(150.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Share,
|
||||
contentDescription = "Share"
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Share")
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
selectedLogFile?.let { file ->
|
||||
saveLauncher.launch(
|
||||
file.absolutePath
|
||||
)
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = buttonBgColor,
|
||||
contentColor = textColor
|
||||
),
|
||||
modifier = Modifier.width(150.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_save),
|
||||
contentDescription = "Save"
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
currentStep = 0
|
||||
showTroubleshootingSteps = false
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = buttonBgColor,
|
||||
contentColor = textColor
|
||||
)
|
||||
) {
|
||||
Text("Done")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDeleteDialog && selectedLogFile != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteDialog = false },
|
||||
title = { Text("Delete Log File") },
|
||||
text = {
|
||||
Text("Are you sure you want to delete this log file? This action cannot be undone.")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
selectedLogFile?.let { file ->
|
||||
if (file.delete()) {
|
||||
savedLogs.remove(file)
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Log file deleted",
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Failed to delete log file",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
showDeleteDialog = false
|
||||
}
|
||||
) {
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showDeleteAllDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteAllDialog = false },
|
||||
title = { Text("Delete All Logs") },
|
||||
text = {
|
||||
Text("Are you sure you want to delete all log files? This action cannot be undone and will remove ${savedLogs.size} log files.")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
coroutineScope.launch(Dispatchers.IO) {
|
||||
var deletedCount = 0
|
||||
savedLogs.forEach { file ->
|
||||
if (file.delete()) {
|
||||
deletedCount++
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
if (deletedCount > 0) {
|
||||
savedLogs.clear()
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Deleted $deletedCount log files",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
} else {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Failed to delete log files",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
showDeleteAllDialog = false
|
||||
}
|
||||
) {
|
||||
Text("Delete All", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteAllDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
|
||||
if (showBottomSheet) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showBottomSheet = false },
|
||||
sheetState = sheetState,
|
||||
containerColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color(0xFFF2F2F7),
|
||||
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
|
||||
tonalElevation = 8.dp
|
||||
) {
|
||||
LaunchedEffect(selectedLogFile) {
|
||||
if (!logContentLoaded) {
|
||||
delay(300)
|
||||
withContext(Dispatchers.IO) {
|
||||
isLoadingLogContent = true
|
||||
logContent = try {
|
||||
selectedLogFile?.readText() ?: ""
|
||||
} catch (e: Exception) {
|
||||
"Error loading log content: ${e.message}"
|
||||
}
|
||||
isLoadingLogContent = false
|
||||
logContentLoaded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||
.padding(bottom = 32.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = selectedLogFile?.name ?: "Log Content",
|
||||
style = TextStyle(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
),
|
||||
color = textColor
|
||||
)
|
||||
Text(
|
||||
text = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.US)
|
||||
.format(Date(selectedLogFile?.lastModified() ?: 0)),
|
||||
fontSize = 14.sp,
|
||||
color = textColor.copy(alpha = 0.7f),
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro))
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoadingLogContent) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(color = accentColor)
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(300.dp)
|
||||
.background(
|
||||
color = Color.Black,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
) {
|
||||
val horizontalScrollState = rememberScrollState()
|
||||
val verticalScrollState = rememberScrollState()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(8.dp)
|
||||
.horizontalScroll(horizontalScrollState)
|
||||
.verticalScroll(verticalScrollState)
|
||||
) {
|
||||
Text(
|
||||
text = logContent,
|
||||
fontSize = 14.sp,
|
||||
color = Color.LightGray,
|
||||
lineHeight = 20.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
softWrap = false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
selectedLogFile?.let { file ->
|
||||
val fileUri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.provider",
|
||||
file
|
||||
)
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_STREAM, fileUri)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
context.startActivity(
|
||||
Intent.createChooser(
|
||||
shareIntent,
|
||||
"Share log file"
|
||||
)
|
||||
)
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = buttonBgColor,
|
||||
contentColor = textColor
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Share,
|
||||
contentDescription = "Share"
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Share")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
selectedLogFile?.let { file ->
|
||||
saveLauncher.launch(file.absolutePath)
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = buttonBgColor,
|
||||
contentColor = textColor
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_save),
|
||||
contentDescription = "Save"
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
logCollector.stopLogCollection()
|
||||
}
|
||||
}
|
||||
}
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
/*
|
||||
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 android.util.Log
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.Job
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.bluetooth.ATTHandles
|
||||
import me.kavishdevar.librepods.data.HearingAidSettings
|
||||
import me.kavishdevar.librepods.data.parseHearingAidSettingsResponse
|
||||
import me.kavishdevar.librepods.data.sendHearingAidSettings
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LibrePodsTheme
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsUiState
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.demoState
|
||||
|
||||
private const val TAG = "UpdateHearingTestScreen"
|
||||
|
||||
@Composable
|
||||
fun UpdateHearingTestRoute(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)
|
||||
) {
|
||||
UpdateHearingTestScreen(
|
||||
state = state,
|
||||
topPadding = topPadding,
|
||||
bottomPadding = bottomPadding,
|
||||
setATTCharacteristicValue = viewModel::setATTCharacteristicValue
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UpdateHearingTestScreen(
|
||||
state: AirPodsUiState,
|
||||
topPadding: Dp = 16.dp,
|
||||
bottomPadding: Dp = 16.dp,
|
||||
setATTCharacteristicValue: (ATTHandles, ByteArray) -> Unit
|
||||
) {
|
||||
val verticalScrollState = rememberScrollState()
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(verticalScrollState)
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.hearing_test_value_instruction),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
val tone = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||
val ambientNoiseReduction = rememberSaveable { mutableFloatStateOf(0.0f) }
|
||||
val ownVoiceAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||
val leftAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||
val rightAmplification = rememberSaveable { mutableFloatStateOf(0.5f) }
|
||||
val conversationBoostEnabled = rememberSaveable { mutableStateOf(false) }
|
||||
val leftEQ = rememberSaveable(
|
||||
saver = Saver(
|
||||
save = { it.value.toList() },
|
||||
restore = { mutableStateOf(it.toFloatArray()) }
|
||||
)
|
||||
) {
|
||||
mutableStateOf(FloatArray(8))
|
||||
}
|
||||
val rightEQ = rememberSaveable(
|
||||
saver = Saver(
|
||||
save = { it.value.toList() },
|
||||
restore = { mutableStateOf(it.toFloatArray()) }
|
||||
)
|
||||
) {
|
||||
mutableStateOf(FloatArray(8))
|
||||
}
|
||||
|
||||
val debounceJob = remember { mutableStateOf<Job?>(null) }
|
||||
val initialized = rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val hearingAidSettings = remember {
|
||||
mutableStateOf(
|
||||
HearingAidSettings(
|
||||
leftEQ = leftEQ.value,
|
||||
rightEQ = rightEQ.value,
|
||||
leftAmplification = leftAmplification.floatValue,
|
||||
rightAmplification = rightAmplification.floatValue,
|
||||
leftTone = tone.floatValue,
|
||||
rightTone = tone.floatValue,
|
||||
leftConversationBoost = conversationBoostEnabled.value,
|
||||
rightConversationBoost = conversationBoostEnabled.value,
|
||||
leftAmbientNoiseReduction = ambientNoiseReduction.floatValue,
|
||||
rightAmbientNoiseReduction = ambientNoiseReduction.floatValue,
|
||||
netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2,
|
||||
balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
|
||||
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(state.hearingAidData) {
|
||||
val parsed = parseHearingAidSettingsResponse(state.hearingAidData)
|
||||
if (parsed != null) {
|
||||
leftEQ.value = parsed.leftEQ.copyOf()
|
||||
rightEQ.value = parsed.rightEQ.copyOf()
|
||||
conversationBoostEnabled.value = parsed.leftConversationBoost
|
||||
tone.floatValue = parsed.leftTone
|
||||
ambientNoiseReduction.floatValue = parsed.leftAmbientNoiseReduction
|
||||
ownVoiceAmplification.floatValue = parsed.ownVoiceAmplification
|
||||
leftAmplification.floatValue = parsed.leftAmplification
|
||||
rightAmplification.floatValue = parsed.rightAmplification
|
||||
initialized.value = true
|
||||
Log.d(TAG, "Updated hearing aid settings from notification")
|
||||
} else {
|
||||
Log.w(TAG, "Failed to parse hearing aid settings from notification")
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
leftEQ.value,
|
||||
rightEQ.value,
|
||||
conversationBoostEnabled.value,
|
||||
leftAmplification.floatValue,
|
||||
rightAmplification.floatValue,
|
||||
tone.floatValue,
|
||||
ambientNoiseReduction.floatValue,
|
||||
ownVoiceAmplification.floatValue
|
||||
) {
|
||||
if (!initialized.value) return@LaunchedEffect
|
||||
hearingAidSettings.value = HearingAidSettings(
|
||||
leftEQ = leftEQ.value,
|
||||
rightEQ = rightEQ.value,
|
||||
leftAmplification = leftAmplification.floatValue,
|
||||
rightAmplification = rightAmplification.floatValue,
|
||||
leftTone = tone.floatValue,
|
||||
rightTone = tone.floatValue,
|
||||
leftConversationBoost = conversationBoostEnabled.value,
|
||||
rightConversationBoost = conversationBoostEnabled.value,
|
||||
leftAmbientNoiseReduction = ambientNoiseReduction.floatValue,
|
||||
rightAmbientNoiseReduction = ambientNoiseReduction.floatValue,
|
||||
netAmplification = leftAmplification.floatValue + rightAmplification.floatValue / 2,
|
||||
balance = 0.5f + (rightAmplification.floatValue - leftAmplification.floatValue) / 2,
|
||||
ownVoiceAmplification = ownVoiceAmplification.floatValue
|
||||
)
|
||||
Log.d(TAG, "Updated settings: ${hearingAidSettings.value}")
|
||||
sendHearingAidSettings(state.hearingAidData, hearingAidSettings.value, debounceJob, setATTCharacteristicValue)
|
||||
}
|
||||
|
||||
val frequencies =
|
||||
listOf("250Hz", "500Hz", "1kHz", "2kHz", "3kHz", "4kHz", "6kHz", "8kHz")
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.width(60.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.left),
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelMediumEmphasized
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.right),
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelMediumEmphasized
|
||||
)
|
||||
}
|
||||
|
||||
frequencies.forEachIndexed { index, freq ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = freq,
|
||||
modifier = Modifier
|
||||
.width(60.dp)
|
||||
.align(Alignment.CenterVertically),
|
||||
textAlign = TextAlign.End,
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = leftEQ.value[index].toString(),
|
||||
onValueChange = { newValue ->
|
||||
val parsed = newValue.toFloatOrNull()
|
||||
if (parsed != null) {
|
||||
val newArray = leftEQ.value.copyOf()
|
||||
newArray[index] = parsed
|
||||
leftEQ.value = newArray
|
||||
Log.d(TAG, "Left EQ updated at index $index to $parsed")
|
||||
}
|
||||
},
|
||||
// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
textStyle = TextStyle(
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontSize = 14.sp
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = rightEQ.value[index].toString(),
|
||||
onValueChange = { newValue ->
|
||||
val parsed = newValue.toFloatOrNull()
|
||||
if (parsed != null) {
|
||||
val newArray = rightEQ.value.copyOf()
|
||||
newArray[index] = parsed
|
||||
rightEQ.value = newArray
|
||||
Log.d(TAG, "Right EQ updated at index $index to $parsed")
|
||||
}
|
||||
},
|
||||
// label = { Text("Value", fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro))) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
textStyle = TextStyle(
|
||||
fontFamily = FontFamily(Font(R.font.sf_pro)),
|
||||
fontSize = 14.sp
|
||||
),
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Apple")
|
||||
@Composable
|
||||
fun UpdateHearingTestScreenPreviewApple() {
|
||||
LibrePodsTheme(
|
||||
m3eEnabled = false
|
||||
) {
|
||||
Box (
|
||||
modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
) {
|
||||
UpdateHearingTestScreen(
|
||||
state = demoState,
|
||||
setATTCharacteristicValue = { _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "Material")
|
||||
@Composable
|
||||
fun UpdateHearingTestScreenPreviewMaterial() {
|
||||
LibrePodsTheme(
|
||||
m3eEnabled = true
|
||||
) {
|
||||
Box (
|
||||
modifier = Modifier
|
||||
.wrapContentHeight()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
) {
|
||||
UpdateHearingTestScreen(
|
||||
state = demoState,
|
||||
setATTCharacteristicValue = { _, _ -> }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
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.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.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 me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.components.StyledList
|
||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||
import me.kavishdevar.librepods.presentation.theme.DesignSystem
|
||||
import me.kavishdevar.librepods.presentation.theme.LocalDesignSystem
|
||||
import me.kavishdevar.librepods.presentation.viewmodel.AirPodsViewModel
|
||||
|
||||
@Composable
|
||||
fun VersionScreen(viewModel: AirPodsViewModel) {
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
|
||||
val m3eEnabled = LocalDesignSystem.current == DesignSystem.Material
|
||||
val topPadding = if (m3eEnabled) 0.dp else WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 84.dp
|
||||
val bottomPadding = if (m3eEnabled) 0.dp else WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 12.dp
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(topPadding))
|
||||
StyledList(title = stringResource(R.string.version)) {
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.version) + " 1",
|
||||
description = state.version1,
|
||||
enabled = false
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.version) + " 2",
|
||||
description = state.version2,
|
||||
enabled = false
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
name = stringResource(R.string.version) + " 3",
|
||||
description = state.version3,
|
||||
enabled = false
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(bottomPadding))
|
||||
}
|
||||
}
|
||||
+8
-13
@@ -4,8 +4,6 @@ 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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -14,13 +12,12 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import me.kavishdevar.librepods.R
|
||||
import me.kavishdevar.librepods.presentation.components.common.AppInfoCard
|
||||
import me.kavishdevar.librepods.presentation.components.common.DeviceInfoCard
|
||||
import me.kavishdevar.librepods.presentation.components.primitives.StyledListItem
|
||||
import me.kavishdevar.librepods.presentation.components.AppInfoCard
|
||||
import me.kavishdevar.librepods.presentation.components.DeviceInfoCard
|
||||
import me.kavishdevar.librepods.presentation.components.StyledListItem
|
||||
|
||||
@Composable
|
||||
fun NotSupportedPage(
|
||||
@@ -30,10 +27,9 @@ fun NotSupportedPage(
|
||||
|
||||
Box(
|
||||
modifier = Modifier.background(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
shape = RoundedCornerShape(42.dp)
|
||||
)
|
||||
.clip(RoundedCornerShape(42.dp))
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
shape = RoundedCornerShape(42.dp)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -41,7 +37,6 @@ fun NotSupportedPage(
|
||||
.verticalScroll(scrollState),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.check_the_repository_for_more_info),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -52,11 +47,11 @@ fun NotSupportedPage(
|
||||
)
|
||||
DeviceInfoCard()
|
||||
AppInfoCard()
|
||||
|
||||
StyledListItem(
|
||||
contentText = stringResource(R.string.bypass_compatibility_check),
|
||||
name = stringResource(R.string.bypass_compatibility_check),
|
||||
onClick = bypassCompatibilityCheck
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-7
@@ -40,14 +40,16 @@ 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.DesignSystem
|
||||
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,
|
||||
@@ -82,12 +84,12 @@ fun OnboardingScreen(
|
||||
}
|
||||
|
||||
LibrePodsTheme(
|
||||
designSystem = DesignSystem.Material
|
||||
m3eEnabled = true
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
@@ -99,15 +101,14 @@ fun OnboardingScreen(
|
||||
.padding(12.dp),
|
||||
state = state,
|
||||
itemWidth = LocalWindowInfo.current.containerDpSize.width - 24.dp,
|
||||
userScrollEnabled = false,
|
||||
userScrollEnabled = false
|
||||
) { index ->
|
||||
val shape = rememberMaskShape(RoundedCornerShape(52.dp))
|
||||
Surface(
|
||||
shape = shape,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(52.dp)),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer
|
||||
.clip(RoundedCornerShape(52.dp))
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier,
|
||||
@@ -135,7 +136,7 @@ fun OnboardingScreen(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.welcome_to_appname),
|
||||
text = "Welcome to",
|
||||
style = MaterialTheme.typography.displayLarge,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -191,6 +192,9 @@ fun OnboardingScreen(
|
||||
NotSupportedPage(
|
||||
bypassCompatibilityCheck = {
|
||||
bypassDeviceCheck(sharedPreferences)
|
||||
animationScope.launch {
|
||||
state.animateScrollToItem(3)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -199,6 +203,9 @@ fun OnboardingScreen(
|
||||
onBackward = {
|
||||
animationScope.launch {
|
||||
if (state.canScrollBackward) state.animateScrollToItem(if (isSupported) 1 else 2)
|
||||
if (!isSupported) {
|
||||
removeDeviceCheckBypass(sharedPreferences)
|
||||
}
|
||||
}
|
||||
},
|
||||
onForward = onOnboardingComplete
|
||||
@@ -213,6 +220,8 @@ fun OnboardingScreen(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Preview
|
||||
@Composable
|
||||
fun OnboardingScreenPreview(){
|
||||
+40
-86
@@ -37,11 +37,8 @@ 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.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.health.connect.client.permission.HealthPermission
|
||||
import androidx.health.connect.client.records.HeartRateRecord
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
@@ -49,12 +46,12 @@ 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.R
|
||||
import me.kavishdevar.librepods.presentation.components.primitives.StyledList
|
||||
import me.kavishdevar.librepods.presentation.components.primitives.StyledListItem
|
||||
import me.kavishdevar.librepods.presentation.components.primitives.StyledListItemOrientation
|
||||
import me.kavishdevar.librepods.presentation.icons.MaterialIcons
|
||||
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,
|
||||
@@ -66,8 +63,11 @@ fun PermissionsPage(
|
||||
val context = LocalContext.current
|
||||
val canDrawOverlays = remember { mutableStateOf(Settings.canDrawOverlays(context)) }
|
||||
|
||||
val healthPermissions = rememberPermissionState(
|
||||
HealthPermission.getWritePermission(HeartRateRecord::class)
|
||||
val phonePermissionState = rememberMultiplePermissionsState(
|
||||
listOf(
|
||||
"android.permission.READ_PHONE_STATE",
|
||||
"android.permission.ANSWER_PHONE_CALLS"
|
||||
)
|
||||
) {
|
||||
if (grantingAll) {
|
||||
if (!canDrawOverlays.value) {
|
||||
@@ -80,27 +80,15 @@ fun PermissionsPage(
|
||||
}
|
||||
}
|
||||
|
||||
val phonePermissionState = rememberMultiplePermissionsState(
|
||||
listOf(
|
||||
"android.permission.READ_PHONE_STATE",
|
||||
"android.permission.ANSWER_PHONE_CALLS"
|
||||
)
|
||||
) {
|
||||
if (grantingAll) {
|
||||
if (!healthPermissions.status.isGranted) healthPermissions.launchPermissionRequest()
|
||||
else if (!canDrawOverlays.value) canDrawOverlays.value = Settings.canDrawOverlays(context)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val notificationPermissionState = rememberPermissionState("android.permission.POST_NOTIFICATIONS") {
|
||||
if (grantingAll) {
|
||||
if (!phonePermissionState.allPermissionsGranted) phonePermissionState.launchMultiplePermissionRequest()
|
||||
else if (!healthPermissions.status.isGranted) healthPermissions.launchPermissionRequest()
|
||||
else if (!canDrawOverlays.value) canDrawOverlays.value = Settings.canDrawOverlays(context)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val bluetoothPermissionsState = rememberMultiplePermissionsState(
|
||||
listOf(
|
||||
"android.permission.BLUETOOTH_CONNECT",
|
||||
@@ -113,7 +101,6 @@ fun PermissionsPage(
|
||||
if (grantingAll) {
|
||||
if (!notificationPermissionState.status.isGranted) notificationPermissionState.launchPermissionRequest()
|
||||
else if (!phonePermissionState.allPermissionsGranted) phonePermissionState.launchMultiplePermissionRequest()
|
||||
else if (!healthPermissions.status.isGranted) healthPermissions.launchPermissionRequest()
|
||||
else if (!canDrawOverlays.value) canDrawOverlays.value = Settings.canDrawOverlays(context)
|
||||
}
|
||||
}
|
||||
@@ -138,7 +125,7 @@ fun PermissionsPage(
|
||||
|
||||
Box(
|
||||
modifier = Modifier.background(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
shape = RoundedCornerShape(42.dp)
|
||||
)
|
||||
) {
|
||||
@@ -149,22 +136,22 @@ fun PermissionsPage(
|
||||
.verticalScroll(scrollState),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
StyledList(title = stringResource(R.string.required_permissions)) {
|
||||
val animatedBluetoothIconColor by animateColorAsState(if (bluetoothPermissionsState.allPermissionsGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
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.surfaceVariant
|
||||
if (bluetoothPermissionsState.allPermissionsGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
contentText = stringResource(R.string.bluetooth),
|
||||
name = "Bluetooth",
|
||||
onClick = if (!bluetoothPermissionsState.allPermissionsGranted) {
|
||||
{
|
||||
grantingAll = false
|
||||
bluetoothPermissionsState.launchMultiplePermissionRequest()
|
||||
}
|
||||
} else null,
|
||||
supportingText = stringResource(R.string.permission_description_bluetooth),
|
||||
orientation = StyledListItemOrientation.Vertical,
|
||||
description = "Required to communicate with AirPods",
|
||||
orientation = ListItemOrientation.Vertical,
|
||||
leadingContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -177,7 +164,7 @@ fun PermissionsPage(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MaterialIcons.Bluetooth,
|
||||
imageVector = MaterialIcons.bluetooth,
|
||||
contentDescription = "bluetooth",
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = animatedBluetoothIconColor
|
||||
@@ -186,28 +173,28 @@ fun PermissionsPage(
|
||||
},
|
||||
)
|
||||
}
|
||||
StyledList(title = stringResource(R.string.optional_permissions)) {
|
||||
StyledList(title = "Optional Permissions") {
|
||||
val animatedNotificationsIconColor by animateColorAsState(
|
||||
if (notificationPermissionState.status.isGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
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.surfaceVariant
|
||||
if (notificationPermissionState.status.isGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
)
|
||||
val animatedPhoneIconColor by animateColorAsState(if (phonePermissionState.allPermissionsGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
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.surfaceVariant
|
||||
if (phonePermissionState.allPermissionsGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
)
|
||||
|
||||
StyledListItem(
|
||||
contentText = stringResource(R.string.notifications),
|
||||
name = "Notifications",
|
||||
onClick = if (!notificationPermissionState.status.isGranted) {
|
||||
{
|
||||
grantingAll = false
|
||||
notificationPermissionState.launchPermissionRequest()
|
||||
}
|
||||
} else null,
|
||||
supportingText = stringResource(R.string.permission_description_notification),
|
||||
orientation = StyledListItemOrientation.Vertical,
|
||||
description = "Show battery status",
|
||||
orientation = ListItemOrientation.Vertical,
|
||||
leadingContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -220,7 +207,7 @@ fun PermissionsPage(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MaterialIcons.Notifications,
|
||||
imageVector = MaterialIcons.notifications,
|
||||
contentDescription = "notifications",
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = animatedNotificationsIconColor
|
||||
@@ -229,15 +216,15 @@ fun PermissionsPage(
|
||||
},
|
||||
)
|
||||
StyledListItem(
|
||||
contentText = stringResource(R.string.phone),
|
||||
name = "Phone",
|
||||
onClick = if (!phonePermissionState.allPermissionsGranted) {
|
||||
{
|
||||
grantingAll = false
|
||||
phonePermissionState.launchMultiplePermissionRequest()
|
||||
}
|
||||
} else null,
|
||||
supportingText = stringResource(R.string.permission_description_phone),
|
||||
orientation = StyledListItemOrientation.Vertical,
|
||||
description = "Respond to phone calls with head gestures",
|
||||
orientation = ListItemOrientation.Vertical,
|
||||
leadingContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -250,8 +237,8 @@ fun PermissionsPage(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MaterialIcons.Call,
|
||||
contentDescription = "call",
|
||||
imageVector = MaterialIcons.call,
|
||||
contentDescription = "bluetooth",
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = animatedPhoneIconColor
|
||||
)
|
||||
@@ -260,44 +247,11 @@ fun PermissionsPage(
|
||||
)
|
||||
}
|
||||
|
||||
val animatedHealthConnectIconColor by animateColorAsState(if (healthPermissions.status.isGranted) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
val animatedHealthConnectContainerColor by animateColorAsState(if (healthPermissions.status.isGranted) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant)
|
||||
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(
|
||||
contentText = stringResource(R.string.permission_healthconnect),
|
||||
onClick = if (!healthPermissions.status.isGranted) {
|
||||
{
|
||||
grantingAll = false
|
||||
healthPermissions.launchPermissionRequest()
|
||||
}
|
||||
} else null,
|
||||
supportingText = stringResource(R.string.permission_description_healthconnect),
|
||||
leadingContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.background(
|
||||
animatedHealthConnectContainerColor,
|
||||
MaterialShapes.SoftBurst.normalized()
|
||||
.toShape()
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MaterialIcons.VitalSigns,
|
||||
contentDescription = "vital signs",
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = animatedHealthConnectIconColor
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
val animatedOverlayIconColor by animateColorAsState(if (canDrawOverlays.value) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
val animatedOverlayContainerColor by animateColorAsState(if (canDrawOverlays.value) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant)
|
||||
|
||||
StyledListItem(
|
||||
contentText = stringResource(R.string.permission_overlay),
|
||||
name = "Display over other apps",
|
||||
onClick = if (!canDrawOverlays.value) {
|
||||
{
|
||||
grantingAll = false
|
||||
@@ -308,8 +262,8 @@ fun PermissionsPage(
|
||||
context.startActivity(intent)
|
||||
}
|
||||
} else null,
|
||||
supportingText = stringResource(R.string.permission_description_overlay),
|
||||
orientation = StyledListItemOrientation.Vertical,
|
||||
description = "Show popups when AirPods are nearby or audio switches to them.",
|
||||
orientation = ListItemOrientation.Vertical,
|
||||
leadingContent = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -322,8 +276,8 @@ fun PermissionsPage(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = MaterialIcons.Overlay,
|
||||
contentDescription = "overlay",
|
||||
imageVector = MaterialIcons.stack,
|
||||
contentDescription = "bluetooth",
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = animatedOverlayIconColor
|
||||
)
|
||||
+28
-58
@@ -29,7 +29,7 @@ fun PrivacyPolicyPage(
|
||||
|
||||
Box(
|
||||
modifier = Modifier.background(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
shape = RoundedCornerShape(42.dp)
|
||||
)
|
||||
) {
|
||||
@@ -48,50 +48,42 @@ fun PrivacyPolicyPage(
|
||||
|
||||
Text(
|
||||
text = "Overview",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
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,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
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,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Third Party Services",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "LibrePods provides several ways to contact me, including email, Discord, and GitHub Issues.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Email",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
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,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "You can edit or remove any of this information before sending the email.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
@@ -101,26 +93,22 @@ fun PrivacyPolicyPage(
|
||||
|
||||
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,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
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,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "GitHub Issues",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
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,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Column(
|
||||
@@ -129,86 +117,68 @@ fun PrivacyPolicyPage(
|
||||
) {
|
||||
Text(
|
||||
"• LibrePods version name and version code",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "• Device manufacturer and model",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "• Android build information",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
text = "• Installation source (Google Play or GitHub)",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
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,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "Payments",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
text = "Payments", style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
|
||||
if (BuildConfig.PLAY_BUILD) {
|
||||
Text(
|
||||
text = "Google Play",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
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,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
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,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "GitHub Sponsors",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
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,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
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,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Contact",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
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,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
|
||||
Button(
|
||||
@@ -217,7 +187,7 @@ fun PrivacyPolicyPage(
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.i_agree),
|
||||
style = MaterialTheme.typography.labelMediumEmphasized,
|
||||
style = MaterialTheme.typography.labelMediumEmphasized
|
||||
)
|
||||
}
|
||||
|
||||
+8
-16
@@ -16,23 +16,15 @@
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package me.kavishdevar.librepods.utils
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
package me.kavishdevar.librepods.presentation.theme
|
||||
|
||||
data class Acceleration(val vertical: Float = 0f, val horizontal: Float = 0f)
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// TODO: remove
|
||||
object HeadTracking {
|
||||
private val _acceleration = MutableStateFlow(Acceleration())
|
||||
val acceleration = _acceleration.asStateFlow()
|
||||
val Purple80 = Color(0xFFD0BCFF)
|
||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||
val Pink80 = Color(0xFFEFB8C8)
|
||||
|
||||
fun addAccel(vert: Float, horiz: Float) {
|
||||
_acceleration.value = Acceleration(vert, horiz)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
_acceleration.value = Acceleration()
|
||||
}
|
||||
}
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFF7D5260)
|
||||
+6
-2
@@ -1,8 +1,12 @@
|
||||
package me.kavishdevar.librepods.presentation.theme
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
enum class DesignSystem {
|
||||
Apple,
|
||||
Material
|
||||
}
|
||||
|
||||
val LocalDesignSystem = compositionLocalOf {
|
||||
DesignSystem.Material
|
||||
DesignSystem.Apple
|
||||
}
|
||||
+18
-46
@@ -30,86 +30,58 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import me.kavishdevar.librepods.presentation.icons.AppleIcons
|
||||
import me.kavishdevar.librepods.presentation.icons.LocalIcons
|
||||
import me.kavishdevar.librepods.presentation.icons.MaterialIcons
|
||||
|
||||
val ColorScheme.sectionHeader: Color
|
||||
get() = onBackground.copy(alpha = 0.6f)
|
||||
|
||||
private val AppleDarkColorScheme = darkColorScheme(
|
||||
background = Color(0xFF000000),
|
||||
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),
|
||||
surfaceContainer = Color(0xFF000000),
|
||||
surface = Color(0xFF1C1C1E),
|
||||
onSurface = Color(0xFFFFFFFF),
|
||||
surfaceDim = Color(0x40888888),
|
||||
primary = Color(0xFF0091FF),
|
||||
onPrimary = Color(0xFFFFFFFF),
|
||||
primaryContainer = Color(0xFF003258),
|
||||
onPrimaryContainer = Color(0xFFB3D9FF),
|
||||
secondaryContainer = Color(0xFF366AA8),
|
||||
onSecondaryContainer = Color(0xFFB3D9FF),
|
||||
tertiary = Color(0xFFEA7B00),
|
||||
scrim = Color(0x8C000000),
|
||||
surfaceContainerHigh = Color(0xFF1C1C1E),
|
||||
surfaceContainerLow = Color(0xFF2C2C2E)
|
||||
onSecondaryContainer = Color(0xFF0091FF),
|
||||
onPrimary = Color(0xFFFFFFFF)
|
||||
)
|
||||
|
||||
private val AppleLightColorScheme = lightColorScheme(
|
||||
background = Color(0xFFF2F2F7),
|
||||
onBackground = Color(0xFF000000),
|
||||
surfaceContainer = Color(0xFFF2F2F7),
|
||||
onBackground = Color(0xFF000000),
|
||||
surface = Color(0xFFFFFFFF),
|
||||
onSurface = Color(0xFF000000),
|
||||
surfaceDim = Color(0x40D9D9D9),
|
||||
primary = Color(0xFF0088FF),
|
||||
onPrimary = Color(0xFFFFFFFF),
|
||||
primaryContainer = Color(0xFFB3D9FF),
|
||||
onPrimaryContainer = Color(0xFF003258),
|
||||
secondaryContainer = Color(0xFF6BC0FF),
|
||||
onSecondaryContainer = Color(0xFF003258),
|
||||
tertiary = Color(0xFFEA7B00),
|
||||
scrim = Color(0xD9F2F2F7),
|
||||
surfaceContainerHigh = Color(0xFFFFFFFF),
|
||||
surfaceContainerLow = Color(0xFFE7E7E7)
|
||||
onSecondaryContainer = Color(0xFF0088FF),
|
||||
primary = Color(0xFF0088FF),
|
||||
onPrimary = Color(0xFFFFFFFF)
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun LibrePodsTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
designSystem: DesignSystem = DesignSystem.Material,
|
||||
overrideMaterialColor: Color? = null,
|
||||
m3eEnabled: Boolean = false,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when(designSystem) {
|
||||
DesignSystem.Material -> {
|
||||
val colorScheme = when {
|
||||
m3eEnabled -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
DesignSystem.Apple -> if (darkTheme) AppleDarkColorScheme else AppleLightColorScheme
|
||||
}
|
||||
|
||||
val typography = when(designSystem) {
|
||||
DesignSystem.Material -> MaterialTypography
|
||||
DesignSystem.Apple -> AppleTypography
|
||||
darkTheme -> AppleDarkColorScheme
|
||||
else -> AppleLightColorScheme
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalDesignSystem provides designSystem,
|
||||
LocalIcons provides when (designSystem) {
|
||||
DesignSystem.Material -> MaterialIcons
|
||||
DesignSystem.Apple -> AppleIcons
|
||||
}
|
||||
LocalDesignSystem provides
|
||||
if (m3eEnabled) DesignSystem.Material
|
||||
else DesignSystem.Apple
|
||||
) {
|
||||
// var colorScheme = colorScheme
|
||||
//
|
||||
// if (designSystem == DesignSystem.Material) {
|
||||
//
|
||||
// }
|
||||
|
||||
MaterialExpressiveTheme(
|
||||
colorScheme = colorScheme,
|
||||
motionScheme = MotionScheme.expressive(),
|
||||
typography = typography,
|
||||
typography = if (m3eEnabled) MaterialTypography else AppleTypography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
+37
-52
@@ -35,83 +35,65 @@ import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontVariation
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.googlefonts.GoogleFont
|
||||
import androidx.compose.ui.tooling.preview.Devices.PIXEL_9_PRO_XL
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.kavishdevar.librepods.R
|
||||
|
||||
val interFamily = FontFamily(Font(R.font.inter))
|
||||
val sfProFamily = FontFamily(Font(R.font.sf_pro))
|
||||
|
||||
val AppleTypography = Typography().run {
|
||||
copy(
|
||||
displayLarge = displayLarge.copy(fontFamily = interFamily),
|
||||
displayMedium = displayMedium.copy(fontFamily = interFamily),
|
||||
displaySmall = displaySmall.copy(fontFamily = interFamily),
|
||||
displayLarge = displayLarge.copy(fontFamily = sfProFamily),
|
||||
displayMedium = displayMedium.copy(fontFamily = sfProFamily),
|
||||
displaySmall = displaySmall.copy(fontFamily = sfProFamily),
|
||||
|
||||
headlineLarge = headlineLarge.copy(fontFamily = interFamily),
|
||||
headlineMedium = headlineMedium.copy(fontFamily = interFamily),
|
||||
headlineSmall = headlineSmall.copy(fontFamily = interFamily),
|
||||
headlineLarge = headlineLarge.copy(fontFamily = sfProFamily),
|
||||
headlineMedium = headlineMedium.copy(fontFamily = sfProFamily),
|
||||
headlineSmall = headlineSmall.copy(fontFamily = sfProFamily),
|
||||
|
||||
titleLarge = titleLarge.copy(fontFamily = interFamily),
|
||||
titleMedium = titleMedium.copy(fontFamily = interFamily),
|
||||
titleSmall = titleSmall.copy(fontFamily = interFamily),
|
||||
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 = interFamily,
|
||||
fontFamily = sfProFamily,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 18.sp
|
||||
),
|
||||
bodySmallEmphasized = bodySmallEmphasized.copy(
|
||||
fontFamily = interFamily,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 18.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
bodyMedium = bodyMedium.copy(
|
||||
fontFamily = interFamily,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 22.sp
|
||||
),
|
||||
bodyMediumEmphasized = bodyMediumEmphasized.copy(
|
||||
fontFamily = interFamily,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 22.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
bodyLarge = bodyLarge.copy(
|
||||
fontFamily = interFamily,
|
||||
fontSize = 18.sp,
|
||||
lineHeight = 24.sp
|
||||
),
|
||||
bodyLargeEmphasized = bodyLargeEmphasized.copy(
|
||||
fontFamily = interFamily,
|
||||
fontSize = 18.sp,
|
||||
lineHeight = 24.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
|
||||
|
||||
labelLarge = labelLarge.copy(fontFamily = interFamily),
|
||||
labelLarge = labelLarge.copy(fontFamily = sfProFamily),
|
||||
|
||||
labelMedium = labelMedium.copy(
|
||||
fontFamily = interFamily,
|
||||
fontFamily = sfProFamily,
|
||||
fontSize = 16.sp,
|
||||
),
|
||||
labelMediumEmphasized = labelMediumEmphasized.copy(
|
||||
fontFamily = interFamily,
|
||||
fontFamily = sfProFamily,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
labelSmallEmphasized = labelSmallEmphasized.copy(
|
||||
fontFamily = interFamily,
|
||||
fontFamily = sfProFamily,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 18.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,
|
||||
@@ -121,8 +103,11 @@ private fun robotoFlex(
|
||||
xopq: Float = 96f,
|
||||
yopq: Float = 79f,
|
||||
) = FontFamily(
|
||||
Font(
|
||||
resId = R.font.roboto_flex,
|
||||
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),
|
||||
@@ -251,13 +236,13 @@ val MaterialTypography = Typography().run {
|
||||
bodyMedium = bodyMedium.copy(
|
||||
fontFamily = body,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 22.sp,
|
||||
lineHeight = 24.sp,
|
||||
),
|
||||
|
||||
bodyLarge = bodyLarge.copy(
|
||||
fontFamily = body,
|
||||
fontSize = 18.sp,
|
||||
lineHeight = 24.sp,
|
||||
lineHeight = 28.sp,
|
||||
),
|
||||
|
||||
bodySmallEmphasized = bodySmallEmphasized.copy(
|
||||
@@ -269,13 +254,13 @@ val MaterialTypography = Typography().run {
|
||||
bodyMediumEmphasized = bodyMediumEmphasized.copy(
|
||||
fontFamily = bodyEmphasized,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 22.sp,
|
||||
lineHeight = 24.sp,
|
||||
),
|
||||
|
||||
bodyLargeEmphasized = bodyLargeEmphasized.copy(
|
||||
fontFamily = bodyEmphasized,
|
||||
fontSize = 18.sp,
|
||||
lineHeight = 24.sp,
|
||||
lineHeight = 28.sp,
|
||||
),
|
||||
|
||||
labelSmall = labelSmall.copy(
|
||||
@@ -323,7 +308,7 @@ val MaterialTypography = Typography().run {
|
||||
)
|
||||
@Composable
|
||||
private fun TypographyPreview() {
|
||||
LibrePodsTheme (designSystem = DesignSystem.Material) {
|
||||
LibrePodsTheme (m3eEnabled = true) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
+784
@@ -0,0 +1,784 @@
|
||||
/*
|
||||
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.viewmodel
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.widget.Toast
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.content.edit
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.BuildConfig
|
||||
import me.kavishdevar.librepods.billing.BillingManager
|
||||
import me.kavishdevar.librepods.bluetooth.AACPManager
|
||||
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.BluetoothConnectionManager
|
||||
import me.kavishdevar.librepods.data.AirPodsInstance
|
||||
import me.kavishdevar.librepods.data.AirPodsModels
|
||||
import me.kavishdevar.librepods.data.AirPodsNotifications
|
||||
import me.kavishdevar.librepods.data.Battery
|
||||
import me.kavishdevar.librepods.data.BatteryComponent
|
||||
import me.kavishdevar.librepods.data.BatteryStatus
|
||||
import me.kavishdevar.librepods.data.Capability
|
||||
import me.kavishdevar.librepods.data.ControlCommandRepository
|
||||
import me.kavishdevar.librepods.data.CustomEq
|
||||
import me.kavishdevar.librepods.data.StemAction
|
||||
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
|
||||
import me.kavishdevar.librepods.services.AirPodsService
|
||||
|
||||
@Suppress("ArrayInDataClass")
|
||||
data class AirPodsUiState(
|
||||
val deviceName: String = "AirPods",
|
||||
|
||||
val isLocallyConnected: Boolean = false,
|
||||
|
||||
val instance: AirPodsInstance? = null,
|
||||
val capabilities: Set<Capability> = emptySet(),
|
||||
|
||||
val controlStates: Map<ControlCommandIdentifiers, ByteArray> = emptyMap(),
|
||||
val offListeningMode: Boolean = true,
|
||||
|
||||
val battery: List<Battery> = emptyList(),
|
||||
val ancMode: Int = 3,
|
||||
|
||||
val modelName: String = "",
|
||||
val actualModel: String = "",
|
||||
val serialNumbers: List<String> = emptyList(),
|
||||
val version1: String = "",
|
||||
val version2: String = "",
|
||||
val version3: String = "",
|
||||
|
||||
val headTrackingActive: Boolean = false,
|
||||
val headGesturesEnabled: Boolean = true,
|
||||
|
||||
val eqData: FloatArray = floatArrayOf(),
|
||||
|
||||
val automaticEarDetectionEnabled: Boolean = true,
|
||||
val automaticConnectionEnabled: Boolean = true,
|
||||
|
||||
val leftAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES,
|
||||
val rightAction: StemAction = StemAction.CYCLE_NOISE_CONTROL_MODES,
|
||||
|
||||
val loudSoundReductionEnabled: Boolean = false,
|
||||
val transparencyData: ByteArray = byteArrayOf(),
|
||||
val hearingAidData: ByteArray = byteArrayOf(),
|
||||
|
||||
val isPremium: Boolean = false,
|
||||
val vendorIdHook: Boolean = false,
|
||||
|
||||
val dynamicEndOfCharge: 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(
|
||||
|
||||
) : ViewModel() {
|
||||
private lateinit var sharedPreferences: SharedPreferences
|
||||
private lateinit var appContext: Context
|
||||
private lateinit var service: AirPodsService
|
||||
private lateinit var controlRepo: ControlCommandRepository
|
||||
|
||||
var isReady by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
fun init(service: AirPodsService, controlRepo: ControlCommandRepository, sharedPreferences: SharedPreferences, appContext: Context) {
|
||||
this.service = service
|
||||
this.controlRepo = controlRepo
|
||||
this.sharedPreferences = sharedPreferences
|
||||
this.appContext = appContext
|
||||
|
||||
observeBroadcasts()
|
||||
loadName()
|
||||
loadInstance()
|
||||
loadSharedPreferences()
|
||||
observeAACP()
|
||||
loadCurrentStatus()
|
||||
loadEq()
|
||||
loadATT()
|
||||
observeATT()
|
||||
observeSharedPreferences()
|
||||
observeBilling()
|
||||
if (isDemoMode) activateDemoMode()
|
||||
isReady = true
|
||||
}
|
||||
|
||||
private val _uiState = MutableStateFlow(AirPodsUiState())
|
||||
|
||||
val uiState: StateFlow<AirPodsUiState> = _uiState
|
||||
|
||||
private var isDemoMode = false
|
||||
|
||||
private val listeners =
|
||||
mutableMapOf<ControlCommandIdentifiers, AACPManager.ControlCommandListener>()
|
||||
|
||||
private val xposedRemotePref = XposedRemotePrefProvider.create()
|
||||
|
||||
private lateinit var broadcastReceiver: BroadcastReceiver
|
||||
|
||||
// private val _cameraAction = MutableStateFlow(
|
||||
// sharedPreferences.getString("camera_action", null)
|
||||
// ?.let { value -> AACPManager.Companion.StemPressType.entries.find { it.name == value } })
|
||||
//
|
||||
// val cameraAction: StateFlow<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
|
||||
// }
|
||||
|
||||
fun setCustomEq(low: Int, mid: Int, high: Int) {
|
||||
require(low in 0..100)
|
||||
require(mid in 0..100)
|
||||
require(high in 0..100)
|
||||
val updatedEq = _uiState.value.customEq.copy(low = low, mid = mid, high = high)
|
||||
service.aacpManager.sendCustomEqPacket(updatedEq)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
customEq = updatedEq
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setCustomEqEnabled(enabled: Boolean) {
|
||||
service.aacpManager.sendCustomEqPacket(_uiState.value.customEq.copy(state = if (enabled) 2 else 1))
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
customEq = it.customEq.copy(state = if (enabled) 2 else 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
listeners.forEach { (id, listener) ->
|
||||
controlRepo.remove(id, listener)
|
||||
}
|
||||
service.aacpManager.customEqCallback = null
|
||||
appContext.unregisterReceiver(broadcastReceiver)
|
||||
}
|
||||
|
||||
private fun loadName() {
|
||||
val name = sharedPreferences.getString("name", "AirPods Pro")!!
|
||||
_uiState.update { it.copy(deviceName = name) }
|
||||
}
|
||||
|
||||
private fun observeBilling() {
|
||||
if (isDemoMode) return
|
||||
viewModelScope.launch {
|
||||
BillingManager.provider.isPremium.collect { 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 {
|
||||
if (_uiState.value.timeUntilFOSSPremiumExpiry <= 0L) {
|
||||
setControlCommandBoolean(
|
||||
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
|
||||
false
|
||||
)
|
||||
setHeadGesturesEnabled(false)
|
||||
_uiState.update { it.copy(isPremium = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
broadcastReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
val action = intent?.action ?: return
|
||||
if (!isDemoMode) when (action) {
|
||||
AirPodsNotifications.AIRPODS_L2CAP_CONNECTED -> {
|
||||
_uiState.update {
|
||||
it.copy(isLocallyConnected = true)
|
||||
}
|
||||
}
|
||||
|
||||
AirPodsNotifications.AIRPODS_DISCONNECTED -> {
|
||||
_uiState.update {
|
||||
it.copy(isLocallyConnected = false)
|
||||
}
|
||||
}
|
||||
|
||||
AirPodsNotifications.BATTERY_DATA -> {
|
||||
_uiState.update {
|
||||
it.copy(battery = service.getBattery())
|
||||
}
|
||||
}
|
||||
|
||||
AirPodsNotifications.EQ_DATA -> {
|
||||
val data = intent.getFloatArrayExtra("eqData") ?: floatArrayOf()
|
||||
|
||||
_uiState.update {
|
||||
it.copy(eqData = data)
|
||||
}
|
||||
}
|
||||
|
||||
AirPodsNotifications.AIRPODS_INFORMATION_UPDATED -> {
|
||||
loadInstance()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(AirPodsNotifications.AIRPODS_CONNECTED)
|
||||
addAction(AirPodsNotifications.AIRPODS_DISCONNECTED)
|
||||
addAction(AirPodsNotifications.BATTERY_DATA)
|
||||
addAction(AirPodsNotifications.EQ_DATA)
|
||||
addAction(AirPodsNotifications.AIRPODS_INFORMATION_UPDATED)
|
||||
}
|
||||
|
||||
appContext.registerReceiver(
|
||||
broadcastReceiver, filter, Context.RECEIVER_NOT_EXPORTED
|
||||
)
|
||||
}
|
||||
|
||||
fun setControlCommandValue(
|
||||
identifier: ControlCommandIdentifiers, value: ByteArray
|
||||
) {
|
||||
if (!isDemoMode) controlRepo.setValue(identifier, value)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
controlStates = it.controlStates + (identifier to value)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setControlCommandBoolean(
|
||||
identifier: ControlCommandIdentifiers, enabled: Boolean
|
||||
) {
|
||||
setControlCommandValue(
|
||||
identifier, if (enabled) byteArrayOf(0x01) else byteArrayOf(0x02)
|
||||
)
|
||||
}
|
||||
|
||||
fun setControlCommandInt(
|
||||
identifier: ControlCommandIdentifiers, value: Int
|
||||
) {
|
||||
setControlCommandValue(identifier, byteArrayOf(value.toByte()))
|
||||
}
|
||||
|
||||
fun setControlCommandByte(
|
||||
identifier: ControlCommandIdentifiers, value: Byte
|
||||
) {
|
||||
setControlCommandValue(identifier, byteArrayOf(value))
|
||||
}
|
||||
|
||||
fun observeControl(identifier: ControlCommandIdentifiers) {
|
||||
val listener = controlRepo.observe(identifier) { value ->
|
||||
_uiState.update { state ->
|
||||
val current = state.controlStates[identifier]
|
||||
if (current?.contentEquals(value) == true) return@update state
|
||||
|
||||
if (identifier == ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE) {
|
||||
state.copy(
|
||||
dynamicEndOfCharge = value[0] == 0x01.toByte(),
|
||||
controlStates = state.controlStates + (identifier to value)
|
||||
)
|
||||
} else {
|
||||
state.copy(
|
||||
controlStates = state.controlStates + (identifier to value)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
listeners[identifier] = listener
|
||||
}
|
||||
|
||||
// I'm lazy, sorry.
|
||||
fun observeAACP() {
|
||||
val identifiersList = listOf(
|
||||
ControlCommandIdentifiers.MIC_MODE,
|
||||
ControlCommandIdentifiers.DOUBLE_CLICK_INTERVAL,
|
||||
ControlCommandIdentifiers.CLICK_HOLD_INTERVAL,
|
||||
ControlCommandIdentifiers.LISTENING_MODE_CONFIGS,
|
||||
ControlCommandIdentifiers.ONE_BUD_ANC_MODE,
|
||||
ControlCommandIdentifiers.LISTENING_MODE,
|
||||
ControlCommandIdentifiers.AUTO_ANSWER_MODE,
|
||||
ControlCommandIdentifiers.CHIME_VOLUME,
|
||||
ControlCommandIdentifiers.VOLUME_SWIPE_INTERVAL,
|
||||
ControlCommandIdentifiers.CALL_MANAGEMENT_CONFIG,
|
||||
ControlCommandIdentifiers.VOLUME_SWIPE_MODE,
|
||||
ControlCommandIdentifiers.ADAPTIVE_VOLUME_CONFIG,
|
||||
ControlCommandIdentifiers.CONVERSATION_DETECT_CONFIG,
|
||||
ControlCommandIdentifiers.HEARING_AID,
|
||||
ControlCommandIdentifiers.AUTO_ANC_STRENGTH,
|
||||
ControlCommandIdentifiers.HPS_GAIN_SWIPE,
|
||||
ControlCommandIdentifiers.HEARING_ASSIST_CONFIG,
|
||||
ControlCommandIdentifiers.ALLOW_OFF_OPTION,
|
||||
ControlCommandIdentifiers.STEM_CONFIG,
|
||||
ControlCommandIdentifiers.SLEEP_DETECTION_CONFIG,
|
||||
ControlCommandIdentifiers.ALLOW_AUTO_CONNECT,
|
||||
ControlCommandIdentifiers.EAR_DETECTION_CONFIG,
|
||||
ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG,
|
||||
ControlCommandIdentifiers.OWNS_CONNECTION,
|
||||
ControlCommandIdentifiers.PPE_TOGGLE_CONFIG,
|
||||
ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE
|
||||
)
|
||||
for (identifier in identifiersList) {
|
||||
observeControl(identifier)
|
||||
}
|
||||
service.aacpManager.customEqCallback = { customEq ->
|
||||
_uiState.update { it.copy(customEq = customEq) }
|
||||
}
|
||||
}
|
||||
|
||||
fun loadCurrentStatus() {
|
||||
if (isDemoMode) return
|
||||
service.let { service ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLocallyConnected = BluetoothConnectionManager.aacpSocket?.isConnected == true,
|
||||
battery = service.getBattery(),
|
||||
ancMode = controlRepo.getValue(ControlCommandIdentifiers.LISTENING_MODE)?.get(0)?.toInt() ?: 1,
|
||||
controlStates = controlRepo.getMap()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadSharedPreferences() {
|
||||
val offListeningModeEnabled = sharedPreferences.getBoolean("off_listening_mode", true)
|
||||
val automaticEarDetectionEnabled =
|
||||
sharedPreferences.getBoolean("automatic_ear_detection", true)
|
||||
val automaticConnectionEnabled =
|
||||
sharedPreferences.getBoolean("automatic_connection_ctrl_cmd", true)
|
||||
val headGesturesEnabled = sharedPreferences.getBoolean("head_gestures", true)
|
||||
val leftAction = StemAction.valueOf(
|
||||
sharedPreferences.getString(
|
||||
"left_long_press_action",
|
||||
"CYCLE_NOISE_CONTROL_MODES"
|
||||
) ?: "CYCLE_NOISE_CONTROL_MODES"
|
||||
)
|
||||
val rightAction = StemAction.valueOf(
|
||||
sharedPreferences.getString(
|
||||
"right_long_press_action",
|
||||
"CYCLE_NOISE_CONTROL_MODES"
|
||||
) ?: "CYCLE_NOISE_CONTROL_MODES"
|
||||
)
|
||||
val vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false)
|
||||
val dynamicEndOfCharge = sharedPreferences.getBoolean("dynamic_end_of_charge", false)
|
||||
|
||||
val connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false)
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
offListeningMode = offListeningModeEnabled,
|
||||
automaticEarDetectionEnabled = automaticEarDetectionEnabled,
|
||||
automaticConnectionEnabled = automaticConnectionEnabled,
|
||||
headGesturesEnabled = headGesturesEnabled,
|
||||
leftAction = leftAction,
|
||||
rightAction = rightAction,
|
||||
vendorIdHook = vendorIdHook,
|
||||
dynamicEndOfCharge = dynamicEndOfCharge,
|
||||
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) {
|
||||
sharedPreferences.edit { putBoolean("off_listening_mode", enabled) }
|
||||
setControlCommandBoolean(ControlCommandIdentifiers.ALLOW_OFF_OPTION, enabled)
|
||||
_uiState.update {
|
||||
it.copy(offListeningMode = enabled)
|
||||
}
|
||||
}
|
||||
|
||||
fun setHeadGesturesEnabled(enabled: Boolean) {
|
||||
sharedPreferences.edit { putBoolean("head_gestures", enabled) }
|
||||
_uiState.update {
|
||||
it.copy(headGesturesEnabled = enabled)
|
||||
}
|
||||
}
|
||||
|
||||
fun setDynamicEndOfCharge(enabled: Boolean) {
|
||||
service.aacpManager.sendControlCommand(ControlCommandIdentifiers.DYNAMIC_END_OF_CHARGE.value, enabled)
|
||||
sharedPreferences.edit { putBoolean("dynamic_end_of_charge", enabled) }
|
||||
_uiState.update {
|
||||
it.copy(dynamicEndOfCharge = enabled)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadEq() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
customEq = service.aacpManager.customEq
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadInstance() {
|
||||
val instance = service.airpodsInstance ?: AirPodsInstance(
|
||||
name = "AirPods",
|
||||
model = AirPodsModels.getModelByModelNumber("A3049")!!,
|
||||
actualModelNumber = "A3049",
|
||||
serialNumber = null,
|
||||
leftSerialNumber = null,
|
||||
rightSerialNumber = null,
|
||||
version1 = null,
|
||||
version2 = null,
|
||||
version3 = null,
|
||||
)
|
||||
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
capabilities = instance.model.capabilities,
|
||||
instance = instance,
|
||||
modelName = instance.model.displayName,
|
||||
actualModel = instance.actualModelNumber,
|
||||
serialNumbers = listOf(
|
||||
instance.serialNumber ?: "",
|
||||
instance.leftSerialNumber ?: "",
|
||||
instance.rightSerialNumber ?: ""
|
||||
),
|
||||
version1 = instance.version1 ?: "",
|
||||
version2 = instance.version2 ?: "",
|
||||
version3 = instance.version3 ?: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun reconnectFromSavedMac() {
|
||||
service.reconnectFromSavedMac()
|
||||
}
|
||||
|
||||
fun setName(name: String) {
|
||||
service.setName(name)
|
||||
}
|
||||
|
||||
fun startHeadTracking() {
|
||||
service.startHeadTracking()
|
||||
_uiState.update { it.copy(headTrackingActive = true) }
|
||||
}
|
||||
|
||||
fun stopHeadTracking() {
|
||||
service.stopHeadTracking()
|
||||
_uiState.update { it.copy(headTrackingActive = false) }
|
||||
}
|
||||
|
||||
fun setATTCharacteristicValue(handle: ATTHandles, value: ByteArray) {
|
||||
when (handle) {
|
||||
// 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) {
|
||||
try {
|
||||
service.attManager.writeCharacteristic(handle, value)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadATT() {
|
||||
val loudSoundReduction = service.attManager.getCharacteristic(ATTHandles.LOUD_SOUND_REDUCTION) ?: byteArrayOf()
|
||||
val loudSoundReductionEnabled = if (loudSoundReduction.isNotEmpty()) {
|
||||
loudSoundReduction[0].toInt() == 1
|
||||
} else false
|
||||
val hearingAidData = service.attManager.getCharacteristic(ATTHandles.HEARING_AID) ?: byteArrayOf()
|
||||
val transparencyData = service.attManager.getCharacteristic(ATTHandles.TRANSPARENCY) ?: byteArrayOf()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
loudSoundReductionEnabled = loudSoundReductionEnabled,
|
||||
transparencyData = transparencyData,
|
||||
hearingAidData = hearingAidData
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun observeATT() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
service.attManager.enableNotification(ATTCCCDHandles.HEARING_AID)
|
||||
service.attManager.enableNotification(ATTCCCDHandles.TRANSPARENCY)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutomaticEarDetectionEnabled(enabled: Boolean) {
|
||||
sharedPreferences.edit { putBoolean("automatic_ear_detection", enabled) }
|
||||
setControlCommandBoolean(ControlCommandIdentifiers.EAR_DETECTION_CONFIG, enabled)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
automaticEarDetectionEnabled = enabled
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutomaticConnectionEnabled(enabled: Boolean) {
|
||||
sharedPreferences.edit { putBoolean("automatic_connection_ctrl_cmd", enabled) }
|
||||
setControlCommandBoolean(ControlCommandIdentifiers.AUTOMATIC_CONNECTION_CONFIG, enabled)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
automaticConnectionEnabled = enabled
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun activateDemoMode() {
|
||||
isDemoMode = true
|
||||
_uiState.update {demoState}
|
||||
}
|
||||
|
||||
fun sendPhoneMediaEQ(eq: FloatArray, phoneByte: Byte, mediaByte: Byte) {
|
||||
service.aacpManager.sendPhoneMediaEQ(eq, phoneByte, mediaByte)
|
||||
}
|
||||
|
||||
fun setLongPressAction(side: String, action: StemAction) {
|
||||
val prefKey = if (side.lowercase() == "left") "left_long_press_action" else "right_long_press_action"
|
||||
sharedPreferences.edit { putString(prefKey, action.name) }
|
||||
_uiState.update {
|
||||
if (side.lowercase() == "left") it.copy(leftAction = action) else it.copy(rightAction = action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun countEnabledModes(byteValue: Int): Int {
|
||||
var count = 0
|
||||
if ((byteValue and 0x01) != 0) count++
|
||||
if ((byteValue and 0x02) != 0) count++
|
||||
if ((byteValue and 0x04) != 0) count++
|
||||
if ((byteValue and 0x08) != 0) count++
|
||||
return count
|
||||
}
|
||||
|
||||
fun toggleListeningMode(modeBit: Int) {
|
||||
val currentByte = uiState.value.controlStates[ControlCommandIdentifiers.LISTENING_MODE_CONFIGS]?.get(0)?.toInt() ?: 0
|
||||
val newValue = if ((currentByte and modeBit) != 0) {
|
||||
val temp = currentByte and modeBit.inv()
|
||||
if (countEnabledModes(temp) >= 2) temp else currentByte
|
||||
} else {
|
||||
currentByte or modeBit
|
||||
}
|
||||
setControlCommandByte(ControlCommandIdentifiers.LISTENING_MODE_CONFIGS, newValue.toByte())
|
||||
sharedPreferences.edit { putInt("long_press_byte", newValue) }
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
if (isDemoMode) {
|
||||
isDemoMode = false
|
||||
_uiState.update {
|
||||
it.copy(isLocallyConnected = false)
|
||||
}
|
||||
} else {
|
||||
service.disconnectAirPods()
|
||||
if (appContext.checkSelfPermission("android.permission.BLUETOOTH_PRIVILEGED") != PackageManager.PERMISSION_GRANTED) {
|
||||
Toast.makeText(
|
||||
appContext, "App has disconnected, disconnect from Android Settings.",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
package me.kavishdevar.librepods.presentation.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import me.kavishdevar.librepods.BuildConfig
|
||||
import me.kavishdevar.librepods.billing.BillingManager
|
||||
import me.kavishdevar.librepods.data.XposedRemotePrefProvider
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
data class AppSettingsUiState(
|
||||
val showPhoneBatteryInWidget: Boolean = false,
|
||||
val conversationalAwarenessPauseMusicEnabled: Boolean = false,
|
||||
val relativeConversationalAwarenessVolumeEnabled: Boolean = true,
|
||||
val disconnectWhenNotWearing: Boolean = false,
|
||||
val takeoverWhenDisconnected: Boolean = false,
|
||||
val takeoverWhenIdle: Boolean = false,
|
||||
val takeoverWhenMusic: Boolean = false,
|
||||
val takeoverWhenCall: Boolean = false,
|
||||
val takeoverWhenRingingCall: Boolean = false,
|
||||
val takeoverWhenMediaStart: Boolean = false,
|
||||
val useAlternateHeadTrackingPackets: Boolean = true,
|
||||
val conversationalAwarenessVolume: Float = 43f,
|
||||
val showCameraDialog: Boolean = false,
|
||||
val cameraPackageValue: String = "",
|
||||
val cameraPackageError: String? = null,
|
||||
val vendorIdHook: Boolean = false,
|
||||
val isPremium: Boolean = false,
|
||||
val connectionSuccessful: Boolean = false,
|
||||
val showBottomSheetPopup: Boolean = true,
|
||||
val showIslandPopup: Boolean = true,
|
||||
val timeUntilFOSSPremiumExpiry: Long = 0L,
|
||||
val m3eEnabled: Boolean = false
|
||||
)
|
||||
|
||||
class AppSettingsViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val sharedPreferences = application.getSharedPreferences("settings", Context.MODE_PRIVATE)
|
||||
|
||||
private val _uiState = MutableStateFlow(AppSettingsUiState())
|
||||
val uiState = _uiState.asStateFlow()
|
||||
|
||||
private val xposedRemotePref = XposedRemotePrefProvider.create()
|
||||
|
||||
val sharedPrefListener = SharedPreferences.OnSharedPreferenceChangeListener { sharedPref, key ->
|
||||
if (key == "connection_successful") {
|
||||
_uiState.update { it.copy(connectionSuccessful = sharedPref.getBoolean(key, false)) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
init {
|
||||
loadSettings()
|
||||
observeBilling()
|
||||
sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPrefListener)
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPrefListener)
|
||||
}
|
||||
|
||||
private fun observeBilling() {
|
||||
viewModelScope.launch {
|
||||
BillingManager.provider.isPremium.collect { 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() {
|
||||
// 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 ->
|
||||
currentState.copy(
|
||||
showPhoneBatteryInWidget = sharedPreferences.getBoolean("show_phone_battery_in_widget", false),
|
||||
conversationalAwarenessPauseMusicEnabled = sharedPreferences.getBoolean("conversational_awareness_pause_music", false),
|
||||
relativeConversationalAwarenessVolumeEnabled = sharedPreferences.getBoolean("relative_conversational_awareness_volume", true),
|
||||
disconnectWhenNotWearing = sharedPreferences.getBoolean("disconnect_when_not_wearing", false),
|
||||
takeoverWhenDisconnected = sharedPreferences.getBoolean("takeover_when_disconnected", false),
|
||||
takeoverWhenIdle = sharedPreferences.getBoolean("takeover_when_idle", false),
|
||||
takeoverWhenMusic = sharedPreferences.getBoolean("takeover_when_music", false),
|
||||
takeoverWhenCall = sharedPreferences.getBoolean("takeover_when_call", false),
|
||||
takeoverWhenRingingCall = sharedPreferences.getBoolean("takeover_when_ringing_call", false),
|
||||
takeoverWhenMediaStart = sharedPreferences.getBoolean("takeover_when_media_start", false),
|
||||
useAlternateHeadTrackingPackets = sharedPreferences.getBoolean("use_alternate_head_tracking_packets", true),
|
||||
conversationalAwarenessVolume = sharedPreferences.getInt("conversational_awareness_volume", 43).toFloat(),
|
||||
cameraPackageValue = sharedPreferences.getString("custom_camera_package", "") ?: "",
|
||||
vendorIdHook = xposedRemotePref.getBoolean("vendor_id_hook", false),
|
||||
connectionSuccessful = sharedPreferences.getBoolean("connection_successful", false),
|
||||
showBottomSheetPopup = sharedPreferences.getBoolean("show_bottom_sheet_popup", true),
|
||||
showIslandPopup = sharedPreferences.getBoolean("show_island_popup", true),
|
||||
m3eEnabled = sharedPreferences.getBoolean("m3e_enabled", true)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setShowPhoneBatteryInWidget(enabled: Boolean) {
|
||||
sharedPreferences.edit { putBoolean("show_phone_battery_in_widget", enabled) }
|
||||
_uiState.update { it.copy(showPhoneBatteryInWidget = enabled) }
|
||||
}
|
||||
|
||||
fun setConversationalAwarenessPauseMusicEnabled(enabled: Boolean) {
|
||||
sharedPreferences.edit { putBoolean("conversational_awareness_pause_music", enabled) }
|
||||
_uiState.update { it.copy(conversationalAwarenessPauseMusicEnabled = enabled) }
|
||||
}
|
||||
|
||||
fun setRelativeConversationalAwarenessVolumeEnabled(enabled: Boolean) {
|
||||
sharedPreferences.edit { putBoolean("relative_conversational_awareness_volume", enabled) }
|
||||
_uiState.update { it.copy(relativeConversationalAwarenessVolumeEnabled = enabled) }
|
||||
}
|
||||
|
||||
fun setDisconnectWhenNotWearing(enabled: Boolean) {
|
||||
sharedPreferences.edit { putBoolean("disconnect_when_not_wearing", enabled) }
|
||||
_uiState.update { it.copy(disconnectWhenNotWearing = enabled) }
|
||||
}
|
||||
|
||||
fun setTakeoverWhenDisconnected(enabled: Boolean) {
|
||||
sharedPreferences.edit { putBoolean("takeover_when_disconnected", enabled) }
|
||||
_uiState.update { it.copy(takeoverWhenDisconnected = enabled) }
|
||||
}
|
||||
|
||||
fun setTakeoverWhenIdle(enabled: Boolean) {
|
||||
sharedPreferences.edit { putBoolean("takeover_when_idle", enabled) }
|
||||
_uiState.update { it.copy(takeoverWhenIdle = enabled) }
|
||||
}
|
||||
|
||||
fun setTakeoverWhenMusic(enabled: Boolean) {
|
||||
sharedPreferences.edit { putBoolean("takeover_when_music", enabled) }
|
||||
_uiState.update { it.copy(takeoverWhenMusic = enabled) }
|
||||
}
|
||||
|
||||
fun setTakeoverWhenCall(enabled: Boolean) {
|
||||
sharedPreferences.edit { putBoolean("takeover_when_call", enabled) }
|
||||
_uiState.update { it.copy(takeoverWhenCall = enabled) }
|
||||
}
|
||||
|
||||
fun setTakeoverWhenRingingCall(enabled: Boolean) {
|
||||
sharedPreferences.edit { putBoolean("takeover_when_ringing_call", enabled) }
|
||||
_uiState.update { it.copy(takeoverWhenRingingCall = enabled) }
|
||||
}
|
||||
|
||||
fun setTakeoverWhenMediaStart(enabled: Boolean) {
|
||||
sharedPreferences.edit { putBoolean("takeover_when_media_start", enabled) }
|
||||
_uiState.update { it.copy(takeoverWhenMediaStart = enabled) }
|
||||
}
|
||||
|
||||
fun setUseAlternateHeadTrackingPackets(enabled: Boolean) {
|
||||
sharedPreferences.edit { putBoolean("use_alternate_head_tracking_packets", enabled) }
|
||||
_uiState.update { it.copy(useAlternateHeadTrackingPackets = enabled) }
|
||||
}
|
||||
|
||||
fun setConversationalAwarenessVolume(volume: Float) {
|
||||
sharedPreferences.edit { putInt("conversational_awareness_volume", volume.roundToInt()) }
|
||||
_uiState.update { it.copy(conversationalAwarenessVolume = volume) }
|
||||
}
|
||||
|
||||
fun setShowCameraDialog(show: Boolean) {
|
||||
_uiState.update { it.copy(showCameraDialog = show) }
|
||||
}
|
||||
|
||||
fun setCameraPackageValue(value: String) {
|
||||
_uiState.update { it.copy(cameraPackageValue = value) }
|
||||
}
|
||||
|
||||
fun setCameraPackageError(error: String?) {
|
||||
_uiState.update { it.copy(cameraPackageError = error) }
|
||||
}
|
||||
|
||||
fun saveCameraPackage() {
|
||||
if (_uiState.value.cameraPackageValue.isBlank()) {
|
||||
sharedPreferences.edit { remove("custom_camera_package") }
|
||||
} else {
|
||||
sharedPreferences.edit { putString("custom_camera_package", _uiState.value.cameraPackageValue) }
|
||||
}
|
||||
setShowCameraDialog(false)
|
||||
}
|
||||
|
||||
fun setVendorIdHook(enabled: Boolean) {
|
||||
xposedRemotePref.putBoolean("vendor_id_hook", enabled)
|
||||
_uiState.update { it.copy(vendorIdHook = enabled) }
|
||||
}
|
||||
|
||||
fun setShowBottomSheetPopup(enabled: Boolean) {
|
||||
sharedPreferences.edit { putBoolean("show_bottom_sheet_popup", enabled) }
|
||||
_uiState.update { it.copy(showBottomSheetPopup = enabled) }
|
||||
}
|
||||
|
||||
fun setShowIslandPopup(enabled: Boolean) {
|
||||
sharedPreferences.edit { putBoolean("show_island_popup", enabled) }
|
||||
_uiState.update { it.copy(showIslandPopup = enabled) }
|
||||
}
|
||||
|
||||
fun setm3eEnabled(enabled: Boolean) {
|
||||
sharedPreferences.edit { putBoolean("m3e_enabled", enabled) }
|
||||
_uiState.update { it.copy(m3eEnabled = enabled) }
|
||||
}
|
||||
}
|
||||
+12
-1
@@ -20,7 +20,18 @@
|
||||
|
||||
package me.kavishdevar.librepods.presentation.widgets
|
||||
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.Context
|
||||
import me.kavishdevar.librepods.services.ServiceManager
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
class BatteryWidget : AppWidgetProvider()
|
||||
class BatteryWidget : AppWidgetProvider() {
|
||||
override fun onUpdate(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
appWidgetIds: IntArray
|
||||
) {
|
||||
ServiceManager.getService()?.updateBattery()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user