diff --git a/.gitignore b/.gitignore
index f11f5a28..8d589cd4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,3 +34,4 @@ replay_pid*
app/release
local.properties
.vscode/launch.json
+build/reports/problems/problems-report.html
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index b98e34fb..22ad21b7 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,11 +1,11 @@
import org.gradle.api.JavaVersion.VERSION_11
import org.gradle.api.JavaVersion.VERSION_17
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
- alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.google.ksp)
alias(libs.plugins.kotlin.compose)
- kotlin("kapt")
}
android {
@@ -16,15 +16,29 @@ android {
applicationId = "com.sameerasw.airsync"
minSdk = 30
targetSdk = 36
- versionCode = 22
- versionName = "2.5.1"
+ versionCode = 23
+ versionName = "2.5.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
+// optimized dev build
+// debug {
+// isMinifyEnabled = true
+// isShrinkResources = true
+// isDebuggable = false
+//
+// proguardFiles(
+// getDefaultProguardFile("proguard-android-optimize.txt"),
+// "proguard-rules.pro"
+// )
+// }
+// end
+
release {
- isMinifyEnabled = false
+ isMinifyEnabled = true
+ isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
@@ -35,9 +49,11 @@ android {
sourceCompatibility = VERSION_11
targetCompatibility = VERSION_17
}
- kotlinOptions {
- jvmTarget = "17"
+kotlin {
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_17)
}
+}
buildFeatures {
compose = true
buildConfig = true
@@ -106,7 +122,7 @@ dependencies {
// Room database for call history
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
- kapt(libs.androidx.room.compiler)
+ ksp(libs.androidx.room.compiler)
// Phone number normalization
implementation(libs.libphonenumber)
@@ -121,6 +137,11 @@ dependencies {
// Google Play Review
implementation(libs.play.review)
implementation(libs.play.review.ktx)
+ implementation(libs.sentry.android)
+
+ // Coil for image and GIF loading
+ implementation("io.coil-kt:coil-compose:2.6.0")
+ implementation("io.coil-kt:coil-gif:2.6.0")
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 481bb434..4246abbd 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -18,4 +18,15 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
-#-renamesourcefileattribute SourceFile
\ No newline at end of file
+#-renamesourcefileattribute SourceFile
+
+# Gson rules
+-keep class com.google.gson.** { *; }
+-keepattributes Signature
+-keepattributes *Annotation*
+
+# Domain models
+-keep class com.sameerasw.airsync.domain.model.** { *; }
+
+# Data Layer
+-keep class com.sameerasw.airsync.data.** { *; }
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8a6621e8..8640e495 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -40,6 +40,7 @@
+
+
+
+
+ options.dsn = "https://cb9b0ead9e88e0818269e773cb662141@o4510996760887296.ingest.de.sentry.io/4511002261389392"
+ options.isEnabled = true
+ }
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt
index 4d3dcda1..b9ab9e5b 100644
--- a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt
+++ b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt
@@ -15,21 +15,22 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.rounded.HelpOutline
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
-import androidx.compose.material3.LargeTopAppBar
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBarDefaults
-import androidx.compose.material3.rememberTopAppBarState
+import androidx.compose.material3.Surface
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -51,6 +52,7 @@ import com.sameerasw.airsync.data.local.DataStoreManager
import com.sameerasw.airsync.presentation.ui.activities.QRScannerActivity
import com.sameerasw.airsync.presentation.ui.screens.AirSyncMainScreen
import com.sameerasw.airsync.ui.theme.AirSyncTheme
+import com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel
import com.sameerasw.airsync.utils.AdbMdnsDiscovery
import com.sameerasw.airsync.utils.ContentCaptureManager
import com.sameerasw.airsync.utils.DevicePreviewResolver
@@ -187,7 +189,11 @@ class MainActivity : ComponentActivity() {
splashScreen.setOnExitAnimationListener { splashScreenViewProvider ->
try {
val splashScreenView = splashScreenViewProvider.view
- val splashIcon = splashScreenViewProvider.iconView
+ val splashIcon = try {
+ splashScreenViewProvider.iconView
+ } catch (e: Exception) {
+ null
+ }
// Retrieve last connected device in background while showing splash
var deviceIconRes: Int? = null
@@ -233,7 +239,7 @@ class MainActivity : ComponentActivity() {
fadeInIcon.doOnEnd {
// Hold on device icon for 0.5s, then start outro animation
try {
- splashIcon.postDelayed({
+ splashScreenView.postDelayed({
startOutroAnimation(
splashScreenView,
splashIcon,
@@ -274,47 +280,28 @@ class MainActivity : ComponentActivity() {
fadeOutIcon.start()
} else {
// No device icon found, or splashIcon is null/not ImageView (OEM device compatibility)
- when {
- splashIcon == null -> {
- Log.w(
- "SplashScreen",
- "iconView is null - OEM device detected, skipping crossfade"
- )
- }
-
- deviceIconRes == null -> {
- Log.d(
+ // Proceed directly to outro after a brief hold
+ try {
+ splashScreenView.postDelayed({
+ startOutroAnimation(
+ splashScreenView,
+ splashIcon,
+ splashScreenViewProvider
+ )
+ }, 500)
+ } catch (e: Exception) {
+ Log.e(
"MainActivity",
- "No device icon resource, proceeding with app icon"
- )
- }
-
- else -> {
- Log.w(
- "SplashScreen",
- "iconView is not an ImageView - OEM device detected"
+ "Error scheduling outro with no icon: ${e.message}",
+ e
)
- }
- }
-
- // Proceed directly to outro after a brief hold
- try {
- splashIcon?.postDelayed({
+ // Fallback: start outro immediately
startOutroAnimation(
splashScreenView,
splashIcon,
splashScreenViewProvider
)
- }, 500)
- } catch (e: Exception) {
- Log.e(
- "MainActivity",
- "Error scheduling outro with no icon: ${e.message}",
- e
- )
- // Fallback: start outro immediately
- startOutroAnimation(splashScreenView, splashIcon, splashScreenViewProvider)
- }
+ }
}
} catch (e: Exception) {
// Fallback for any unexpected exceptions during animation
@@ -376,18 +363,18 @@ class MainActivity : ComponentActivity() {
val isFromQrScan = data != null
setContent {
- AirSyncTheme {
+ val viewModel: com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel =
+ androidx.lifecycle.viewmodel.compose.viewModel {
+ com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel.create(this@MainActivity)
+ }
+ val uiState by viewModel.uiState.collectAsState()
+
+ AirSyncTheme(pitchBlackTheme = uiState.isPitchBlackThemeEnabled) {
val navController = rememberNavController()
- var showAboutDialog by remember { mutableStateOf(false) }
- var showHelpSheet by remember { mutableStateOf(false) }
- val scrollBehavior =
- TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
- var topBarTitle by remember { mutableStateOf("AirSync") }
Scaffold(
modifier = Modifier
- .fillMaxSize()
- .nestedScroll(scrollBehavior.nestedScrollConnection),
+ .fillMaxSize(),
containerColor = MaterialTheme.colorScheme.surfaceContainer,
contentWindowInsets = androidx.compose.foundation.layout.WindowInsets(
0,
@@ -395,72 +382,6 @@ class MainActivity : ComponentActivity() {
0,
0
),
- topBar = {
- LargeTopAppBar(
- colors = TopAppBarDefaults.largeTopAppBarColors(
- containerColor = MaterialTheme.colorScheme.surfaceContainer,
- scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer,
- ),
- modifier = Modifier.padding(horizontal = 8.dp),
- title = {
- Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
- // Dynamic icon based on last connected device category
- val ctx = androidx.compose.ui.platform.LocalContext.current
- val ds = remember(ctx) { DataStoreManager(ctx) }
- val lastDevice by ds.getLastConnectedDevice()
- .collectAsState(initial = null)
- val iconRes =
- com.sameerasw.airsync.utils.DeviceIconResolver.getIconRes(
- lastDevice
- )
- Image(
- painter = painterResource(id = iconRes),
- contentDescription = "AirSync Logo",
- modifier = Modifier.size(32.dp),
- contentScale = ContentScale.Fit,
- colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary)
- )
- Spacer(modifier = Modifier.width(8.dp))
- Text(
- topBarTitle,
- style = MaterialTheme.typography.titleLarge,
- color = MaterialTheme.colorScheme.primary,
- maxLines = 1,
- )
- }
- },
- actions = {
- IconButton(
- onClick = { showHelpSheet = true },
- colors = IconButtonDefaults.iconButtonColors(
- containerColor = MaterialTheme.colorScheme.surfaceBright
- ),
- modifier = Modifier.size(48.dp)
- ) {
- Icon(
- imageVector = androidx.compose.material.icons.Icons.Rounded.HelpOutline,
- contentDescription = "Help",
- modifier = Modifier.size(32.dp)
- )
- }
- Spacer(modifier = Modifier.width(8.dp))
- IconButton(
- onClick = { showAboutDialog = true },
- colors = IconButtonDefaults.iconButtonColors(
- containerColor = MaterialTheme.colorScheme.surfaceBright
- ),
- modifier = Modifier.size(48.dp)
- ) {
- Icon(
- painter = painterResource(id = R.drawable.rounded_info_24),
- contentDescription = "About",
- modifier = Modifier.size(32.dp)
- )
- }
- },
- scrollBehavior = scrollBehavior
- )
- }
) { innerPadding ->
NavHost(
navController = navController,
@@ -474,12 +395,7 @@ class MainActivity : ComponentActivity() {
showConnectionDialog = isFromQrScan,
pcName = pcName,
isPlus = isPlus,
- symmetricKey = symmetricKey,
- showAboutDialog = showAboutDialog,
- onDismissAbout = { showAboutDialog = false },
- showHelpSheet = showHelpSheet,
- onDismissHelp = { showHelpSheet = false },
- onTitleChange = { topBarTitle = it }
+ symmetricKey = symmetricKey
)
}
}
diff --git a/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt b/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt
index b9881b74..f9a2d9e4 100644
--- a/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt
+++ b/app/src/main/java/com/sameerasw/airsync/data/local/DataStoreManager.kt
@@ -88,6 +88,9 @@ class DataStoreManager(private val context: Context) {
private val DEVICE_DISCOVERY_ENABLED = booleanPreferencesKey("device_discovery_enabled")
private val LAST_CALL_SYNC_TIMESTAMP = longPreferencesKey("last_call_sync_timestamp")
private val DEVICE_ID = stringPreferencesKey("device_id")
+ private val USE_BLUR = booleanPreferencesKey("use_blur")
+ private val PITCH_BLACK_THEME = booleanPreferencesKey("pitch_black_theme")
+ private val SENTRY_REPORTING_ENABLED = booleanPreferencesKey("sentry_reporting_enabled")
private const val NETWORK_DEVICES_PREFIX = "network_device_"
private const val NETWORK_CONNECTIONS_PREFIX = "network_connections_"
@@ -286,6 +289,42 @@ class DataStoreManager(private val context: Context) {
}
}
+ suspend fun setUseBlurEnabled(enabled: Boolean) {
+ context.dataStore.edit { preferences ->
+ preferences[USE_BLUR] = enabled
+ }
+ }
+
+ fun getUseBlurEnabled(): Flow {
+ return context.dataStore.data.map { preferences ->
+ preferences[USE_BLUR] ?: true // Default to enabled
+ }
+ }
+
+ suspend fun setPitchBlackThemeEnabled(enabled: Boolean) {
+ context.dataStore.edit { preferences ->
+ preferences[PITCH_BLACK_THEME] = enabled
+ }
+ }
+
+ fun getPitchBlackThemeEnabled(): Flow {
+ return context.dataStore.data.map { preferences ->
+ preferences[PITCH_BLACK_THEME] ?: false // Default to disabled
+ }
+ }
+
+ suspend fun setSentryReportingEnabled(enabled: Boolean) {
+ context.dataStore.edit { preferences ->
+ preferences[SENTRY_REPORTING_ENABLED] = enabled
+ }
+ }
+
+ fun getSentryReportingEnabled(): Flow {
+ return context.dataStore.data.map { preferences ->
+ preferences[SENTRY_REPORTING_ENABLED] ?: true // Default to enabled
+ }
+ }
+
suspend fun setDefaultTab(tab: String) {
context.dataStore.edit { prefs ->
prefs[DEFAULT_TAB] = tab
@@ -731,9 +770,6 @@ class DataStoreManager(private val context: Context) {
prefs.asMap().forEach { (key, value) ->
try {
- // Skip nulls
- if (value == null) return@forEach
-
// If string looks like an embedded image or is very large, skip
if (value is String) {
val lower = value.lowercase()
diff --git a/app/src/main/java/com/sameerasw/airsync/data/repository/AirSyncRepositoryImpl.kt b/app/src/main/java/com/sameerasw/airsync/data/repository/AirSyncRepositoryImpl.kt
index 43ddce42..c4504f06 100644
--- a/app/src/main/java/com/sameerasw/airsync/data/repository/AirSyncRepositoryImpl.kt
+++ b/app/src/main/java/com/sameerasw/airsync/data/repository/AirSyncRepositoryImpl.kt
@@ -200,6 +200,22 @@ class AirSyncRepositoryImpl(
return dataStoreManager.getMacMediaControlsEnabled()
}
+ override suspend fun setUseBlurEnabled(enabled: Boolean) {
+ dataStoreManager.setUseBlurEnabled(enabled)
+ }
+
+ override fun getUseBlurEnabled(): Flow {
+ return dataStoreManager.getUseBlurEnabled()
+ }
+
+ override suspend fun setPitchBlackThemeEnabled(enabled: Boolean) {
+ dataStoreManager.setPitchBlackThemeEnabled(enabled)
+ }
+
+ override fun getPitchBlackThemeEnabled(): Flow {
+ return dataStoreManager.getPitchBlackThemeEnabled()
+ }
+
override suspend fun setDefaultTab(tab: String) {
dataStoreManager.setDefaultTab(tab)
}
@@ -208,6 +224,14 @@ class AirSyncRepositoryImpl(
return dataStoreManager.getDefaultTab()
}
+ override suspend fun setSentryReportingEnabled(enabled: Boolean) {
+ dataStoreManager.setSentryReportingEnabled(enabled)
+ }
+
+ override fun getSentryReportingEnabled(): Flow {
+ return dataStoreManager.getSentryReportingEnabled()
+ }
+
override suspend fun setEssentialsConnectionEnabled(enabled: Boolean) {
dataStoreManager.setEssentialsConnectionEnabled(enabled)
}
diff --git a/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt b/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt
index a572d0b6..cf95e684 100644
--- a/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt
+++ b/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt
@@ -41,5 +41,11 @@ data class UiState(
val activeIp: String? = null,
val connectingDeviceId: String? = null,
val isDeviceDiscoveryEnabled: Boolean = true,
- val shouldShowRatingPrompt: Boolean = false
+ val shouldShowRatingPrompt: Boolean = false,
+ val isBlurSettingEnabled: Boolean = true,
+ val isPowerSaveMode: Boolean = false,
+ val isPitchBlackThemeEnabled: Boolean = false,
+ val isBlurEnabled: Boolean = true,
+ val isSentryReportingEnabled: Boolean = true,
+ val isOnboardingCompleted: Boolean = true
)
\ No newline at end of file
diff --git a/app/src/main/java/com/sameerasw/airsync/domain/repository/AirSyncRepository.kt b/app/src/main/java/com/sameerasw/airsync/domain/repository/AirSyncRepository.kt
index cde74a72..31e1a42f 100644
--- a/app/src/main/java/com/sameerasw/airsync/domain/repository/AirSyncRepository.kt
+++ b/app/src/main/java/com/sameerasw/airsync/domain/repository/AirSyncRepository.kt
@@ -89,10 +89,22 @@ interface AirSyncRepository {
suspend fun setMacMediaControlsEnabled(enabled: Boolean)
fun getMacMediaControlsEnabled(): Flow
+ // Blur settings
+ suspend fun setUseBlurEnabled(enabled: Boolean)
+ fun getUseBlurEnabled(): Flow
+
+ // Pitch Black Theme settings
+ suspend fun setPitchBlackThemeEnabled(enabled: Boolean)
+ fun getPitchBlackThemeEnabled(): Flow
+
// Default tab settings
suspend fun setDefaultTab(tab: String)
fun getDefaultTab(): Flow
+ // Sentry reporting settings
+ suspend fun setSentryReportingEnabled(enabled: Boolean)
+ fun getSentryReportingEnabled(): Flow
+
// Essentials Bridge
suspend fun setEssentialsConnectionEnabled(enabled: Boolean)
fun getEssentialsConnectionEnabled(): Flow
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/ClipboardActionActivity.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/ClipboardActionActivity.kt
index b61b2c0a..103a07f5 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/ClipboardActionActivity.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/ClipboardActionActivity.kt
@@ -52,6 +52,7 @@ import androidx.compose.ui.unit.dp
import com.sameerasw.airsync.R
import com.sameerasw.airsync.data.local.DataStoreManager
import com.sameerasw.airsync.domain.model.ConnectedDevice
+import com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel
import com.sameerasw.airsync.ui.theme.AirSyncTheme
import com.sameerasw.airsync.utils.ClipboardSyncManager
import com.sameerasw.airsync.utils.ClipboardUtil
@@ -81,7 +82,13 @@ class ClipboardActionActivity : ComponentActivity() {
window.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
setContent {
- AirSyncTheme {
+ val viewModel: com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel =
+ androidx.lifecycle.viewmodel.compose.viewModel {
+ com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel.create(this@ClipboardActionActivity)
+ }
+ val uiState by viewModel.uiState.collectAsState()
+
+ AirSyncTheme(pitchBlackTheme = uiState.isPitchBlackThemeEnabled) {
ClipboardActionScreen(
hasWindowFocus = _windowFocus.value,
onFinished = { finish() }
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/PermissionsActivity.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/PermissionsActivity.kt
index 617e082a..97d0940c 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/PermissionsActivity.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/PermissionsActivity.kt
@@ -21,6 +21,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -28,6 +29,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import com.sameerasw.airsync.presentation.ui.screens.PermissionsScreen
import com.sameerasw.airsync.ui.theme.AirSyncTheme
+import com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel
import com.sameerasw.airsync.utils.PermissionUtil
class PermissionsActivity : ComponentActivity() {
@@ -69,11 +71,16 @@ class PermissionsActivity : ComponentActivity() {
// Disable scrim on 3-button navigation (API 29+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
- window.isStatusBarContrastEnforced = false
}
setContent {
- AirSyncTheme {
+ val viewModel: com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel =
+ androidx.lifecycle.viewmodel.compose.viewModel {
+ com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel.create(this@PermissionsActivity)
+ }
+ val uiState by viewModel.uiState.collectAsState()
+
+ AirSyncTheme(pitchBlackTheme = uiState.isPitchBlackThemeEnabled) {
val scrollBehavior =
TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/QRScannerActivity.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/QRScannerActivity.kt
index 07e1f468..4327ab66 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/QRScannerActivity.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/QRScannerActivity.kt
@@ -34,6 +34,7 @@ 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
@@ -50,6 +51,7 @@ import androidx.core.content.ContextCompat
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.common.InputImage
+import com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel
import com.sameerasw.airsync.ui.theme.AirSyncTheme
import com.sameerasw.airsync.utils.HapticUtil
import java.util.concurrent.Executors
@@ -84,7 +86,6 @@ class QRScannerActivity : ComponentActivity() {
// Disable scrim on 3-button navigation (API 29+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
- window.isStatusBarContrastEnforced = false
}
// Check camera permission
@@ -101,7 +102,13 @@ class QRScannerActivity : ComponentActivity() {
private fun startScanner() {
setContent {
- AirSyncTheme {
+ val viewModel: com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel =
+ androidx.lifecycle.viewmodel.compose.viewModel {
+ com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel.create(this@QRScannerActivity)
+ }
+ val uiState by viewModel.uiState.collectAsState()
+
+ AirSyncTheme(pitchBlackTheme = uiState.isPitchBlackThemeEnabled) {
QRScannerScreen(
onQrScanned = { qrData ->
// Return the scanned QR code data to the caller
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/ShareActivity.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/ShareActivity.kt
index ac3de541..95721716 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/ShareActivity.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/activities/ShareActivity.kt
@@ -33,7 +33,6 @@ class ShareActivity : ComponentActivity() {
// Disable scrim on 3-button navigation (API 29+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
- window.isStatusBarContrastEnforced = false
}
when (intent?.action) {
@@ -42,7 +41,12 @@ class ShareActivity : ComponentActivity() {
handleTextShare(intent)
} else {
// Try to handle file share
- val stream = intent.getParcelableExtra(Intent.EXTRA_STREAM)
+ val stream = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ intent.getParcelableExtra(Intent.EXTRA_STREAM, android.net.Uri::class.java)
+ } else {
+ @Suppress("DEPRECATION")
+ intent.getParcelableExtra(Intent.EXTRA_STREAM)
+ }
if (stream != null) {
handleFileShare(stream)
}
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/AboutBottomSheet.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AboutSection.kt
similarity index 81%
rename from app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/AboutBottomSheet.kt
rename to app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AboutSection.kt
index 2e882e86..dec76ebf 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sheets/AboutBottomSheet.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AboutSection.kt
@@ -1,7 +1,6 @@
-package com.sameerasw.airsync.presentation.ui.components.sheets
+package com.sameerasw.airsync.presentation.ui.components
import android.content.Intent
-import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -12,48 +11,38 @@ import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
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.layout.size
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.material3.BottomSheetDefaults
import androidx.compose.material3.Button
-import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
-import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
-import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import com.sameerasw.airsync.R
-@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
+@OptIn(ExperimentalFoundationApi::class)
@Composable
-fun AboutBottomSheet(
- onDismissRequest: () -> Unit,
- onToggleDeveloperMode: () -> Unit,
+fun AboutSection(
+ modifier: Modifier = Modifier,
+ onAvatarLongClick: () -> Unit = {},
appName: String = "AirSync",
developerName: String = "Sameera Wijerathna",
description: String = "AirSync enables seamless synchronization between your Android device and Mac. Share notifications, clipboard content, and device status wirelessly over your local network."
) {
val context = LocalContext.current
- val haptics = LocalHapticFeedback.current
- val scrollState = rememberScrollState()
val versionName = try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName
@@ -61,18 +50,14 @@ fun AboutBottomSheet(
"Unknown"
}
- ModalBottomSheet(
- onDismissRequest = onDismissRequest,
- sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
- dragHandle = { BottomSheetDefaults.DragHandle() }
+ Surface(
+ modifier = modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.surfaceBright
) {
Column(
- modifier = Modifier
- .fillMaxWidth()
- .verticalScroll(scrollState)
- .padding(horizontal = 16.dp)
- .padding(bottom = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
@@ -88,8 +73,6 @@ fun AboutBottomSheet(
color = MaterialTheme.colorScheme.onSurfaceVariant
)
- Spacer(modifier = Modifier.height(8.dp))
-
Image(
painter = painterResource(id = R.drawable.avatar),
contentDescription = "Developer Avatar",
@@ -101,10 +84,7 @@ fun AboutBottomSheet(
.combinedClickable(
onClick = { },
onLongClick = {
- haptics.performHapticFeedback(HapticFeedbackType.LongPress)
- Toast.makeText(context, "Developer mode toggled", Toast.LENGTH_SHORT)
- .show()
- onToggleDeveloperMode()
+ onAvatarLongClick()
}
)
)
@@ -115,7 +95,6 @@ fun AboutBottomSheet(
textAlign = TextAlign.Center
)
- // Main Action Buttons
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
@@ -157,8 +136,6 @@ fun AboutBottomSheet(
)
}
- Spacer(modifier = Modifier.height(0.dp))
-
Text(
text = "Other Apps",
style = MaterialTheme.typography.titleMedium,
@@ -193,10 +170,8 @@ fun AboutBottomSheet(
)
}
- Spacer(modifier = Modifier.height(0.dp))
-
Text(
- text = "With ❤\uFE0F from \uD83C\uDDF1\uD83C\uDDF0",
+ text = "With ❤️ from 🇱🇰",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
@@ -268,7 +243,6 @@ private fun openUrl(context: android.content.Context, url: String) {
try {
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
context.startActivity(intent)
- } catch (e: Exception) {
- Toast.makeText(context, "Could not open link", Toast.LENGTH_SHORT).show()
+ } catch (_: Exception) {
}
}
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AirSyncFloatingToolbar.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AirSyncFloatingToolbar.kt
index f8e39083..cad76e91 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AirSyncFloatingToolbar.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AirSyncFloatingToolbar.kt
@@ -1,9 +1,18 @@
package com.sameerasw.airsync.presentation.ui.components
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FloatingToolbarDefaults
@@ -13,18 +22,17 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.sameerasw.airsync.presentation.ui.models.AirSyncTab
-import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -33,82 +41,113 @@ fun AirSyncFloatingToolbar(
currentPage: Int,
tabs: List,
onTabSelected: (Int) -> Unit,
- scrollBehavior: FloatingToolbarScrollBehavior,
+ scrollBehavior: FloatingToolbarScrollBehavior? = null,
floatingActionButton: @Composable () -> Unit = {}
) {
- var interactionCount by remember { mutableStateOf(0) }
-
- // Track which tab was just selected for bump animation
- var bumpingTab by remember { mutableIntStateOf(-1) }
- var bumpKey by remember { mutableIntStateOf(0) }
-
- // Reset bump animation after delay
- LaunchedEffect(bumpKey) {
- if (bumpingTab >= 0) {
- delay(200)
- bumpingTab = -1
- }
- }
+ // Persistent visibility
+ var expanded by remember { mutableStateOf(true) }
HorizontalFloatingToolbar(
- modifier = modifier,
- expanded = true,
+ modifier = modifier
+ .windowInsetsPadding(
+ androidx.compose.foundation.layout.WindowInsets.navigationBars
+ ),
+ expanded = expanded,
floatingActionButton = floatingActionButton,
scrollBehavior = scrollBehavior,
colors = FloatingToolbarDefaults.vibrantFloatingToolbarColors(
- toolbarContentColor = MaterialTheme.colorScheme.onPrimary,
- toolbarContainerColor = MaterialTheme.colorScheme.primary
+ toolbarContentColor = MaterialTheme.colorScheme.onSurface,
+ toolbarContainerColor = MaterialTheme.colorScheme.primary,
),
content = {
// FIXED ORDER LOOP to prevent shifting
tabs.forEachIndexed { index, tab ->
val isSelected = currentPage == index
- // Animate alpha for smooth fade
- val itemAlpha = 1f
-
// Animate width for spacing
- val itemWidth = 48.dp
+ val itemWidth by animateDpAsState(
+ targetValue = if (expanded || isSelected) 48.dp else 0.dp,
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioMediumBouncy,
+ stiffness = Spring.StiffnessLow
+ ),
+ label = "item_width_$index"
+ )
- // Animate spacer width
- val spacerWidth = if (index < tabs.size - 1) 16.dp else 0.dp
+ // Animate label width for active tab
+ val labelWidth by animateDpAsState(
+ targetValue = if (isSelected) 80.dp else 0.dp,
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioMediumBouncy,
+ stiffness = Spring.StiffnessLow
+ ),
+ label = "label_width_$index"
+ )
- // Always render the button
- IconButton(
- onClick = {
- interactionCount++
- onTabSelected(index)
- },
- modifier = Modifier
- .width(itemWidth)
- .height(48.dp)
- .graphicsLayer {
- scaleX = 1f
- scaleY = 1f
- alpha = itemAlpha
+ // Animate spacer width
+ val spacerWidth by animateDpAsState(
+ targetValue = if (index < tabs.size - 1) 8.dp else 0.dp,
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioMediumBouncy,
+ stiffness = Spring.StiffnessLow
+ ),
+ label = "spacer_width_$index"
+ )
+
+ // Always render the button, but animate its visibility
+ if (itemWidth > 0.dp || isSelected) {
+ IconButton(
+ onClick = {
+ onTabSelected(index)
},
- colors = if (isSelected) {
- IconButtonDefaults.filledIconButtonColors(
- contentColor = MaterialTheme.colorScheme.primary,
- containerColor = MaterialTheme.colorScheme.background
- )
- } else {
- IconButtonDefaults.iconButtonColors(
- contentColor = MaterialTheme.colorScheme.background,
- containerColor = MaterialTheme.colorScheme.primary
- )
+ modifier = Modifier
+ .width(itemWidth + labelWidth)
+ .height(48.dp),
+ colors = if (isSelected) {
+ IconButtonDefaults.filledIconButtonColors(
+ contentColor = MaterialTheme.colorScheme.primary,
+ containerColor = MaterialTheme.colorScheme.background
+ )
+ } else {
+ IconButtonDefaults.iconButtonColors(
+ contentColor = MaterialTheme.colorScheme.background,
+ containerColor = MaterialTheme.colorScheme.primary
+ )
+ }
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center,
+ modifier = Modifier.padding(horizontal = 8.dp)
+ ) {
+ Box {
+ Icon(
+ imageVector = tab.icon,
+ contentDescription = stringResource(id = tab.title),
+ tint = if (isSelected) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.background
+ },
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ if (isSelected) {
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = stringResource(id = tab.title),
+ style = MaterialTheme.typography.labelLarge,
+ maxLines = 1,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
}
- ) {
- Icon(
- imageVector = tab.icon,
- contentDescription = tab.title,
- modifier = Modifier.size(24.dp)
- )
- }
- // Spacing between buttons
- if (index < tabs.size - 1) {
- Spacer(modifier = Modifier.width(spacerWidth))
+ // Animated spacing between buttons
+ if (index < tabs.size - 1) {
+ Spacer(modifier = Modifier.width(spacerWidth))
+ }
}
}
}
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/HelpAndGuides.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/HelpAndGuides.kt
new file mode 100644
index 00000000..94084cd3
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/HelpAndGuides.kt
@@ -0,0 +1,154 @@
+package com.sameerasw.airsync.presentation.ui.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.KeyboardArrowDown
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.rotate
+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.core.net.toUri
+import com.sameerasw.airsync.R
+import com.sameerasw.airsync.presentation.ui.components.sheets.HelpSection
+import android.content.Intent
+import android.net.Uri
+
+@Composable
+fun HelpAndGuidesContent() {
+ val sections = remember {
+ listOf(
+ HelpSection(
+ title = "Getting Started",
+ iconRes = R.drawable.rounded_qr_code_scanner_24,
+ content = "To connect your Mac, ensure both devices are on the same Wi-Fi network or with Extended networking on tailscale or similar network. Scan the QR code in AirSync for Mac.",
+ links = listOf("Manual Auth Info" to "https://airsync.notion.site")
+ ),
+ HelpSection(
+ title = "Permissions & Usage",
+ iconRes = R.drawable.rounded_security_24,
+ content = "• Notification Access: Required to sync alerts/media. For sideloaded installs, enable 'Restricted Settings' in App Info.\n• Post Notifications: For the ongoing connection indicator.\n• Background Usage: Keeps the connection alive.\n• Storage: Required for wallpaper sync (still images only).",
+ links = listOf("Privacy Policy" to "https://www.sameerasw.com/airsync/privacy")
+ ),
+ HelpSection(
+ title = "ADB & Mirroring",
+ iconRes = R.drawable.rounded_laptop_mac_24,
+ content = "Install 'android-platform-tools' and 'scrcpy' on Mac via brew. Enable Wireless Debugging in Developer Options on Android. Use 'adb pair ip:port' with the code provided. Mirroring is an AirSync+ feature.",
+ links = listOf("ADB Setup Guide" to "https://www.notion.so/2-5-ADB-and-mirroring-setup-2549c6099d408072b46cfa517ebf2719")
+ ),
+ HelpSection(
+ title = "Re-connection",
+ iconRes = R.drawable.rounded_sync_desktop_24,
+ content = "Auto-reconnect tries for 1 minute (every 10s) and then every minute. Use the Quick Settings tile for instant one-tap reconnection to the last device.",
+ ),
+ HelpSection(
+ title = "Updates",
+ iconRes = R.drawable.rounded_notifications_active_24,
+ content = "Update the Android app via Google Play Store. The macOS app has a built-in updater.",
+ ),
+ HelpSection(
+ title = "Troubleshooting",
+ iconRes = R.drawable.rounded_troubleshoot_24,
+ content = "• IP N/A on Mac: Ensure LAN connection (not just internet).\n• Random Disconnects: Check battery/data saving policies and network stability.\n• Black Display: If 'Darken screen' is on during mirroring, use ⌥+Shift+O to reveal.",
+ links = listOf("Full FAQ" to "https://airsync.notion.site")
+ )
+ )
+ }
+
+ RoundedCardContainer {
+ sections.forEach { section ->
+ ExpandableHelpSection(section)
+ }
+ }
+}
+
+@Composable
+private fun ExpandableHelpSection(section: HelpSection) {
+ var expanded by remember { mutableStateOf(false) }
+ val rotation by animateFloatAsState(
+ targetValue = if (expanded) 180f else 0f,
+ label = "arrow_rotation"
+ )
+ val context = LocalContext.current
+
+ Surface(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { expanded = !expanded },
+ color = if (expanded) MaterialTheme.colorScheme.surfaceBright else MaterialTheme.colorScheme.surfaceVariant,
+ shape = MaterialTheme.shapes.extraSmall,
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Surface(
+ color = MaterialTheme.colorScheme.primaryContainer,
+ shape = RoundedCornerShape(8.dp),
+ modifier = Modifier.size(36.dp)
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(
+ painter = painterResource(id = section.iconRes),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ }
+
+ Text(
+ text = section.title,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.weight(1f)
+ )
+
+ Icon(
+ imageVector = Icons.Rounded.KeyboardArrowDown,
+ contentDescription = if (expanded) "Collapse" else "Expand",
+ modifier = Modifier.rotate(rotation)
+ )
+ }
+
+ AnimatedVisibility(visible = expanded) {
+ Column(
+ modifier = Modifier.padding(top = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Text(
+ text = section.content,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ section.links.forEach { (label, url) ->
+ TextButton(
+ onClick = {
+ try {
+ val intent = Intent(Intent.ACTION_VIEW, url.toUri())
+ context.startActivity(intent)
+ } catch (e: Exception) {
+ // Ignore
+ }
+ },
+ contentPadding = PaddingValues(0.dp)
+ ) {
+ Text(label, style = MaterialTheme.typography.labelLarge)
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt
index cd5e6005..c02f6fdb 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/SettingsView.kt
@@ -7,21 +7,28 @@ import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.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.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -37,9 +44,10 @@ import com.sameerasw.airsync.presentation.ui.components.cards.DefaultTabCard
import com.sameerasw.airsync.presentation.ui.components.cards.DeveloperModeCard
import com.sameerasw.airsync.presentation.ui.components.cards.DeviceInfoCard
import com.sameerasw.airsync.presentation.ui.components.cards.ExpandNetworkingCard
+import com.sameerasw.airsync.presentation.ui.components.cards.MediaSyncCard
import com.sameerasw.airsync.presentation.ui.components.cards.NotificationSyncCard
import com.sameerasw.airsync.presentation.ui.components.cards.PermissionsCard
-import com.sameerasw.airsync.presentation.ui.components.cards.QuickSettingsTipCard
+import com.sameerasw.airsync.presentation.ui.components.cards.QuickSettingsTilesCard
import com.sameerasw.airsync.presentation.ui.components.cards.SendNowPlayingCard
import com.sameerasw.airsync.presentation.ui.components.cards.SmartspacerCard
import com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel
@@ -79,7 +87,9 @@ fun SettingsView(
onSendMessage: (String) -> Unit = {},
onExport: (String) -> Unit = {},
onImport: () -> Unit = {},
- onResetOnboarding: () -> Unit = {}
+ onResetOnboarding: () -> Unit = {},
+ onShowHelp: () -> Unit = {},
+ onToggleDeveloperMode: () -> Unit = {}
) {
val haptics = LocalHapticFeedback.current
@@ -92,284 +102,361 @@ fun SettingsView(
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
- Spacer(modifier = Modifier.height(0.dp))
+ val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
+ val topSpacing = (statusBarHeight - 24.dp).coerceAtLeast(0.dp)
+ Spacer(
+ modifier = Modifier
+ .height(topSpacing)
+ .fillMaxWidth()
+ )
+
+ // Top Section (Untitled)
RoundedCardContainer {
- DefaultTabCard(
- currentDefaultTab = uiState.defaultTab,
- onDefaultTabChange = { tab -> viewModel.setDefaultTab(tab) }
- )
PermissionsCard(missingPermissionsCount = uiState.missingPermissions.size)
- QuickSettingsTipCard(
- isQSTileAdded = com.sameerasw.airsync.utils.QuickSettingsUtil.isQSTileAdded(
+
+ // Help and guides card
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ HapticUtil.performClick(haptics)
+ onShowHelp()
+ },
+ shape = MaterialTheme.shapes.extraSmall,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.label_help_guides),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.subtitle_help_guides),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
+ )
+ }
+
+ Icon(
+ painter = androidx.compose.ui.res.painterResource(id = com.sameerasw.airsync.R.drawable.rounded_keyboard_arrow_right_24),
+ contentDescription = "Open help",
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
+
+ QuickSettingsTilesCard(
+ isConnectionTileAdded = com.sameerasw.airsync.utils.QuickSettingsUtil.isQSTileAdded(
context,
com.sameerasw.airsync.service.AirSyncTileService::class.java
- )
- )
- com.sameerasw.airsync.presentation.ui.components.cards.ClipboardTileTipCard(
- isQSTileAdded = com.sameerasw.airsync.utils.QuickSettingsUtil.isQSTileAdded(
+ ),
+ isClipboardTileAdded = com.sameerasw.airsync.utils.QuickSettingsUtil.isQSTileAdded(
context,
com.sameerasw.airsync.service.ClipboardTileService::class.java
)
)
}
- // Notifications & Sync Features Section
- RoundedCardContainer {
- NotificationSyncCard(
- isNotificationEnabled = uiState.isNotificationEnabled,
- isNotificationSyncEnabled = uiState.isNotificationSyncEnabled,
- onToggleSync = { enabled ->
- viewModel.setNotificationSyncEnabled(enabled)
- },
- onGrantPermissions = { viewModel.setPermissionDialogVisible(true) }
- )
-
- ClipboardFeaturesCard(
- isClipboardSyncEnabled = uiState.isClipboardSyncEnabled,
- onToggleClipboardSync = { enabled: Boolean ->
- viewModel.setClipboardSyncEnabled(enabled)
- },
- isContinueBrowsingEnabled = uiState.isContinueBrowsingEnabled,
- onToggleContinueBrowsing = { enabled: Boolean ->
- viewModel.setContinueBrowsingEnabled(enabled)
- },
- isContinueBrowsingToggleEnabled = true,
- continueBrowsingSubtitle = "Prompt to open shared links in browser",
- isKeepPreviousLinkEnabled = uiState.isKeepPreviousLinkEnabled,
- onToggleKeepPreviousLink = { enabled: Boolean ->
- viewModel.setKeepPreviousLinkEnabled(enabled)
- }
- )
+ // App Section
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ SettingsCategoryTitle(androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.cat_app))
+ RoundedCardContainer {
+ DefaultTabCard(
+ currentDefaultTab = uiState.defaultTab,
+ onDefaultTabChange = { tab -> viewModel.setDefaultTab(tab) }
+ )
- SendNowPlayingCard(
- isSendNowPlayingEnabled = uiState.isSendNowPlayingEnabled,
- onToggleSendNowPlaying = { enabled: Boolean ->
- viewModel.setSendNowPlayingEnabled(enabled)
- }
- )
+ SendNowPlayingCard(
+ isSendNowPlayingEnabled = uiState.isBlurSettingEnabled,
+ onToggleSendNowPlaying = { enabled: Boolean ->
+ viewModel.setUseBlurEnabled(enabled, context)
+ },
+ title = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.label_use_blur),
+ subtitle = when {
+ com.sameerasw.airsync.utils.DeviceInfoUtil.isBlurProblematicDevice() ->
+ androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.subtitle_blur_disabled_samsung)
- SmartspacerCard(
- isSmartspacerShowWhenDisconnected = uiState.isSmartspacerShowWhenDisconnected,
- onToggleSmartspacerShowWhenDisconnected = { enabled: Boolean ->
- viewModel.setSmartspacerShowWhenDisconnected(enabled)
- }
- )
+ uiState.isPowerSaveMode && uiState.isBlurSettingEnabled ->
+ androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.subtitle_blur_disabled_power_save)
- // Mac Media Controls toggle for Play Store initiation proof
- SendNowPlayingCard(
- isSendNowPlayingEnabled = uiState.isMacMediaControlsEnabled,
- onToggleSendNowPlaying = { enabled: Boolean ->
- viewModel.setMacMediaControlsEnabled(enabled)
- },
- title = "Show Mac Media Controls",
- subtitle = "Show media controls when Mac is playing music"
- )
+ else -> androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.subtitle_use_blur)
+ },
+ enabled = !com.sameerasw.airsync.utils.DeviceInfoUtil.isBlurProblematicDevice()
+ )
- // Essentials Bridge Toggle
- val isEssentialsInstalled = try {
- context.packageManager.getPackageInfo("com.sameerasw.essentials", 0)
- true
- } catch (e: android.content.pm.PackageManager.NameNotFoundException) {
- false
- }
+ SendNowPlayingCard(
+ isSendNowPlayingEnabled = uiState.isPitchBlackThemeEnabled,
+ onToggleSendNowPlaying = { enabled: Boolean ->
+ viewModel.setPitchBlackThemeEnabled(enabled)
+ },
+ title = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.label_pitch_black_theme),
+ subtitle = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.subtitle_pitch_black_theme)
+ )
- if (isEssentialsInstalled) {
SendNowPlayingCard(
- isSendNowPlayingEnabled = uiState.isEssentialsConnectionEnabled,
+ isSendNowPlayingEnabled = uiState.isSentryReportingEnabled,
onToggleSendNowPlaying = { enabled: Boolean ->
- viewModel.setEssentialsConnectionEnabled(enabled)
+ viewModel.setSentryReportingEnabled(enabled)
},
- title = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.connect_to_essentials),
- subtitle = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.connect_to_essentials_summary)
+ title = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.label_error_reporting),
+ subtitle = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.subtitle_error_reporting)
)
- } else {
- Card(
- modifier = Modifier.fillMaxWidth(),
- shape = MaterialTheme.shapes.extraSmall,
- ) {
- androidx.compose.material3.ListItem(
- colors = androidx.compose.material3.ListItemDefaults.colors(containerColor = androidx.compose.ui.graphics.Color.Transparent),
- headlineContent = { Text(androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.download_essentials)) },
- supportingContent = { Text(androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.download_essentials_summary)) },
- trailingContent = {
- Button(
- onClick = {
- val intent = android.content.Intent(
- android.content.Intent.ACTION_VIEW,
- android.net.Uri.parse("https://github.com/sameerasw/essentials/releases/latest")
- )
- intent.flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK
- context.startActivity(intent)
- }
- ) {
- Text("Download")
- }
- }
- )
- }
}
-
- ExpandNetworkingCard(context)
- }
-
- // Device Info Section
- RoundedCardContainer {
- DeviceInfoCard(
- deviceName = uiState.deviceNameInput,
- localIp = deviceInfo.localIp,
- onDeviceNameChange = { viewModel.updateDeviceName(it) }
- )
}
- // Developer Mode & Icon Sync Section
- RoundedCardContainer {
- AnimatedVisibility(
- visible = uiState.isDeveloperModeVisible,
- enter = expandVertically() + fadeIn(),
- exit = shrinkVertically() + fadeOut()
- ) {
- DeveloperModeCard(
- isDeveloperMode = uiState.isDeveloperMode,
- onToggleDeveloperMode = { viewModel.setDeveloperMode(it) },
- isLoading = uiState.isLoading,
- onSendDeviceInfo = {
- val adbPorts = try {
- val discoveredServices =
- com.sameerasw.airsync.AdbDiscoveryHolder.getDiscoveredServices()
- discoveredServices.map { it.port.toString() }
- } catch (_: Exception) {
- emptyList()
- }
- val deviceId =
- com.sameerasw.airsync.utils.DeviceInfoUtil.getDeviceId(context)
- val message = com.sameerasw.airsync.utils.JsonUtil.createDeviceInfoJson(
- deviceId,
- deviceInfo.name,
- deviceInfo.localIp,
- uiState.port.toIntOrNull() ?: 6996,
- versionName ?: "2.0.0",
- adbPorts
- )
- onSendMessage(message)
+ // Sync Section
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ SettingsCategoryTitle("Sync")
+ RoundedCardContainer {
+ NotificationSyncCard(
+ isNotificationEnabled = uiState.isNotificationEnabled,
+ isNotificationSyncEnabled = uiState.isNotificationSyncEnabled,
+ onToggleSync = { enabled ->
+ viewModel.setNotificationSyncEnabled(enabled)
},
- onSendNotification = {
- val testNotification =
- com.sameerasw.airsync.utils.TestNotificationUtil.generateRandomNotification()
-
- // Store ID for mock dismissal support
- com.sameerasw.airsync.utils.NotificationDismissalUtil.storeTestNotificationId(
- testNotification.id
- )
+ onGrantPermissions = { viewModel.setPermissionDialogVisible(true) }
+ )
- val message = com.sameerasw.airsync.utils.JsonUtil.createNotificationJson(
- testNotification.id,
- testNotification.title,
- testNotification.body,
- testNotification.appName,
- testNotification.packageName,
- testNotification.priority,
- testNotification.actions
- )
- onSendMessage(message)
+ ClipboardFeaturesCard(
+ isClipboardSyncEnabled = uiState.isClipboardSyncEnabled,
+ onToggleClipboardSync = { enabled: Boolean ->
+ viewModel.setClipboardSyncEnabled(enabled)
},
- onSendDeviceStatus = {
- val message =
- com.sameerasw.airsync.utils.DeviceInfoUtil.generateDeviceStatusJson(
- context
- )
- onSendMessage(message)
+ isContinueBrowsingEnabled = uiState.isContinueBrowsingEnabled,
+ onToggleContinueBrowsing = { enabled: Boolean ->
+ viewModel.setContinueBrowsingEnabled(enabled)
},
- onExportData = {
- viewModel.setLoading(true)
- scope.launch(Dispatchers.IO) {
- val json = viewModel.exportAllDataToJson(context)
- if (json == null) {
- scope.launch(Dispatchers.Main) {
- Toast.makeText(
- context,
- "Export failed",
- Toast.LENGTH_LONG
- ).show()
- viewModel.setLoading(false)
- }
- } else {
- scope.launch(Dispatchers.Main) {
- onExport(json)
- }
- }
- }
- },
- onImportData = {
- onImport()
+ isContinueBrowsingToggleEnabled = true,
+ continueBrowsingSubtitle = "Prompt to open shared links in browser",
+ isKeepPreviousLinkEnabled = uiState.isKeepPreviousLinkEnabled,
+ onToggleKeepPreviousLink = { enabled: Boolean ->
+ viewModel.setKeepPreviousLinkEnabled(enabled)
+ }
+ )
+
+ MediaSyncCard(
+ isSendNowPlayingEnabled = uiState.isSendNowPlayingEnabled,
+ onToggleSendNowPlaying = { enabled ->
+ viewModel.setSendNowPlayingEnabled(enabled)
},
- onResetOnboarding = {
- onResetOnboarding()
+ isMacMediaControlsEnabled = uiState.isMacMediaControlsEnabled,
+ onToggleMacMediaControls = { enabled ->
+ viewModel.setMacMediaControlsEnabled(enabled)
}
)
}
+ }
- // Manual Icon Sync Button
- Button(
- onClick = {
- HapticUtil.performClick(haptics)
- viewModel.manualSyncAppIcons(context)
- },
- modifier = Modifier.fillMaxWidth(),
- enabled = uiState.isConnected && !uiState.isIconSyncLoading
- ) {
- if (uiState.isIconSyncLoading) {
- CircularProgressIndicator(
- modifier = Modifier.width(16.dp),
- strokeWidth = 2.dp,
- color = MaterialTheme.colorScheme.onPrimary
- )
- Spacer(modifier = Modifier.width(8.dp))
+ // Integration Section
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ SettingsCategoryTitle("Integration")
+ RoundedCardContainer {
+ SmartspacerCard(
+ isSmartspacerShowWhenDisconnected = uiState.isSmartspacerShowWhenDisconnected,
+ onToggleSmartspacerShowWhenDisconnected = { enabled: Boolean ->
+ viewModel.setSmartspacerShowWhenDisconnected(enabled)
+ }
+ )
+
+ val isEssentialsInstalled = try {
+ context.packageManager.getPackageInfo("com.sameerasw.essentials", 0)
+ true
+ } catch (e: android.content.pm.PackageManager.NameNotFoundException) {
+ false
}
- Text(if (uiState.isIconSyncLoading) "Syncing Icons..." else "Sync App Icons")
- }
- // Icon Sync Message Display
- AnimatedVisibility(
- visible = uiState.iconSyncMessage.isNotEmpty(),
- enter = expandVertically() + fadeIn(),
- exit = shrinkVertically() + fadeOut()
- ) {
- Card(
- modifier = Modifier.fillMaxWidth(),
- shape = MaterialTheme.shapes.extraSmall,
- colors = CardDefaults.cardColors(
- containerColor = if (uiState.iconSyncMessage.contains("Successfully"))
- MaterialTheme.colorScheme.primaryContainer
- else MaterialTheme.colorScheme.errorContainer
+ if (isEssentialsInstalled) {
+ SendNowPlayingCard(
+ isSendNowPlayingEnabled = uiState.isEssentialsConnectionEnabled,
+ onToggleSendNowPlaying = { enabled: Boolean ->
+ viewModel.setEssentialsConnectionEnabled(enabled)
+ },
+ title = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.connect_to_essentials),
+ subtitle = androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.connect_to_essentials_summary)
)
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- horizontalArrangement = Arrangement.SpaceBetween,
- verticalAlignment = Alignment.CenterVertically
+ } else {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.extraSmall,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
+ )
) {
- Text(
- text = uiState.iconSyncMessage,
- modifier = Modifier.weight(1f),
- color = if (uiState.iconSyncMessage.contains("Successfully"))
- MaterialTheme.colorScheme.onPrimaryContainer
- else MaterialTheme.colorScheme.onErrorContainer
+ androidx.compose.material3.ListItem(
+ colors = androidx.compose.material3.ListItemDefaults.colors(
+ containerColor = androidx.compose.ui.graphics.Color.Transparent
+ ),
+ headlineContent = { Text(androidx.compose.ui.res.stringResource(com.sameerasw.airsync.R.string.download_essentials)) },
+ supportingContent = {
+ Text(
+ androidx.compose.ui.res.stringResource(
+ com.sameerasw.airsync.R.string.download_essentials_summary
+ )
+ )
+ },
+ trailingContent = {
+ Button(
+ onClick = {
+ val intent = android.content.Intent(
+ android.content.Intent.ACTION_VIEW,
+ android.net.Uri.parse("https://github.com/sameerasw/essentials/releases/latest")
+ )
+ intent.flags =
+ android.content.Intent.FLAG_ACTIVITY_NEW_TASK
+ context.startActivity(intent)
+ }
+ ) {
+ Text("Download")
+ }
+ }
)
- TextButton(onClick = {
- HapticUtil.performClick(haptics)
- viewModel.clearIconSyncMessage()
- }) {
- Text("Dismiss")
- }
}
}
}
}
- Spacer(modifier = Modifier.height(24.dp))
+ // Connection Section
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ SettingsCategoryTitle("Connection")
+ RoundedCardContainer {
+ DeviceInfoCard(
+ deviceName = uiState.deviceNameInput,
+ localIp = deviceInfo.localIp,
+ onDeviceNameChange = { viewModel.updateDeviceName(it) }
+ )
+
+ ExpandNetworkingCard(context)
+ }
+ }
+
+ // Developer Mode
+ AnimatedVisibility(
+ visible = uiState.isDeveloperModeVisible,
+ enter = expandVertically() + fadeIn(),
+ exit = shrinkVertically() + fadeOut()
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ SettingsCategoryTitle("Advanced")
+ RoundedCardContainer {
+ DeveloperModeCard(
+ isDeveloperMode = uiState.isDeveloperMode,
+ onToggleDeveloperMode = { viewModel.setDeveloperMode(it) },
+ isLoading = uiState.isLoading,
+ onSendDeviceInfo = {
+ val adbPorts = try {
+ val discoveredServices =
+ com.sameerasw.airsync.AdbDiscoveryHolder.getDiscoveredServices()
+ discoveredServices.map { it.port.toString() }
+ } catch (_: Exception) {
+ emptyList()
+ }
+ val deviceId =
+ com.sameerasw.airsync.utils.DeviceInfoUtil.getDeviceId(context)
+ val message = com.sameerasw.airsync.utils.JsonUtil.createDeviceInfoJson(
+ deviceId,
+ deviceInfo.name,
+ deviceInfo.localIp,
+ uiState.port.toIntOrNull() ?: 6996,
+ versionName ?: "2.0.0",
+ adbPorts
+ )
+ onSendMessage(message)
+ },
+ onSendNotification = {
+ val testNotification =
+ com.sameerasw.airsync.utils.TestNotificationUtil.generateRandomNotification()
+
+ // Store ID for mock dismissal support
+ com.sameerasw.airsync.utils.NotificationDismissalUtil.storeTestNotificationId(
+ testNotification.id
+ )
+
+ val message =
+ com.sameerasw.airsync.utils.JsonUtil.createNotificationJson(
+ testNotification.id,
+ testNotification.title,
+ testNotification.body,
+ testNotification.appName,
+ testNotification.packageName,
+ testNotification.priority,
+ testNotification.actions
+ )
+ onSendMessage(message)
+ },
+ onSendDeviceStatus = {
+ val message =
+ com.sameerasw.airsync.utils.DeviceInfoUtil.generateDeviceStatusJson(
+ context
+ )
+ onSendMessage(message)
+ },
+ onExportData = {
+ viewModel.setLoading(true)
+ scope.launch(Dispatchers.IO) {
+ val json = viewModel.exportAllDataToJson(context)
+ if (json == null) {
+ scope.launch(Dispatchers.Main) {
+ Toast.makeText(
+ context,
+ "Export failed",
+ Toast.LENGTH_LONG
+ ).show()
+ viewModel.setLoading(false)
+ }
+ } else {
+ scope.launch(Dispatchers.Main) {
+ onExport(json)
+ }
+ }
+ }
+ },
+ onImportData = {
+ onImport()
+ },
+ onResetOnboarding = {
+ onResetOnboarding()
+ },
+ isIconSyncLoading = uiState.isIconSyncLoading,
+ iconSyncMessage = uiState.iconSyncMessage,
+ onManualSyncIcons = {
+ viewModel.manualSyncAppIcons(context)
+ },
+ onClearIconSyncMessage = {
+ viewModel.clearIconSyncMessage()
+ },
+ isConnected = uiState.isConnected
+ )
+ }
+ }
+ }
+
+ AboutSection(
+ onAvatarLongClick = onToggleDeveloperMode
+ )
+
+ Spacer(modifier = Modifier.height(100.dp))
}
}
+@Composable
+fun SettingsCategoryTitle(title: String) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(horizontal = 8.dp)
+ )
+}
+
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ClipboardTileTipCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ClipboardTileTipCard.kt
deleted file mode 100644
index 31c83231..00000000
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ClipboardTileTipCard.kt
+++ /dev/null
@@ -1,66 +0,0 @@
-package com.sameerasw.airsync.presentation.ui.components.cards
-
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Card
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedButton
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalHapticFeedback
-import androidx.compose.ui.unit.dp
-import com.sameerasw.airsync.utils.HapticUtil
-import com.sameerasw.airsync.utils.QuickSettingsUtil
-
-@Composable
-fun ClipboardTileTipCard(
- isQSTileAdded: Boolean = false
-) {
- val context = LocalContext.current
- val haptics = LocalHapticFeedback.current
-
- Card(
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 0.dp),
- shape = MaterialTheme.shapes.extraSmall,
- ) {
- Column(modifier = Modifier.padding(16.dp)) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 8.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = "Add 'Send Clipboard' Tile",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurface
- )
- Text(
- text = "Manually send clipboard content to your computer from Quick Settings.",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
- )
- }
-
- OutlinedButton(
- onClick = {
- HapticUtil.performClick(haptics)
- QuickSettingsUtil.requestAddClipboardTile(context)
- },
- modifier = Modifier.padding(start = 8.dp),
- enabled = !isQSTileAdded
- ) {
- Text(if (isQSTileAdded) "Added" else "Add Tile")
- }
- }
- }
- }
-}
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DefaultTabCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DefaultTabCard.kt
index b184bde3..74141df5 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DefaultTabCard.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DefaultTabCard.kt
@@ -13,6 +13,7 @@ import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.filled.Gamepad
import androidx.compose.material.icons.filled.Phonelink
import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -39,6 +40,9 @@ fun DefaultTabCard(
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.extraSmall,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
+ )
) {
Column(
modifier = Modifier.padding(16.dp)
@@ -124,7 +128,7 @@ private fun TabOption(
)
} else {
IconButtonDefaults.iconButtonColors(
- containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
},
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeveloperModeCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeveloperModeCard.kt
index 743d9539..36ae30ba 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeveloperModeCard.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeveloperModeCard.kt
@@ -1,5 +1,10 @@
package com.sameerasw.airsync.presentation.ui.components.cards
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -7,11 +12,17 @@ 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.layout.width
+import androidx.compose.foundation.background
import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -29,13 +40,22 @@ fun DeveloperModeCard(
onSendDeviceStatus: () -> Unit,
onExportData: () -> Unit,
onImportData: () -> Unit,
- onResetOnboarding: () -> Unit
+ onResetOnboarding: () -> Unit,
+ // Icon Sync Parameters
+ isIconSyncLoading: Boolean,
+ iconSyncMessage: String,
+ onManualSyncIcons: () -> Unit,
+ onClearIconSyncMessage: () -> Unit,
+ isConnected: Boolean
) {
val haptics = LocalHapticFeedback.current
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.extraSmall,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
+ )
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
@@ -99,7 +119,6 @@ fun DeveloperModeCard(
Text("Send Device Status")
}
- // New: export/import split buttons
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {
@@ -135,9 +154,89 @@ fun DeveloperModeCard(
Text("Reset Onboarding")
}
- }
+ // Consolidated Icon Sync Section
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ "Icons",
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary
+ )
+
+ Button(
+ onClick = {
+ HapticUtil.performClick(haptics)
+ onManualSyncIcons()
+ },
+ modifier = Modifier.fillMaxWidth(),
+ enabled = isConnected && !isIconSyncLoading
+ ) {
+ if (isIconSyncLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.width(16.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ }
+ Text(if (isIconSyncLoading) "Syncing Icons..." else "Sync App Icons")
+ }
+
+ AnimatedVisibility(
+ visible = iconSyncMessage.isNotEmpty(),
+ enter = expandVertically() + fadeIn(),
+ exit = shrinkVertically() + fadeOut()
+ ) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.extraSmall,
+ colors = CardDefaults.cardColors(
+ containerColor = if (iconSyncMessage.contains("Successfully"))
+ MaterialTheme.colorScheme.primaryContainer
+ else MaterialTheme.colorScheme.errorContainer
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = iconSyncMessage,
+ modifier = Modifier.weight(1f),
+ style = MaterialTheme.typography.bodySmall,
+ color = if (iconSyncMessage.contains("Successfully"))
+ MaterialTheme.colorScheme.onPrimaryContainer
+ else MaterialTheme.colorScheme.onErrorContainer
+ )
+ TextButton(onClick = {
+ HapticUtil.performClick(haptics)
+ onClearIconSyncMessage()
+ }) {
+ Text("Dismiss", style = MaterialTheme.typography.labelMedium)
+ }
+ }
+ }
+ }
+ // Sentry section
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ "Crash Reporting",
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary
+ )
- // Removed preview/response display to avoid crashes from large/encoded payloads
+ Button(
+ onClick = {
+ HapticUtil.performClick(haptics)
+ throw RuntimeException("Test Crash from Developer Options")
+ },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Simulate Crash")
+ }
+ }
}
}
}
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeviceInfoCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeviceInfoCard.kt
index 8f3162fe..315e7766 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeviceInfoCard.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/DeviceInfoCard.kt
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
@@ -22,6 +23,9 @@ fun DeviceInfoCard(
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.extraSmall,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
+ )
) {
Column(modifier = Modifier.padding(16.dp)) {
Text("My Android", style = MaterialTheme.typography.titleMedium)
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ExpandNetworkingCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ExpandNetworkingCard.kt
index 207bf1f2..ef387993 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ExpandNetworkingCard.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ExpandNetworkingCard.kt
@@ -42,6 +42,9 @@ fun ExpandNetworkingCard(context: Context) {
.fillMaxWidth()
.padding(vertical = 0.dp, horizontal = 0.dp),
shape = MaterialTheme.shapes.extraSmall,
+ colors = androidx.compose.material3.CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
+ )
) {
Row(
modifier = Modifier
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/IconToggleItem.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/IconToggleItem.kt
new file mode 100644
index 00000000..fd3e4fba
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/IconToggleItem.kt
@@ -0,0 +1,116 @@
+package com.sameerasw.airsync.presentation.ui.components.cards
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+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.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import com.sameerasw.airsync.utils.HapticUtil
+
+@Composable
+fun IconToggleItem(
+ iconRes: Int,
+ title: String,
+ modifier: Modifier = Modifier,
+ description: String? = null,
+ isChecked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ enabled: Boolean = true,
+ onDisabledClick: (() -> Unit)? = null,
+ showToggle: Boolean = true
+) {
+ val haptics = LocalHapticFeedback.current
+
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .background(
+ color = MaterialTheme.colorScheme.surfaceBright,
+ shape = RoundedCornerShape(MaterialTheme.shapes.extraSmall.bottomEnd)
+ )
+ .clickable(enabled = !showToggle && enabled) {
+ if (enabled) {
+ HapticUtil.performClick(haptics)
+ onCheckedChange(!isChecked)
+ } else if (onDisabledClick != null) {
+ HapticUtil.performClick(haptics)
+ onDisabledClick()
+ }
+ }
+ .padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Spacer(modifier = Modifier.size(2.dp))
+ Icon(
+ painter = painterResource(id = iconRes),
+ contentDescription = title,
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.size(2.dp))
+
+ if (description != null) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = description,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ } else {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.weight(1f),
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+
+ if (showToggle) {
+ Box {
+ Switch(
+ checked = if (enabled) isChecked else false,
+ onCheckedChange = { checked ->
+ if (enabled) {
+ HapticUtil.performClick(haptics)
+ onCheckedChange(checked)
+ }
+ },
+ enabled = enabled
+ )
+
+ if (!enabled && onDisabledClick != null) {
+ Box(modifier = Modifier
+ .matchParentSize()
+ .clickable {
+ HapticUtil.performClick(haptics)
+ onDisabledClick()
+ })
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/MediaPlayerCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/MediaPlayerCard.kt
new file mode 100644
index 00000000..fe96167d
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/MediaPlayerCard.kt
@@ -0,0 +1,206 @@
+package com.sameerasw.airsync.presentation.ui.components.cards
+
+import android.graphics.Bitmap
+import androidx.compose.foundation.Image
+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.fillMaxHeight
+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.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.VolumeOff
+import androidx.compose.material.icons.automirrored.filled.VolumeUp
+import androidx.compose.material.icons.rounded.Pause
+import androidx.compose.material.icons.rounded.PlayArrow
+import androidx.compose.material.icons.rounded.SkipNext
+import androidx.compose.material.icons.rounded.SkipPrevious
+import androidx.compose.material3.ButtonGroup
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+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.Slider
+import androidx.compose.material3.SliderDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.blur
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.sameerasw.airsync.domain.model.MacMusicInfo
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun MediaPlayerCard(
+ musicInfo: MacMusicInfo?,
+ albumArtBitmap: Bitmap?,
+ volume: Float,
+ isMuted: Boolean,
+ onVolumeChange: (Float) -> Unit,
+ onToggleMute: () -> Unit,
+ onMediaAction: (String) -> Unit,
+ modifier: Modifier = Modifier
+) {
+
+ Card(
+ modifier = modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(4.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceBright)
+ ) {
+ Box(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ // Background Image (Album Art)
+ if (albumArtBitmap != null) {
+ Image(
+ bitmap = albumArtBitmap.asImageBitmap(),
+ contentDescription = null,
+ modifier = Modifier
+ .matchParentSize()
+ .blur(8.dp),
+ contentScale = ContentScale.Crop
+ )
+ // Dark scrim for readability
+ Box(
+ modifier = Modifier
+ .matchParentSize()
+ .background(MaterialTheme.colorScheme.background.copy(alpha = 0.6f))
+ )
+ }
+ Column(
+ modifier = Modifier.padding(
+ horizontal = 24.dp,
+ vertical = 32.dp
+ ),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(32.dp)
+ ) {
+ // Metadata
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ Text(
+ text = musicInfo?.title?.takeIf { it.isNotEmpty() }
+ ?: "Nothing Playing",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = if (albumArtBitmap != null) MaterialTheme.colorScheme.onBackground else MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = musicInfo?.artist?.takeIf { it.isNotEmpty() }
+ ?: "from your Mac",
+ style = MaterialTheme.typography.titleMedium,
+ color = if (albumArtBitmap != null) MaterialTheme.colorScheme.onBackground.copy(
+ alpha = 0.7f
+ ) else MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ ButtonGroup(
+ modifier = Modifier
+ .weight(1f)
+ .height(60.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ content = {
+ // Previous Button
+ FilledTonalIconButton(
+ onClick = { onMediaAction("media_prev") },
+ modifier = Modifier
+ .weight(0.7f)
+ .fillMaxHeight(),
+ ) {
+ Icon(
+ Icons.Rounded.SkipPrevious,
+ contentDescription = "Previous",
+ modifier = Modifier.size(36.dp)
+ )
+ }
+
+ // Play/Pause Button
+ FilledIconButton(
+ onClick = { onMediaAction("media_play_pause") },
+ modifier = Modifier
+ .weight(1.5f)
+ .fillMaxHeight()
+ ) {
+ Icon(
+ imageVector = if (musicInfo?.isPlaying == true) Icons.Rounded.Pause else Icons.Rounded.PlayArrow,
+ contentDescription = if (musicInfo?.isPlaying == true) "Pause" else "Play",
+ modifier = Modifier.size(48.dp)
+ )
+ }
+
+ // Next Button
+ FilledTonalIconButton(
+ onClick = { onMediaAction("media_next") },
+ modifier = Modifier
+ .weight(0.7f)
+ .fillMaxHeight(),
+ ) {
+ Icon(
+ Icons.Rounded.SkipNext,
+ contentDescription = "Next",
+ modifier = Modifier.size(36.dp)
+ )
+ }
+ }
+ )
+ }
+
+ // Volume Control
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ IconButton(onClick = onToggleMute) {
+ Icon(
+ imageVector = if (isMuted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp,
+ contentDescription = "Mute",
+ tint = if (albumArtBitmap != null) Color.White else MaterialTheme.colorScheme.onSurface
+ )
+ }
+
+ Slider(
+ value = volume,
+ onValueChange = onVolumeChange,
+ valueRange = 0f..100f,
+ modifier = Modifier.weight(1f),
+ colors = SliderDefaults.colors(
+ thumbColor = if (albumArtBitmap != null) Color.White else MaterialTheme.colorScheme.primary,
+ activeTrackColor = if (albumArtBitmap != null) Color.White else MaterialTheme.colorScheme.primary,
+ inactiveTrackColor = if (albumArtBitmap != null) Color.White.copy(alpha = 0.3f) else MaterialTheme.colorScheme.surfaceVariant
+ )
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/MediaSyncCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/MediaSyncCard.kt
new file mode 100644
index 00000000..f7f6da44
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/MediaSyncCard.kt
@@ -0,0 +1,86 @@
+package com.sameerasw.airsync.presentation.ui.components.cards
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.unit.dp
+import com.sameerasw.airsync.utils.HapticUtil
+
+@Composable
+fun MediaSyncCard(
+ isSendNowPlayingEnabled: Boolean,
+ onToggleSendNowPlaying: (Boolean) -> Unit,
+ isMacMediaControlsEnabled: Boolean,
+ onToggleMacMediaControls: (Boolean) -> Unit
+) {
+ val haptics = LocalHapticFeedback.current
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.extraSmall,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
+ )
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ // Send Now Playing Row
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text("Send now playing", style = MaterialTheme.typography.titleMedium)
+ Text(
+ "Share media playback details with desktop",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Switch(
+ checked = isSendNowPlayingEnabled,
+ onCheckedChange = { enabled ->
+ if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff(haptics)
+ onToggleSendNowPlaying(enabled)
+ }
+ )
+ }
+
+ // Mac Media Controls Row
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text("Show Mac Media Controls", style = MaterialTheme.typography.titleMedium)
+ Text(
+ "Show media controls when Mac is playing music",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Switch(
+ checked = isMacMediaControlsEnabled,
+ onCheckedChange = { enabled ->
+ if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff(haptics)
+ onToggleMacMediaControls(enabled)
+ }
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/NotificationSyncCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/NotificationSyncCard.kt
index 098b3140..35f87132 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/NotificationSyncCard.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/NotificationSyncCard.kt
@@ -30,6 +30,9 @@ fun NotificationSyncCard(
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.extraSmall,
+ colors = androidx.compose.material3.CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
+ )
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/QuickSettingsTilesCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/QuickSettingsTilesCard.kt
new file mode 100644
index 00000000..f46f5bf6
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/QuickSettingsTilesCard.kt
@@ -0,0 +1,115 @@
+package com.sameerasw.airsync.presentation.ui.components.cards
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Card
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.sameerasw.airsync.R
+import com.sameerasw.airsync.utils.HapticUtil
+import com.sameerasw.airsync.utils.QuickSettingsUtil
+
+@Composable
+fun QuickSettingsTilesCard(
+ isConnectionTileAdded: Boolean,
+ isClipboardTileAdded: Boolean
+) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.extraSmall,
+ colors = androidx.compose.material3.CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
+ )
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ TileItem(
+ modifier = Modifier.weight(1f),
+ title = stringResource(R.string.label_connection_tile),
+ iconRes = R.drawable.ic_laptop_24,
+ isAdded = isConnectionTileAdded,
+ onClick = { context ->
+ QuickSettingsUtil.requestAddQuickSettingsTile(context)
+ }
+ )
+
+ TileItem(
+ modifier = Modifier.weight(1f),
+ title = stringResource(R.string.label_clipboard_tile),
+ iconRes = R.drawable.ic_clipboard_24,
+ isAdded = isClipboardTileAdded,
+ onClick = { context ->
+ QuickSettingsUtil.requestAddClipboardTile(context)
+ }
+ )
+ }
+ }
+}
+
+@Composable
+private fun TileItem(
+ modifier: Modifier = Modifier,
+ title: String,
+ iconRes: Int,
+ isAdded: Boolean,
+ onClick: (android.content.Context) -> Unit
+) {
+ val context = LocalContext.current
+ val haptics = LocalHapticFeedback.current
+
+ Row(
+ modifier = modifier
+ .alpha(if (isAdded) 0.5f else 1f)
+ .clip(MaterialTheme.shapes.medium)
+ .background(if (isAdded) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.primary)
+ .clickable(enabled = !isAdded) {
+ HapticUtil.performClick(haptics)
+ onClick(context)
+ }
+ .padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Icon(
+ painter = painterResource(id = iconRes),
+ contentDescription = null,
+ modifier = Modifier.size(28.dp),
+ tint = if (isAdded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onPrimary
+ )
+
+ Column(horizontalAlignment = Alignment.Start) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.labelLarge,
+ color = if (isAdded) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onPrimary
+ )
+ Text(
+ text = if (isAdded) stringResource(R.string.status_added) else stringResource(R.string.status_add),
+ style = MaterialTheme.typography.bodySmall,
+ color = if (isAdded) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f)
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/QuickSettingsTipCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/QuickSettingsTipCard.kt
deleted file mode 100644
index 153e04e1..00000000
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/QuickSettingsTipCard.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-package com.sameerasw.airsync.presentation.ui.components.cards
-
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Card
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedButton
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalHapticFeedback
-import androidx.compose.ui.unit.dp
-import com.sameerasw.airsync.utils.HapticUtil
-import com.sameerasw.airsync.utils.QuickSettingsUtil
-
-@Composable
-fun QuickSettingsTipCard(
- isQSTileAdded: Boolean = false
-) {
- val context = LocalContext.current
- val haptics = LocalHapticFeedback.current
-
- Card(
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 0.dp),
- shape = MaterialTheme.shapes.extraSmall,
- ) {
- Column(modifier = Modifier.padding(16.dp)) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(vertical = 8.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = "Add Quick Settings Tile",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurface
- )
- Text(
- text = "For quick connection and scanner access with long press.",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
- )
- }
-
- OutlinedButton(
- onClick = {
- HapticUtil.performClick(haptics)
- QuickSettingsUtil.requestAddQuickSettingsTile(context)
- },
- modifier = Modifier.padding(start = 8.dp),
- enabled = !isQSTileAdded
- ) {
- Text(if (isQSTileAdded) "Added" else "Add Tile")
- }
- }
- }
- }
-}
-
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/RemoteFunctionsCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/RemoteFunctionsCard.kt
new file mode 100644
index 00000000..2fa7b946
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/RemoteFunctionsCard.kt
@@ -0,0 +1,157 @@
+package com.sameerasw.airsync.presentation.ui.components.cards
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+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.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.sameerasw.airsync.R
+import com.sameerasw.airsync.utils.HapticUtil
+
+/**
+ * A card containing quick remote functions for the connected Mac.
+ * Currently includes: Lock Screen.
+ */
+@Composable
+fun RemoteFunctionsCard(
+ onRemoteAction: (String) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val haptics = LocalHapticFeedback.current
+
+ Card(
+ modifier = modifier
+ .fillMaxWidth(),
+ shape = MaterialTheme.shapes.extraSmall,
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceBright)
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(12.dp),
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Lock Screen Button
+ Button(
+ onClick = {
+ HapticUtil.performClick(haptics)
+ onRemoteAction("lock_screen")
+ },
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer,
+ contentColor = MaterialTheme.colorScheme.onSecondaryContainer
+ ),
+ contentPadding = PaddingValues(horizontal = 8.dp),
+ modifier = Modifier
+ .weight(1f)
+ .height(48.dp)
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.rounded_lock_24),
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(modifier = Modifier.size(6.dp))
+ Text(
+ text = stringResource(id = R.string.action_lock_screen),
+ style = MaterialTheme.typography.labelLarge,
+ maxLines = 1
+ )
+ }
+
+ // Screensaver Button
+ Button(
+ onClick = {
+ HapticUtil.performClick(haptics)
+ onRemoteAction("screensaver")
+ },
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer,
+ contentColor = MaterialTheme.colorScheme.onSecondaryContainer
+ ),
+ contentPadding = PaddingValues(horizontal = 8.dp),
+ modifier = Modifier
+ .weight(1.3f)
+ .height(48.dp)
+ ) {
+ Icon(
+ painter = painterResource(id = com.sameerasw.airsync.R.drawable.rounded_screenshot_monitor_24),
+ contentDescription = null,
+ modifier = Modifier.size(18.dp)
+ )
+ Spacer(modifier = Modifier.size(6.dp))
+ Text(
+ text = stringResource(id = R.string.action_screensaver),
+ style = MaterialTheme.typography.labelLarge,
+ maxLines = 1
+ )
+ }
+
+ // Brightness Down Button
+ Button(
+ onClick = {
+ HapticUtil.performClick(haptics)
+ onRemoteAction("brightness_down")
+ },
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer,
+ contentColor = MaterialTheme.colorScheme.onSecondaryContainer
+ ),
+ contentPadding = PaddingValues(0.dp),
+ modifier = Modifier
+ .weight(0.5f)
+ .height(48.dp)
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.rounded_brightness_5_24),
+ contentDescription = stringResource(id = R.string.content_description_brightness_down),
+ modifier = Modifier.size(20.dp)
+ )
+ }
+
+ // Brightness Up Button
+ Button(
+ onClick = {
+ HapticUtil.performClick(haptics)
+ onRemoteAction("brightness_up")
+ },
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer,
+ contentColor = MaterialTheme.colorScheme.onSecondaryContainer
+ ),
+ contentPadding = PaddingValues(0.dp),
+ modifier = Modifier
+ .weight(0.5f)
+ .height(48.dp)
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.rounded_brightness_7_24),
+ contentDescription = stringResource(id = R.string.content_description_brightness_up),
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SendNowPlayingCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SendNowPlayingCard.kt
index a2ad687d..2f0205c8 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SendNowPlayingCard.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SendNowPlayingCard.kt
@@ -21,13 +21,17 @@ fun SendNowPlayingCard(
isSendNowPlayingEnabled: Boolean,
onToggleSendNowPlaying: (Boolean) -> Unit,
title: String = "Send now playing",
- subtitle: String = "Share media playback details with desktop"
+ subtitle: String = "Share media playback details with desktop",
+ enabled: Boolean = true
) {
val haptics = LocalHapticFeedback.current
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.extraSmall,
+ colors = androidx.compose.material3.CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
+ )
) {
Row(
modifier = Modifier
@@ -46,6 +50,7 @@ fun SendNowPlayingCard(
}
Switch(
checked = isSendNowPlayingEnabled,
+ enabled = enabled,
onCheckedChange = { enabled ->
if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff(
haptics
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SmartspacerCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SmartspacerCard.kt
index a77ea2c1..d4027eec 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SmartspacerCard.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SmartspacerCard.kt
@@ -26,6 +26,9 @@ fun SmartspacerCard(
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.extraSmall,
+ colors = androidx.compose.material3.CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
+ )
) {
Row(
modifier = Modifier
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SyncFeaturesCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SyncFeaturesCard.kt
index a19359a1..612de356 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SyncFeaturesCard.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/SyncFeaturesCard.kt
@@ -36,6 +36,9 @@ fun ClipboardFeaturesCard(
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.extraSmall,
+ colors = androidx.compose.material3.CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainerHighest
+ )
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialog.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialog.kt
index 79a00929..cd7ff320 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialog.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/dialogs/PermissionDialog.kt
@@ -1,5 +1,6 @@
package com.sameerasw.airsync.presentation.ui.components.dialogs
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -69,7 +70,7 @@ fun PermissionExplanationDialog(
) {
Column(
modifier = Modifier
- .fillMaxWidth()
+ .fillMaxWidth().background(MaterialTheme.colorScheme.surfaceContainerHigh)
.padding(18.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
@@ -117,12 +118,10 @@ fun PermissionExplanationDialog(
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.SemiBold
),
- color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = permissionInfo.whyNeeded,
style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/pickers/CrashReportingPicker.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/pickers/CrashReportingPicker.kt
new file mode 100644
index 00000000..53771a32
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/pickers/CrashReportingPicker.kt
@@ -0,0 +1,96 @@
+package com.sameerasw.airsync.presentation.ui.components.pickers
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.sameerasw.airsync.R
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun CrashReportingPicker(
+ isReportingEnabled: Boolean,
+ onReportingChanged: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+ iconRes: Int = R.drawable.rounded_security_24
+) {
+ val options = listOf(false, true)
+ val labels = listOf(
+ R.string.sentry_mode_off,
+ R.string.sentry_mode_auto
+ )
+
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .background(
+ color = MaterialTheme.colorScheme.surfaceBright,
+ shape = MaterialTheme.shapes.extraSmall
+ )
+ .padding(12.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Spacer(modifier = Modifier.size(2.dp))
+ Icon(
+ painter = painterResource(id = iconRes),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.size(2.dp))
+
+ Text(
+ text = stringResource(R.string.sentry_report_mode_title),
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.weight(1f),
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(ButtonGroupDefaults.ConnectedSpaceBetween),
+ ) {
+ options.forEachIndexed { index, option ->
+ val isChecked = isReportingEnabled == option
+
+ ToggleButton(
+ checked = isChecked,
+ onCheckedChange = {
+ onReportingChanged(option)
+ },
+ modifier = Modifier
+ .weight(1f)
+ .semantics { role = Role.RadioButton },
+ shapes = when {
+ index == 0 -> ButtonGroupDefaults.connectedLeadingButtonShapes()
+ index == options.lastIndex -> ButtonGroupDefaults.connectedTrailingButtonShapes()
+ else -> ButtonGroupDefaults.connectedMiddleButtonShapes()
+ },
+ ) {
+ Text(
+ text = stringResource(labels[index]),
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = if (isChecked) FontWeight.Bold else FontWeight.Normal,
+ maxLines = 1
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/composables/WelcomeScreen.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/composables/WelcomeScreen.kt
new file mode 100644
index 00000000..387385e1
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/composables/WelcomeScreen.kt
@@ -0,0 +1,835 @@
+package com.sameerasw.airsync.presentation.ui.composables
+
+import android.content.Intent
+import android.content.res.Configuration
+import android.os.Build
+import androidx.compose.animation.*
+import androidx.compose.animation.core.*
+import androidx.compose.foundation.*
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.runtime.*
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.hapticfeedback.HapticFeedback
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.core.net.toUri
+import coil.ImageLoader
+import coil.compose.AsyncImage
+import coil.decode.GifDecoder
+import coil.decode.ImageDecoderDecoder
+import coil.request.ImageRequest
+import com.sameerasw.airsync.R
+import com.sameerasw.airsync.presentation.ui.components.HelpAndGuidesContent
+import com.sameerasw.airsync.presentation.ui.components.cards.IconToggleItem
+import com.sameerasw.airsync.presentation.ui.components.pickers.CrashReportingPicker
+import com.sameerasw.airsync.presentation.ui.components.RoundedCardContainer
+import com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel
+import com.sameerasw.airsync.ui.theme.GoogleSansFlex
+import com.sameerasw.airsync.utils.HapticUtil
+import com.sameerasw.airsync.utils.DeviceInfoUtil
+import kotlinx.coroutines.launch
+import kotlin.math.PI
+import kotlin.math.atan2
+
+enum class OnboardingStep {
+ WELCOME,
+ ACKNOWLEDGEMENT,
+ PREFERENCES,
+ FEATURE_INTRODUCTION
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun WelcomeScreen(
+ viewModel: AirSyncViewModel,
+ onBeginClick: () -> Unit
+) {
+ val haptics = LocalHapticFeedback.current
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val uiState by viewModel.uiState.collectAsState()
+
+ var currentStep by remember { mutableStateOf(OnboardingStep.WELCOME) }
+ val rotationAnimatable = remember { Animatable(0f) }
+ var center by remember { mutableStateOf(Offset.Zero) }
+ var hasTriggeredEasterEgg by remember { mutableStateOf(false) }
+
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.surfaceContainer
+ ) {
+ AnimatedContent(
+ targetState = currentStep,
+ transitionSpec = {
+ if (targetState.ordinal > initialState.ordinal) {
+ (slideInHorizontally { it } + fadeIn(tween(400)))
+ .togetherWith(slideOutHorizontally { -it } + fadeOut(tween(400)))
+ } else {
+ (slideInHorizontally { -it } + fadeIn(tween(400)))
+ .togetherWith(slideOutHorizontally { it } + fadeOut(tween(400)))
+ }
+ },
+ label = "OnboardingTransition"
+ ) { step ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ ) {
+ when (step) {
+ OnboardingStep.WELCOME -> {
+ WelcomeStepContent(
+ haptics = haptics,
+ rotationAnimatable = rotationAnimatable,
+ center = center,
+ onCenterChanged = { center = it },
+ hasTriggeredEasterEgg = hasTriggeredEasterEgg,
+ onEasterEggTriggered = { hasTriggeredEasterEgg = true },
+ onNext = {
+ HapticUtil.performClick(haptics)
+ currentStep = OnboardingStep.ACKNOWLEDGEMENT
+ }
+ )
+ }
+
+ OnboardingStep.ACKNOWLEDGEMENT -> {
+ AcknowledgementStepContent(
+ haptics = haptics,
+ missingPermissionsCount = uiState.missingPermissions.size,
+ onBack = {
+ HapticUtil.performClick(haptics)
+ currentStep = OnboardingStep.WELCOME
+ },
+ onNext = {
+ HapticUtil.performClick(haptics)
+ currentStep = OnboardingStep.PREFERENCES
+ }
+ )
+ }
+
+ OnboardingStep.PREFERENCES -> {
+ PreferencesStepContent(
+ haptics = haptics,
+ viewModel = viewModel,
+ uiState = uiState,
+ onBack = {
+ HapticUtil.performClick(haptics)
+ currentStep = OnboardingStep.ACKNOWLEDGEMENT
+ },
+ onNext = {
+ HapticUtil.performClick(haptics)
+ viewModel.setOnboardingCompleted(true)
+ currentStep = OnboardingStep.FEATURE_INTRODUCTION
+ }
+ )
+ }
+
+ OnboardingStep.FEATURE_INTRODUCTION -> {
+ FeatureIntroStepContent(
+ haptics = haptics,
+ onBack = {
+ HapticUtil.performClick(haptics)
+ currentStep = OnboardingStep.PREFERENCES
+ },
+ onFinish = {
+ HapticUtil.performClick(haptics)
+ onBeginClick()
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun WelcomeStepContent(
+ haptics: androidx.compose.ui.hapticfeedback.HapticFeedback,
+ rotationAnimatable: Animatable,
+ center: Offset,
+ onCenterChanged: (Offset) -> Unit,
+ hasTriggeredEasterEgg: Boolean,
+ onEasterEggTriggered: () -> Unit,
+ onNext: () -> Unit
+) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+
+ Column(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Spacer(modifier = Modifier.statusBarsPadding())
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ Image(
+ painter = painterResource(id = R.drawable.ic_launcher_foreground),
+ contentDescription = null,
+ modifier = Modifier
+ .size(240.dp)
+ .onSizeChanged {
+ onCenterChanged(Offset(it.width / 2f, it.height / 2f))
+ }
+ .pointerInput(Unit) {
+ val majorStep = 60f
+ val minorStep = 2f
+
+ var currentRotation = 0f
+ var lastMajorNotch = 0
+ var lastMinorNotch = 0
+
+ detectDragGestures(
+ onDragStart = {
+ scope.launch { rotationAnimatable.stop() }
+ currentRotation = rotationAnimatable.value
+ lastMajorNotch = kotlin.math.round(currentRotation / majorStep).toInt()
+ lastMinorNotch = kotlin.math.round(currentRotation / minorStep).toInt()
+ },
+ onDrag = { change, _ ->
+ val oldAngle = atan2(
+ change.previousPosition.y - center.y,
+ change.previousPosition.x - center.x
+ )
+ val newAngle = atan2(
+ change.position.y - center.y,
+ change.position.x - center.x
+ )
+ var delta = (newAngle - oldAngle) * 180 / PI
+
+ if (delta > 180) delta -= 360
+ if (delta < -180) delta += 360
+
+ currentRotation += delta.toFloat()
+
+ // Easter Egg logic
+ if (!hasTriggeredEasterEgg && kotlin.math.abs(currentRotation) >= 3600f) {
+ onEasterEggTriggered()
+ val rickRollUrl = "https://youtu.be/dQw4w9WgXcQ"
+ val intent = Intent(Intent.ACTION_VIEW, rickRollUrl.toUri())
+ context.startActivity(intent)
+ }
+
+ // Minor notches
+ val currentMinorNotch = kotlin.math.round(currentRotation / minorStep).toInt()
+ if (currentMinorNotch != lastMinorNotch) {
+ HapticUtil.performLightTick(haptics)
+ lastMinorNotch = currentMinorNotch
+ }
+
+ lastMajorNotch = kotlin.math.round(currentRotation / majorStep).toInt()
+
+ scope.launch {
+ rotationAnimatable.snapTo(currentRotation)
+ }
+ },
+ onDragEnd = {
+ scope.launch {
+ rotationAnimatable.animateTo(
+ targetValue = 0f,
+ animationSpec = spring(
+ dampingRatio = Spring.DampingRatioMediumBouncy,
+ stiffness = Spring.StiffnessLow
+ )
+ ) {
+ val currentMajorNotch = kotlin.math.round(value / majorStep).toInt()
+ if (currentMajorNotch != lastMajorNotch) {
+ HapticUtil.performClick(haptics)
+ lastMajorNotch = currentMajorNotch
+ }
+ }
+ currentRotation = 0f
+ lastMajorNotch = 0
+ lastMinorNotch = 0
+ }
+ }
+ )
+ }
+ .graphicsLayer {
+ rotationZ = rotationAnimatable.value
+ }
+ )
+
+ Spacer(modifier = Modifier.height(18.dp))
+
+ Text(
+ text = stringResource(R.string.welcome_title),
+ style = MaterialTheme.typography.headlineMedium.copy(
+ fontFamily = GoogleSansFlex,
+ fontWeight = FontWeight.SemiBold
+ ),
+ textAlign = TextAlign.Center,
+ )
+
+ Text(
+ text = stringResource(R.string.welcome_subtitle),
+ textAlign = TextAlign.Center,
+ )
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ Row(
+ modifier = Modifier
+ .clip(RoundedCornerShape(100.dp))
+ .background(MaterialTheme.colorScheme.primaryContainer)
+ .padding(8.dp)
+ .clickable {
+ val websiteUrl = "https://sameerasw.com"
+ val intent = Intent(Intent.ACTION_VIEW, websiteUrl.toUri())
+ context.startActivity(intent)
+ },
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Image(
+ painter = painterResource(id = R.drawable.avatar),
+ contentDescription = "Developer Avatar",
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .size(32.dp)
+ .clip(RoundedCornerShape(100.dp))
+ .background(MaterialTheme.colorScheme.surfaceContainerHigh)
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ Text(
+ text = stringResource(R.string.welcome_developer_attribution),
+ textAlign = TextAlign.Center,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ modifier = Modifier.padding(end = 4.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.weight(0.3f))
+ }
+
+ Button(
+ onClick = onNext,
+ modifier = Modifier
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .padding(16.dp)
+ .height(56.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.action_lets_begin),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ Icon(
+ painter = painterResource(id = R.drawable.rounded_keyboard_arrow_right_24),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ }
+}
+
+@Composable
+fun AcknowledgementStepContent(
+ haptics: androidx.compose.ui.hapticfeedback.HapticFeedback,
+ missingPermissionsCount: Int,
+ onBack: () -> Unit,
+ onNext: () -> Unit
+) {
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Top
+ ) {
+ Spacer(modifier = Modifier.statusBarsPadding())
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = stringResource(R.string.acknowledgement_title),
+ style = MaterialTheme.typography.headlineLarge.copy(
+ fontFamily = GoogleSansFlex,
+ fontWeight = FontWeight.Bold
+ ),
+ textAlign = TextAlign.Center
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Surface(
+ modifier = Modifier.weight(1f),
+ shape = RoundedCornerShape(24.dp),
+ color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
+ ) {
+ Column(
+ modifier = Modifier
+ .padding(horizontal = 24.dp)
+ .verticalScroll(rememberScrollState())
+ ) {
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = stringResource(R.string.acknowledgement_desc),
+ style = MaterialTheme.typography.bodyLarge
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+
+ Text(
+ text = stringResource(R.string.acknowledgement_footer),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ RoundedCardContainer {
+ com.sameerasw.airsync.presentation.ui.components.cards.PermissionsCard(
+ missingPermissionsCount = missingPermissionsCount
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .navigationBarsPadding(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedButton(
+ onClick = {
+ HapticUtil.performClick(haptics)
+ onBack()
+ },
+ modifier = Modifier.size(56.dp),
+ shape = RoundedCornerShape(16.dp),
+ contentPadding = PaddingValues(0.dp)
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.rounded_arrow_back_24),
+ contentDescription = stringResource(R.string.action_back),
+ modifier = Modifier.size(24.dp)
+ )
+ }
+
+ Button(
+ onClick = onNext,
+ modifier = Modifier
+ .weight(1f)
+ .height(56.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.action_i_understand),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ Icon(
+ painter = painterResource(id = R.drawable.rounded_check_24),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun FeatureIntroStepContent(
+ haptics: androidx.compose.ui.hapticfeedback.HapticFeedback,
+ onBack: () -> Unit,
+ onFinish: () -> Unit
+) {
+ val context = LocalContext.current
+ val imageLoader = remember {
+ ImageLoader.Builder(context)
+ .components {
+ if (Build.VERSION.SDK_INT >= 28) {
+ add(ImageDecoderDecoder.Factory())
+ } else {
+ add(GifDecoder.Factory())
+ }
+ }
+ .build()
+ }
+
+ Column(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .padding(horizontal = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.statusBarsPadding())
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = stringResource(R.string.action_how_to_connect),
+ style = MaterialTheme.typography.headlineLarge.copy(
+ fontFamily = GoogleSansFlex,
+ fontWeight = FontWeight.Bold
+ ),
+ textAlign = TextAlign.Center
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = stringResource(R.string.feature_intro_desc),
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Start
+ )
+
+
+ Spacer(modifier = Modifier.height(24.dp))
+
+ Text(
+ text = "Quick Settings Tiles",
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(start = 16.dp, bottom = 8.dp).fillMaxWidth(),
+ textAlign = TextAlign.Start
+ )
+
+ RoundedCardContainer {
+ com.sameerasw.airsync.presentation.ui.components.cards.QuickSettingsTilesCard(
+ isConnectionTileAdded = com.sameerasw.airsync.utils.QuickSettingsUtil.isQSTileAdded(
+ context,
+ com.sameerasw.airsync.service.AirSyncTileService::class.java
+ ),
+ isClipboardTileAdded = com.sameerasw.airsync.utils.QuickSettingsUtil.isQSTileAdded(
+ context,
+ com.sameerasw.airsync.service.ClipboardTileService::class.java
+ )
+ )
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ val configuration = LocalConfiguration.current
+ val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
+ val isLargeScreen = configuration.screenWidthDp >= 600
+
+
+ GifItem(
+ modifier = Modifier.fillMaxWidth(),
+ imageLoader = imageLoader,
+ gifResId = R.drawable.airsync_scan
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ Text(
+ text = stringResource(R.string.help_guides_title),
+ style = MaterialTheme.typography.headlineSmall.copy(
+ fontFamily = GoogleSansFlex,
+ fontWeight = FontWeight.Bold
+ ),
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ HelpAndGuidesContent()
+
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Text(
+ text = stringResource(R.string.feature_intro_footer),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Start
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .navigationBarsPadding()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedButton(
+ onClick = {
+ HapticUtil.performClick(haptics)
+ onBack()
+ },
+ modifier = Modifier.size(56.dp),
+ shape = RoundedCornerShape(16.dp),
+ contentPadding = PaddingValues(0.dp)
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.rounded_arrow_back_24),
+ contentDescription = stringResource(R.string.action_back),
+ modifier = Modifier.size(24.dp)
+ )
+ }
+
+ Button(
+ onClick = onFinish,
+ modifier = Modifier
+ .weight(1f)
+ .height(56.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.action_let_me_in),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ Icon(
+ painter = painterResource(id = R.drawable.rounded_mobile_check_24),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun GifItem(
+ modifier: Modifier = Modifier,
+ imageLoader: ImageLoader,
+ gifResId: Int
+) {
+ Surface(
+ modifier = modifier.fillMaxHeight(),
+ shape = RoundedCornerShape(20.dp),
+ color = MaterialTheme.colorScheme.surfaceContainerHigh
+ ) {
+ AsyncImage(
+ model = ImageRequest.Builder(LocalContext.current)
+ .data(gifResId)
+ .crossfade(true)
+ .build(),
+ imageLoader = imageLoader,
+ contentDescription = null,
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Fit
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun PreferencesStepContent(
+ haptics: androidx.compose.ui.hapticfeedback.HapticFeedback,
+ viewModel: AirSyncViewModel,
+ uiState: com.sameerasw.airsync.domain.model.UiState,
+ onBack: () -> Unit,
+ onNext: () -> Unit
+) {
+ val context = LocalContext.current
+ val deviceInfo by viewModel.deviceInfo.collectAsState()
+
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(horizontal = 16.dp)
+ .padding(bottom = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.statusBarsPadding())
+
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState()),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.height(32.dp))
+
+ Text(
+ text = stringResource(R.string.preferences_title),
+ style = MaterialTheme.typography.headlineLarge.copy(
+ fontFamily = GoogleSansFlex,
+ fontWeight = FontWeight.Bold,
+ fontSize = 36.sp
+ ),
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center
+ )
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Text(
+ text = stringResource(R.string.preferences_desc),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // App Settings Section
+ Text(
+ text = stringResource(R.string.label_app_settings),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(start = 12.dp, bottom = 8.dp).fillMaxWidth(),
+ color = MaterialTheme.colorScheme.primary,
+ textAlign = TextAlign.Start
+ )
+
+ RoundedCardContainer {
+ IconToggleItem(
+ iconRes = R.drawable.rounded_mobile_vibrate_24,
+ title = stringResource(R.string.label_haptic_feedback),
+ isChecked = true, // Default to true or load from setting if available
+ onCheckedChange = { _ ->
+ // Haptic settings usually global or handled by HapticUtil
+ }
+ )
+ IconToggleItem(
+ iconRes = R.drawable.rounded_invert_colors_24,
+ title = stringResource(R.string.label_pitch_black_theme),
+ description = stringResource(R.string.subtitle_pitch_black_theme),
+ isChecked = uiState.isPitchBlackThemeEnabled,
+ onCheckedChange = { viewModel.setPitchBlackThemeEnabled(it) }
+ )
+ val isBlurProblematic = remember { DeviceInfoUtil.isBlurProblematicDevice() }
+ IconToggleItem(
+ iconRes = R.drawable.rounded_blur_on_24,
+ title = stringResource(R.string.label_use_blur),
+ description = if (isBlurProblematic) {
+ stringResource(R.string.subtitle_blur_disabled_samsung)
+ } else {
+ stringResource(R.string.subtitle_use_blur)
+ },
+ isChecked = uiState.isBlurEnabled,
+ onCheckedChange = { viewModel.setUseBlurEnabled(it, context) },
+ enabled = !isBlurProblematic
+ )
+ IconToggleItem(
+ iconRes = R.drawable.rounded_security_24,
+ title = stringResource(R.string.label_error_reporting),
+ description = stringResource(R.string.subtitle_error_reporting),
+ isChecked = uiState.isSentryReportingEnabled,
+ onCheckedChange = { viewModel.setSentryReportingEnabled(it) }
+ )
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ // Connection Section
+ Text(
+ text = "Connection",
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(start = 12.dp, bottom = 8.dp).fillMaxWidth(),
+ color = MaterialTheme.colorScheme.primary,
+ textAlign = TextAlign.Start
+ )
+
+ RoundedCardContainer {
+ com.sameerasw.airsync.presentation.ui.components.cards.DeviceInfoCard(
+ deviceName = uiState.deviceNameInput,
+ localIp = deviceInfo.localIp,
+ onDeviceNameChange = { viewModel.updateDeviceName(it) }
+ )
+
+ com.sameerasw.airsync.presentation.ui.components.cards.ExpandNetworkingCard(context)
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .navigationBarsPadding(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedButton(
+ onClick = {
+ HapticUtil.performClick(haptics)
+ onBack()
+ },
+ modifier = Modifier.size(56.dp),
+ shape = RoundedCornerShape(16.dp),
+ contentPadding = PaddingValues(0.dp)
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.rounded_arrow_back_24),
+ contentDescription = stringResource(R.string.action_back),
+ modifier = Modifier.size(24.dp)
+ )
+ }
+
+ Button(
+ onClick = onNext,
+ modifier = Modifier
+ .weight(1f)
+ .height(56.dp)
+ ) {
+ Text(
+ text = "Continue",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold
+ )
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ Icon(
+ painter = painterResource(id = R.drawable.rounded_keyboard_arrow_right_24),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp)
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/models/AirSyncTab.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/models/AirSyncTab.kt
index b3f6ffc1..2506543c 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/models/AirSyncTab.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/models/AirSyncTab.kt
@@ -1,9 +1,10 @@
package com.sameerasw.airsync.presentation.ui.models
+import androidx.annotation.StringRes
import androidx.compose.ui.graphics.vector.ImageVector
data class AirSyncTab(
- val title: String,
+ @param:StringRes val title: Int,
val icon: ImageVector,
val index: Int
)
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/modifiers/ProgressiveBlurModifier.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/modifiers/ProgressiveBlurModifier.kt
new file mode 100644
index 00000000..d843cdbd
--- /dev/null
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/modifiers/ProgressiveBlurModifier.kt
@@ -0,0 +1,93 @@
+package com.sameerasw.airsync.presentation.ui.modifiers
+
+import android.graphics.RenderEffect
+import android.graphics.RuntimeShader
+import android.os.Build
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.asComposeRenderEffect
+import androidx.compose.ui.graphics.graphicsLayer
+
+enum class BlurDirection {
+ TOP, BOTTOM
+}
+
+private val PROGRESSIVE_BLUR_SKSL = """
+ uniform shader content;
+ uniform float blurRadius;
+ uniform float height;
+ uniform float contentHeight;
+ uniform int isTop;
+
+ half4 main(float2 fragCoord) {
+ float progress;
+ if (isTop == 1) {
+ progress = 1.0 - clamp(fragCoord.y / height, 0.0, 1.0);
+ } else {
+ progress = 1.0 - clamp((contentHeight - fragCoord.y) / height, 0.0, 1.0);
+ }
+
+ // Easing curve for smoother transition (power curve)
+ progress = pow(progress, 1.5);
+
+ float radius = progress * blurRadius;
+
+ if (radius <= 0.0) {
+ return content.eval(fragCoord);
+ }
+
+ half4 accum = half4(0.0);
+ float weightSum = 0.0;
+
+ // Random value for dithering based on pixel coordinates
+ float dither = fract(sin(dot(fragCoord, float2(12.9898, 78.233))) * 43758.5453);
+ float2 jitter = float2(dither - 0.5, fract(dither * 1.618) - 0.5);
+
+ const int SAMPLES = 4;
+ float offsetScale = radius / float(SAMPLES);
+
+ for (int x = -SAMPLES; x <= SAMPLES; x++) {
+ for (int y = -SAMPLES; y <= SAMPLES; y++) {
+ // Apply jittered sampling with dither
+ float2 offset = (float2(float(x), float(y)) + jitter) * offsetScale;
+
+ float distSq = dot(offset, offset);
+ float radiusSq = radius * radius;
+
+ if (distSq <= radiusSq) {
+ float weight = exp(-3.0 * distSq / radiusSq);
+ accum += content.eval(fragCoord + offset) * weight;
+ weightSum += weight;
+ }
+ }
+ }
+
+ return accum / weightSum;
+ }
+""".trimIndent()
+
+/**
+ * Applies a progressive blur to the specified edge of the element.
+ * Only works on Android 13+ (API 33).
+ */
+fun Modifier.progressiveBlur(
+ blurRadius: Float,
+ height: Float,
+ direction: BlurDirection = BlurDirection.TOP
+): Modifier = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ this.then(
+ Modifier.graphicsLayer {
+ if (blurRadius <= 0f) return@graphicsLayer
+
+ val shader = RuntimeShader(PROGRESSIVE_BLUR_SKSL)
+ shader.setFloatUniform("blurRadius", blurRadius)
+ shader.setFloatUniform("height", height)
+ shader.setFloatUniform("contentHeight", size.height)
+ shader.setIntUniform("isTop", if (direction == BlurDirection.TOP) 1 else 0)
+
+ renderEffect = RenderEffect.createRuntimeShaderEffect(shader, "content")
+ .asComposeRenderEffect()
+ }
+ )
+} else {
+ this
+}
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/AirSyncMainScreen.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/AirSyncMainScreen.kt
index bae5218a..ac450ed4 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/AirSyncMainScreen.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/AirSyncMainScreen.kt
@@ -19,13 +19,17 @@ 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.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
@@ -54,6 +58,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
@@ -63,10 +68,12 @@ 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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
@@ -87,18 +94,24 @@ import com.sameerasw.airsync.presentation.ui.activities.QRScannerActivity
import com.sameerasw.airsync.presentation.ui.components.AirSyncFloatingToolbar
import com.sameerasw.airsync.presentation.ui.components.RoundedCardContainer
import com.sameerasw.airsync.presentation.ui.components.SettingsView
+import com.sameerasw.airsync.presentation.ui.modifiers.BlurDirection
+import com.sameerasw.airsync.presentation.ui.modifiers.progressiveBlur
import com.sameerasw.airsync.presentation.ui.components.cards.ConnectionStatusCard
import com.sameerasw.airsync.presentation.ui.components.cards.LastConnectedDeviceCard
import com.sameerasw.airsync.presentation.ui.components.cards.ManualConnectionCard
+import com.sameerasw.airsync.presentation.ui.components.cards.MediaPlayerCard
+import com.sameerasw.airsync.presentation.ui.components.cards.RemoteFunctionsCard
import com.sameerasw.airsync.presentation.ui.components.cards.RateAppCard
import com.sameerasw.airsync.presentation.ui.components.dialogs.ConnectionDialog
-import com.sameerasw.airsync.presentation.ui.components.sheets.AboutBottomSheet
import com.sameerasw.airsync.presentation.ui.components.sheets.HelpSupportBottomSheet
+import com.sameerasw.airsync.presentation.ui.composables.WelcomeScreen
import com.sameerasw.airsync.presentation.ui.models.AirSyncTab
import com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel
import com.sameerasw.airsync.utils.ClipboardSyncManager
import com.sameerasw.airsync.utils.HapticUtil
import com.sameerasw.airsync.utils.JsonUtil
+import com.sameerasw.airsync.utils.MacDeviceStatusManager
+import com.sameerasw.airsync.utils.WebSocketMessageHandler
import com.sameerasw.airsync.utils.WebSocketUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -107,6 +120,7 @@ import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
+import org.json.JSONObject
import java.net.URLDecoder
@OptIn(
@@ -124,10 +138,6 @@ fun AirSyncMainScreen(
isPlus: Boolean = false,
symmetricKey: String? = null,
onNavigateToApps: () -> Unit = {},
- showAboutDialog: Boolean = false,
- onDismissAbout: () -> Unit = {},
- showHelpSheet: Boolean = false,
- onDismissHelp: () -> Unit = {},
onTitleChange: (String) -> Unit = {}
) {
val context = LocalContext.current
@@ -149,6 +159,48 @@ fun AirSyncMainScreen(
val settingsScrollState = rememberScrollState()
var hasProcessedQrDialog by remember { mutableStateOf(false) }
var hasAppliedInitialTab by remember { mutableStateOf(false) }
+ var isWelcomeDismissed by rememberSaveable { mutableStateOf(false) }
+ var hasSeenWelcomeThisSession by rememberSaveable { mutableStateOf(false) }
+
+ if (!uiState.isOnboardingCompleted) {
+ hasSeenWelcomeThisSession = true
+ }
+
+ // Volume & Media state
+ var volume by remember { mutableFloatStateOf(50f) }
+ var isMuted by remember { mutableStateOf(false) }
+
+ // Observe Mac Status
+ val macStatus by MacDeviceStatusManager.macDeviceStatus.collectAsState()
+ val albumArtBitmap by MacDeviceStatusManager.albumArt.collectAsState()
+
+ // Volume updates from Mac
+ DisposableEffect(Unit) {
+ val callback = { newVolume: Int ->
+ volume = newVolume.toFloat()
+ }
+ WebSocketMessageHandler.setOnMacVolumeCallback(callback)
+ onDispose {
+ WebSocketMessageHandler.setOnMacVolumeCallback(null)
+ }
+ }
+
+ fun sendRemoteAction(action: String, value: Any? = null) {
+ scope.launch {
+ try {
+ HapticUtil.performLightTick(haptics)
+ val json = JSONObject()
+ json.put("type", "remoteControl")
+ val data = JSONObject()
+ data.put("action", action)
+ if (value != null) data.put("value", value)
+ json.put("data", data)
+ WebSocketUtil.sendMessage(json.toString())
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+ }
val pagerState =
rememberPagerState(initialPage = 0, pageCount = { if (uiState.isConnected) 4 else 2 })
val navCallbackState = rememberUpdatedState(onNavigateToApps)
@@ -157,6 +209,8 @@ fun AirSyncMainScreen(
var fabVisible by remember { mutableStateOf(true) }
var fabExpanded by remember { mutableStateOf(true) }
var showKeyboard by remember { mutableStateOf(false) } // State for Keyboard Sheet in Remote Tab
+ var showHelpSheet by remember { mutableStateOf(false) }
+ val onDismissHelp = { showHelpSheet = false }
var loadingHapticsJob by remember { mutableStateOf(null) }
// Initial tab navigation logic
@@ -174,7 +228,7 @@ fun AirSyncMainScreen(
"clipboard" -> 2
"dynamic" -> {
// Check if music is playing on Mac
- if (uiState.macDeviceStatus?.music?.isPlaying == true) 1 else 2
+ if (uiState.macDeviceStatus?.music?.isPlaying == true) 0 else 2
}
else -> 0
@@ -191,8 +245,6 @@ fun AirSyncMainScreen(
var pendingExportJson by remember { mutableStateOf(null) }
rememberNavController()
- val exitAlwaysScrollBehavior =
- FloatingToolbarDefaults.exitAlwaysScrollBehavior(exitDirection = Bottom)
fun connect(deviceId: String? = null) {
// Check if critical permissions are missing
@@ -258,7 +310,7 @@ fun AirSyncMainScreen(
Toast.makeText(context, "Connection Timed Out", Toast.LENGTH_SHORT).show()
viewModel.setResponse("Connection Timed Out")
} else {
- val connected = result ?: false
+ val connected = result
viewModel.setConnectionStatus(isConnected = connected, isConnecting = false)
if (connected) {
viewModel.setResponse("Connected successfully!")
@@ -596,15 +648,15 @@ fun AirSyncMainScreen(
val tabs = remember(uiState.isConnected) {
if (uiState.isConnected) {
listOf(
- AirSyncTab("Connect", Icons.Outlined.Phonelink, 0),
- AirSyncTab("Remote", Icons.Filled.Gamepad, 1),
- AirSyncTab("Clipboard", Icons.Filled.ContentPaste, 2),
- AirSyncTab("Settings", Icons.Filled.Settings, 3)
+ AirSyncTab(R.string.tab_connect, Icons.Outlined.Phonelink, 0),
+ AirSyncTab(R.string.tab_remote, Icons.Filled.Gamepad, 1),
+ AirSyncTab(R.string.tab_clipboard, Icons.Filled.ContentPaste, 2),
+ AirSyncTab(R.string.tab_settings, Icons.Filled.Settings, 3)
)
} else {
listOf(
- AirSyncTab("Connect", Icons.Filled.Phonelink, 0),
- AirSyncTab("Settings", Icons.Filled.Settings, 1)
+ AirSyncTab(R.string.tab_connect, Icons.Filled.Phonelink, 0),
+ AirSyncTab(R.string.tab_settings, Icons.Filled.Settings, 1)
)
}
}
@@ -613,23 +665,21 @@ fun AirSyncMainScreen(
LaunchedEffect(pagerState.currentPage, tabs) {
val currentTab = tabs.getOrNull(pagerState.currentPage)
if (currentTab != null) {
- val title = if (currentTab.title == "Connect") "AirSync" else currentTab.title
+ val titleStr = context.getString(currentTab.title)
+ val title = if (titleStr == "Connect") "AirSync" else titleStr
onTitleChange(title)
}
}
- Scaffold(
- contentWindowInsets = WindowInsets(0, 0, 0, 0),
- modifier = Modifier
- .fillMaxSize()
- .nestedScroll(
- if (pagerState.currentPage != 2) exitAlwaysScrollBehavior
- else remember {
- object : androidx.compose.ui.input.nestedscroll.NestedScrollConnection {}
- }
- ),
- containerColor = MaterialTheme.colorScheme.surfaceContainer,
- ) { innerPadding ->
+ Box(modifier = Modifier.fillMaxSize()) {
+ Scaffold(
+ contentWindowInsets = WindowInsets(0, 0, 0, 0),
+ modifier = Modifier.fillMaxSize(),
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ ) { innerPadding ->
+ val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
+ val topSpacing = (statusBarHeight - 24.dp).coerceAtLeast(0.dp)
+
// Track page changes for haptic feedback on swipe
LaunchedEffect(pagerState.currentPage) {
snapshotFlow { pagerState.currentPage }.collect { _ ->
@@ -637,9 +687,35 @@ fun AirSyncMainScreen(
}
}
- Box(modifier = Modifier.fillMaxSize()) {
+ // Blur heights
+ val density = androidx.compose.ui.platform.LocalDensity.current
+ val statusBarHeightPx = with(density) {
+ WindowInsets.statusBars.asPaddingValues().calculateTopPadding().toPx()
+ }
+ val bottomBlurHeightPx = with(density) { 130.dp.toPx() }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ ) {
HorizontalPager(
- modifier = modifier.fillMaxSize(),
+ modifier = modifier
+ .fillMaxSize()
+ .then(
+ if (uiState.isBlurEnabled) {
+ Modifier
+ .progressiveBlur(
+ blurRadius = 40f,
+ height = statusBarHeightPx * 1.15f,
+ direction = BlurDirection.TOP
+ )
+ .progressiveBlur(
+ blurRadius = 40f,
+ height = bottomBlurHeightPx,
+ direction = BlurDirection.BOTTOM
+ )
+ } else Modifier
+ ),
state = pagerState
) { page ->
when (page) {
@@ -648,14 +724,18 @@ fun AirSyncMainScreen(
Column(
modifier = Modifier
.fillMaxSize()
- .padding(bottom = 0.dp)
+ .padding(vertical = 0.dp)
.verticalScroll(connectScrollState)
.padding(horizontal = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
- Spacer(modifier = Modifier.height(0.dp))
+ Spacer(
+ modifier = Modifier
+ .height(topSpacing)
+ .fillMaxWidth()
+ )
RoundedCardContainer {
@@ -680,6 +760,40 @@ fun AirSyncMainScreen(
lastConnected = uiState.lastConnectedDevice != null,
uiState = uiState,
)
+
+ // Remote Functions Card (Lock Screen, etc.)
+ AnimatedVisibility(
+ visible = uiState.isConnected,
+ enter = expandVertically() + fadeIn(),
+ exit = shrinkVertically() + fadeOut()
+ ) {
+ RemoteFunctionsCard(
+ onRemoteAction = { sendRemoteAction(it) }
+ )
+ }
+
+ // Media Player Card
+ AnimatedVisibility(
+ visible = uiState.isConnected,
+ enter = expandVertically() + fadeIn(),
+ exit = shrinkVertically() + fadeOut()
+ ) {
+ MediaPlayerCard(
+ musicInfo = macStatus?.music,
+ albumArtBitmap = albumArtBitmap,
+ volume = volume,
+ isMuted = isMuted,
+ onVolumeChange = {
+ volume = it
+ sendRemoteAction("vol_set", it.toInt())
+ },
+ onToggleMute = {
+ sendRemoteAction("vol_mute")
+ isMuted = !isMuted
+ },
+ onMediaAction = { sendRemoteAction(it) }
+ )
+ }
}
RoundedCardContainer {
@@ -914,7 +1028,7 @@ fun AirSyncMainScreen(
}
}
- Spacer(modifier = Modifier.height(24.dp))
+ Spacer(modifier = Modifier.height(100.dp))
}
}
@@ -924,7 +1038,7 @@ fun AirSyncMainScreen(
RemoteControlScreen(
modifier = Modifier
.fillMaxSize()
- .padding(bottom = 100.dp),
+ .padding(top = statusBarHeight, bottom = 100.dp),
showKeyboard = showKeyboard,
onDismissKeyboard = { showKeyboard = false }
)
@@ -946,7 +1060,9 @@ fun AirSyncMainScreen(
createDocLauncher.launch("airsync_settings_${System.currentTimeMillis()}.json")
},
onImport = { openDocLauncher.launch(arrayOf("application/json")) },
- onResetOnboarding = { viewModel.resetOnboarding() }
+ onResetOnboarding = { viewModel.resetOnboarding() },
+ onShowHelp = { showHelpSheet = true },
+ onToggleDeveloperMode = { viewModel.toggleDeveloperModeVisibility() }
)
}
}
@@ -967,7 +1083,7 @@ fun AirSyncMainScreen(
onHistoryToggle = { viewModel.setClipboardHistoryEnabled(it) },
modifier = Modifier
.fillMaxSize()
- .padding(bottom = 100.dp)
+ .padding(top = topSpacing, bottom = 100.dp),
)
} else {
Box(Modifier.fillMaxSize())
@@ -992,7 +1108,9 @@ fun AirSyncMainScreen(
createDocLauncher.launch("airsync_settings_${System.currentTimeMillis()}.json")
},
onImport = { openDocLauncher.launch(arrayOf("application/json")) },
- onResetOnboarding = { viewModel.resetOnboarding() }
+ onResetOnboarding = { viewModel.resetOnboarding() },
+ onShowHelp = { showHelpSheet = true },
+ onToggleDeveloperMode = { viewModel.toggleDeveloperModeVisibility() }
)
}
}
@@ -1001,7 +1119,7 @@ fun AirSyncMainScreen(
AirSyncFloatingToolbar(
modifier = Modifier
.align(Alignment.BottomCenter)
- .offset(y = -ScreenOffset - 12.dp)
+ // .offset(y = -ScreenOffset)
.zIndex(1f),
currentPage = pagerState.currentPage,
tabs = tabs,
@@ -1015,13 +1133,13 @@ fun AirSyncMainScreen(
}
}
},
- scrollBehavior = exitAlwaysScrollBehavior,
- floatingActionButton = {
+ floatingActionButton = @Composable {
val currentTab = tabs.getOrNull(pagerState.currentPage)
FloatingToolbarDefaults.StandardFloatingActionButton(
onClick = {
HapticUtil.performClick(haptics)
- when (currentTab?.title) {
+ val titleStr = currentTab?.let { context.getString(it.title) }
+ when (titleStr) {
"Remote" -> {
showKeyboard = !showKeyboard
}
@@ -1040,7 +1158,8 @@ fun AirSyncMainScreen(
}
}
) {
- when (currentTab?.title) {
+ val titleStr = currentTab?.let { context.getString(it.title) }
+ when (titleStr) {
"Remote" -> {
Icon(Icons.Rounded.Keyboard, contentDescription = "Keyboard")
}
@@ -1086,13 +1205,6 @@ fun AirSyncMainScreen(
)
}
- // About Bottom Sheet - controlled by parent via showAboutDialog
- if (showAboutDialog) {
- AboutBottomSheet(
- onDismissRequest = onDismissAbout,
- onToggleDeveloperMode = { viewModel.toggleDeveloperModeVisibility() }
- )
- }
// Help & Support Bottom Sheet
if (showHelpSheet) {
@@ -1100,4 +1212,21 @@ fun AirSyncMainScreen(
onDismissRequest = onDismissHelp
)
}
+
+ // Welcome Screen Overlay
+ AnimatedVisibility(
+ visible = (!uiState.isOnboardingCompleted || hasSeenWelcomeThisSession) && !isWelcomeDismissed,
+ enter = fadeIn() + expandVertically(),
+ exit = fadeOut() + shrinkVertically(),
+ modifier = Modifier.zIndex(100f)
+ ) {
+ WelcomeScreen(
+ viewModel = viewModel,
+ onBeginClick = {
+ isWelcomeDismissed = true
+ viewModel.setOnboardingCompleted(true)
+ }
+ )
+ }
+ }
}
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/PermissionsScreen.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/PermissionsScreen.kt
index c955e5fc..1a04029f 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/PermissionsScreen.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/PermissionsScreen.kt
@@ -192,7 +192,7 @@ fun PermissionsScreen(
"Call Log Access" -> {
PermissionButton(
permissionName = permission,
- description = "Enables call log sync",
+ description = "Necessary for mapping the caller info",
onExplainClick = {
showDialog = PermissionType.CALL_LOG
},
@@ -203,7 +203,7 @@ fun PermissionsScreen(
"Contacts Access" -> {
PermissionButton(
permissionName = permission,
- description = "Enables contacts sync",
+ description = "Necessary for displaying known contacts in calls",
onExplainClick = {
showDialog = PermissionType.CONTACTS
},
@@ -214,7 +214,7 @@ fun PermissionsScreen(
"Phone Access" -> {
PermissionButton(
permissionName = permission,
- description = "Enables phone state access",
+ description = "Detects call state changes",
onExplainClick = { showDialog = PermissionType.PHONE },
isCritical = false
)
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/RemoteControlScreen.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/RemoteControlScreen.kt
index effa6352..94bb5cfd 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/RemoteControlScreen.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/screens/RemoteControlScreen.kt
@@ -1,15 +1,10 @@
package com.sameerasw.airsync.presentation.ui.screens
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.expandVertically
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.shrinkVertically
-import androidx.compose.foundation.Image
-import androidx.compose.foundation.background
+import android.content.res.Configuration
+
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
-import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -28,40 +23,25 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDownward
-import androidx.compose.material.icons.filled.ArrowForward
+import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.SpaceBar
-import androidx.compose.material.icons.filled.VolumeOff
-import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material.icons.rounded.KeyboardArrowUp
-import androidx.compose.material.icons.rounded.Pause
-import androidx.compose.material.icons.rounded.PlayArrow
-import androidx.compose.material.icons.rounded.SkipNext
-import androidx.compose.material.icons.rounded.SkipPrevious
-import androidx.compose.material3.ButtonGroup
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
-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.OutlinedButton
-import androidx.compose.material3.Slider
-import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -69,13 +49,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
-import androidx.compose.ui.draw.blur
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
-import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
@@ -88,8 +66,6 @@ import com.sameerasw.airsync.presentation.ui.components.KeyboardModifiers
import com.sameerasw.airsync.presentation.ui.components.ModifierStatus
import com.sameerasw.airsync.presentation.ui.components.RoundedCardContainer
import com.sameerasw.airsync.utils.HapticUtil
-import com.sameerasw.airsync.utils.MacDeviceStatusManager
-import com.sameerasw.airsync.utils.WebSocketMessageHandler
import com.sameerasw.airsync.utils.WebSocketUtil
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
@@ -108,29 +84,6 @@ fun RemoteControlScreen(
val scope = rememberCoroutineScope()
LocalContext.current
- // Volume state (0-100)
- var volume by remember { mutableFloatStateOf(50f) }
- var isMuted by remember { mutableStateOf(false) }
- var isPlayerExpanded by remember { mutableStateOf(false) }
-
- // Observe Mac Status
- val macStatus by MacDeviceStatusManager.macDeviceStatus.collectAsState()
- val musicInfo = macStatus?.music
-
- // Use the centrally managed bitmap flow
- val albumArtBitmap by MacDeviceStatusManager.albumArt.collectAsState()
-
- // Listen for volume updates from Mac
- DisposableEffect(Unit) {
- val callback = { newVolume: Int ->
- volume = newVolume.toFloat()
- }
- WebSocketMessageHandler.setOnMacVolumeCallback(callback)
- onDispose {
- WebSocketMessageHandler.setOnMacVolumeCallback(null)
- }
- }
-
fun sendRemoteAction(
action: String,
value: Any? = null,
@@ -168,16 +121,13 @@ fun RemoteControlScreen(
HapticUtil.performLightTick(haptics)
}
-
- var isMouseMode by remember { mutableStateOf(false) }
var activeModifiers by remember { mutableStateOf(setOf()) }
val moveChannel = remember { Channel(Channel.UNLIMITED) }
val scrollChannel = remember { Channel(Channel.UNLIMITED) }
// movement batching at 100Hz with smoothing
- LaunchedEffect(isMouseMode) {
- if (!isMouseMode) return@LaunchedEffect
+ LaunchedEffect(Unit) {
var pendingMove = Offset.Zero
var pendingScroll = Offset.Zero
@@ -205,7 +155,7 @@ fun RemoteControlScreen(
),
performHaptic = false
)
- lastSentMove = Offset(smoothedX.toFloat(), smoothedY.toFloat())
+ lastSentMove = Offset(smoothedX, smoothedY)
pendingMove = Offset.Zero
}
@@ -293,214 +243,14 @@ fun RemoteControlScreen(
)
}
- val scrollState = rememberScrollState()
-
- Column(
- modifier = modifier
- .fillMaxSize()
- .verticalScroll(scrollState)
- .padding(16.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(24.dp)
- ) {
-
- RoundedCardContainer {
- // Now Playing Card
- Card(
- modifier = Modifier.fillMaxWidth(),
- shape = RoundedCornerShape(4.dp),
- colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
- ) {
- Box(
- modifier = Modifier.fillMaxWidth()
- ) {
- // Background Image (Album Art)
- if (albumArtBitmap != null) {
- Image(
- bitmap = albumArtBitmap!!.asImageBitmap(),
- contentDescription = null,
- modifier = Modifier
- .matchParentSize()
- .blur(8.dp),
- contentScale = ContentScale.Crop
- )
- // Dark scrim for readability
- Box(
- modifier = Modifier
- .matchParentSize()
- .background(MaterialTheme.colorScheme.background.copy(alpha = 0.6f))
- )
- }
- Column(
- modifier = Modifier.padding(
- horizontal = 24.dp,
- vertical = if (isPlayerExpanded) 32.dp else 16.dp
- ),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(if (isPlayerExpanded) 32.dp else 16.dp)
- ) {
- // Album Art (Foreground) & Info
- Column(
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- // Metadata
- Column(
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Text(
- text = musicInfo?.title?.takeIf { it.isNotEmpty() }
- ?: "Nothing Playing",
- style = MaterialTheme.typography.titleLarge,
- fontWeight = FontWeight.Bold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- color = if (albumArtBitmap != null) MaterialTheme.colorScheme.onBackground else MaterialTheme.colorScheme.onSurface
- )
- Text(
- text = musicInfo?.artist?.takeIf { it.isNotEmpty() }
- ?: "from your Mac",
- style = MaterialTheme.typography.titleMedium,
- color = if (albumArtBitmap != null) MaterialTheme.colorScheme.onBackground.copy(
- alpha = 0.7f
- ) else MaterialTheme.colorScheme.onSurfaceVariant,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
- }
- }
-
- // ButtonGroup
-
- Row(
- modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(8.dp)
- ) {
- // Expand/Collapse Toggle
- IconButton(
- onClick = { isPlayerExpanded = !isPlayerExpanded },
- modifier = Modifier.size(48.dp)
- ) {
- Icon(
- imageVector = if (isPlayerExpanded) Icons.Rounded.KeyboardArrowUp else Icons.Rounded.KeyboardArrowDown,
- contentDescription = if (isPlayerExpanded) "Collapse" else "Expand",
- tint = if (albumArtBitmap != null) Color.White else MaterialTheme.colorScheme.onSurface,
- modifier = Modifier.size(24.dp)
- )
- }
-
- ButtonGroup(
- modifier = Modifier
- .weight(1f)
- .height(60.dp),
- horizontalArrangement = Arrangement.spacedBy(4.dp),
- content = {
- // Previous Button
- val prevInteraction = remember { MutableInteractionSource() }
- FilledTonalIconButton(
- onClick = { sendRemoteAction("media_prev") },
- interactionSource = prevInteraction,
- modifier = Modifier
- .weight(0.7f)
- .fillMaxHeight()
- .animateWidth(prevInteraction),
- ) {
- Icon(
- Icons.Rounded.SkipPrevious,
- contentDescription = "Previous",
- modifier = Modifier.size(36.dp)
- )
- }
-
- // Play/Pause Button
- val playInteraction = remember { MutableInteractionSource() }
- FilledIconButton(
- onClick = { sendRemoteAction("media_play_pause") },
- interactionSource = playInteraction,
- modifier = Modifier
- .weight(1.5f)
- .fillMaxHeight()
- .animateWidth(playInteraction)
- ) {
- Icon(
- imageVector = if (musicInfo?.isPlaying == true) Icons.Rounded.Pause else Icons.Rounded.PlayArrow,
- contentDescription = if (musicInfo?.isPlaying == true) "Pause" else "Play",
- modifier = Modifier.size(48.dp)
- )
- }
-
- // Next Button
- val nextInteraction = remember { MutableInteractionSource() }
- FilledTonalIconButton(
- onClick = { sendRemoteAction("media_next") },
- interactionSource = nextInteraction,
- modifier = Modifier
- .weight(0.7f)
- .fillMaxHeight()
- .animateWidth(nextInteraction),
- ) {
- Icon(
- Icons.Rounded.SkipNext,
- contentDescription = "Next",
- modifier = Modifier.size(36.dp)
- )
- }
- }
- )
- }
-
- // Volume Control
- AnimatedVisibility(
- visible = isPlayerExpanded,
- enter = expandVertically() + fadeIn(),
- exit = shrinkVertically() + fadeOut()
- ) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(top = 16.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- IconButton(onClick = {
- sendRemoteAction("vol_mute")
- isMuted = !isMuted
- }) {
- Icon(
- imageVector = if (isMuted) Icons.Default.VolumeOff else Icons.Default.VolumeUp,
- contentDescription = "Mute",
- tint = if (albumArtBitmap != null) Color.White else MaterialTheme.colorScheme.onSurface
- )
- }
-
- Slider(
- value = volume,
- onValueChange = {
- volume = it
- sendRemoteAction("vol_set", it.toInt())
- },
- valueRange = 0f..100f,
- modifier = Modifier.weight(1f),
- colors = SliderDefaults.colors(
- thumbColor = if (albumArtBitmap != null) Color.White else MaterialTheme.colorScheme.primary,
- activeTrackColor = if (albumArtBitmap != null) Color.White else MaterialTheme.colorScheme.primary,
- inactiveTrackColor = if (albumArtBitmap != null) Color.White.copy(
- alpha = 0.3f
- ) else MaterialTheme.colorScheme.surfaceVariant
- )
- )
- }
- }
- }
- }
- }
- }
-
+ val config = LocalConfiguration.current
+ val isWide = config.orientation == Configuration.ORIENTATION_LANDSCAPE || config.screenWidthDp > 600
+ @Composable
+ fun ExtraKeys() {
// Extra Keys
FlowRow(
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
horizontalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.spacedBy(12.dp),
maxItemsInEachRow = 3
@@ -518,212 +268,242 @@ fun RemoteControlScreen(
) {
Icon(Icons.Default.SpaceBar, "Space", modifier = Modifier.size(18.dp))
}
- // Mouse Toggle
- OutlinedButton(
- onClick = { isMouseMode = !isMouseMode },
- modifier = Modifier.padding(horizontal = 4.dp)
+ }
+ }
+
+ @Composable
+ fun DPad() {
+ // D-Pad and Navigation
+ Box(
+ modifier = Modifier
+ .size(240.dp)
+ .background(MaterialTheme.colorScheme.surfaceContainerLow, CircleShape),
+ contentAlignment = Alignment.Center
+ ) {
+ // Up
+ RemoteButton(
+ onClick = { sendRemoteAction("arrow_up") },
+ icon = Icons.Default.ArrowUpward,
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .padding(top = 16.dp)
+ )
+
+ // Down
+ RemoteButton(
+ onClick = { sendRemoteAction("arrow_down") },
+ icon = Icons.Default.ArrowDownward,
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(bottom = 16.dp)
+ )
+
+ // Left
+ RemoteButton(
+ onClick = { sendRemoteAction("arrow_left") },
+ icon = Icons.AutoMirrored.Filled.ArrowBack,
+ modifier = Modifier
+ .align(Alignment.CenterStart)
+ .padding(start = 16.dp)
+ )
+
+ // Right
+ RemoteButton(
+ onClick = { sendRemoteAction("arrow_right") },
+ icon = Icons.AutoMirrored.Filled.ArrowForward,
+ modifier = Modifier
+ .align(Alignment.CenterEnd)
+ .padding(end = 16.dp)
+ )
+
+ // Center (OK/Enter)
+ FilledTonalIconButton(
+ onClick = { sendRemoteAction("enter") },
+ modifier = Modifier.size(64.dp)
) {
- Icon(
- painter = if (isMouseMode) painterResource(id = R.drawable.rounded_drag_click_24) else painterResource(
- id = R.drawable.rounded_gamepad_circle_up_24
- ),
- contentDescription = if (isMouseMode) "Touchpad Mode" else "Mouse Mode",
- modifier = Modifier.size(20.dp)
- )
+ Icon(Icons.Default.Circle, "Enter", modifier = Modifier.size(24.dp))
}
}
+ }
- if (isMouseMode) {
- // Trackpad Area
- Surface(
- modifier = Modifier
- .fillMaxWidth()
- .heightIn(min = if (isPlayerExpanded) 300.dp else 450.dp, max = 800.dp)
- .pointerInput(isMouseMode) {
- if (!isMouseMode) return@pointerInput
-
- awaitEachGesture {
- val firstDown = awaitFirstDown()
- var totalMoved = 0f
- var totalHapticDistance = 0f
- var lastPosition = firstDown.position
- var isTwoFinger = false
-
- val dragThreshold = 10f
- val hapticInterval = 25f
-
- while (true) {
- val event = awaitPointerEvent()
- val pointers = event.changes
-
- if (pointers.size >= 2) {
- isTwoFinger = true
- val change1 = pointers[0]
- val change2 = pointers[1]
-
- val currentCenter = (change1.position + change2.position) / 2f
- val prevCenter =
- (change1.previousPosition + change2.previousPosition) / 2f
- val scrollDelta = currentCenter - prevCenter
- val scrollDist = scrollDelta.getDistance()
-
- if (scrollDist > 0.5f) {
- totalMoved += scrollDist
- totalHapticDistance += scrollDist
- if (totalHapticDistance > hapticInterval) {
- performLightHaptic()
- totalHapticDistance = 0f
- }
- scrollChannel.trySend(scrollDelta * 2f)
+ @Composable
+ fun Trackpad(modifier: Modifier = Modifier) {
+ // Trackpad Area
+ Surface(
+ modifier = modifier
+ .fillMaxWidth()
+ .pointerInput(Unit) {
+ awaitEachGesture {
+ val firstDown = awaitFirstDown()
+ var totalMoved = 0f
+ var totalHapticDistance = 0f
+ var lastPosition = firstDown.position
+ var isTwoFinger = false
+
+ val dragThreshold = 10f
+ val hapticInterval = 25f
+
+ while (true) {
+ val event = awaitPointerEvent()
+ val pointers = event.changes
+
+ if (pointers.size >= 2) {
+ isTwoFinger = true
+ val change1 = pointers[0]
+ val change2 = pointers[1]
+
+ val currentCenter = (change1.position + change2.position) / 2f
+ val prevCenter =
+ (change1.previousPosition + change2.previousPosition) / 2f
+ val scrollDelta = currentCenter - prevCenter
+ val scrollDist = scrollDelta.getDistance()
+
+ if (scrollDist > 0.5f) {
+ totalMoved += scrollDist
+ totalHapticDistance += scrollDist
+ if (totalHapticDistance > hapticInterval) {
+ performLightHaptic()
+ totalHapticDistance = 0f
}
- pointers.forEach { it.consume() }
- } else if (pointers.size == 1) {
- val change = pointers[0]
- if (change.pressed) {
- val delta = change.position - lastPosition
- val dist = delta.getDistance()
- lastPosition = change.position
-
- if (!isTwoFinger) {
- if (dist > 0.1f) {
- totalMoved += dist
- totalHapticDistance += dist
- if (totalHapticDistance > hapticInterval) {
- performLightHaptic()
- totalHapticDistance = 0f
- }
- moveChannel.trySend(delta * 1.8f)
+ scrollChannel.trySend(scrollDelta * 2f)
+ }
+ pointers.forEach { it.consume() }
+ } else if (pointers.size == 1) {
+ val change = pointers[0]
+ if (change.pressed) {
+ val delta = change.position - lastPosition
+ val dist = delta.getDistance()
+ lastPosition = change.position
+
+ if (!isTwoFinger) {
+ if (dist > 0.1f) {
+ totalMoved += dist
+ totalHapticDistance += dist
+ if (totalHapticDistance > hapticInterval) {
+ performLightHaptic()
+ totalHapticDistance = 0f
}
+ moveChannel.trySend(delta * 1.8f)
}
- change.consume()
- } else {
- // Finger lifted
- if (isTwoFinger) {
- if (totalMoved < 30f) {
- performLightHaptic()
+ }
+ change.consume()
+ } else {
+ // Finger lifted
+ if (isTwoFinger) {
+ if (totalMoved < 30f) {
+ performLightHaptic()
+ sendRemoteAction(
+ "mouse_click",
+ extras = mapOf(
+ "button" to "right",
+ "isDown" to true
+ )
+ )
+ scope.launch {
+ delay(50)
sendRemoteAction(
"mouse_click",
extras = mapOf(
"button" to "right",
- "isDown" to true
+ "isDown" to false
)
)
- scope.launch {
- delay(50)
- sendRemoteAction(
- "mouse_click",
- extras = mapOf(
- "button" to "right",
- "isDown" to false
- )
- )
- }
}
- } else {
- if (totalMoved < dragThreshold) {
- // CLICK
- performLightHaptic()
+ }
+ } else {
+ if (totalMoved < dragThreshold) {
+ // CLICK
+ performLightHaptic()
+ sendRemoteAction(
+ "mouse_click",
+ extras = mapOf(
+ "button" to "left",
+ "isDown" to true
+ )
+ )
+ scope.launch {
+ delay(50)
sendRemoteAction(
"mouse_click",
extras = mapOf(
"button" to "left",
- "isDown" to true
+ "isDown" to false
)
)
- scope.launch {
- delay(50)
- sendRemoteAction(
- "mouse_click",
- extras = mapOf(
- "button" to "left",
- "isDown" to false
- )
- )
- }
}
}
- break
}
- } else {
break
}
+ } else {
+ break
}
}
- },
- shape = RoundedCornerShape(24.dp),
- color = MaterialTheme.colorScheme.surfaceContainerLow,
- border = androidx.compose.foundation.BorderStroke(
- 1.dp,
- MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)
+ }
+ },
+ shape = RoundedCornerShape(24.dp),
+ color = MaterialTheme.colorScheme.surfaceBright,
+ border = androidx.compose.foundation.BorderStroke(
+ 1.dp,
+ MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)
+ )
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ Icon(
+ painter = painterResource(id = R.drawable.rounded_drag_click_24),
+ contentDescription = null,
+ modifier = Modifier
+ .size(48.dp)
+ .alpha(0.1f),
+ tint = MaterialTheme.colorScheme.onSurface
)
- ) {
- Box(contentAlignment = Alignment.Center) {
- Icon(
- painter = painterResource(id = R.drawable.rounded_drag_click_24),
- contentDescription = null,
- modifier = Modifier
- .size(48.dp)
- .alpha(0.1f),
- tint = MaterialTheme.colorScheme.onSurface
- )
- }
}
- } else {
- // D-Pad and Navigation
- Box(
+ }
+ }
+
+ if (isWide) {
+ Row(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.spacedBy(24.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(
modifier = Modifier
- .size(240.dp)
- .background(MaterialTheme.colorScheme.surfaceContainerLow, CircleShape),
- contentAlignment = Alignment.Center
+ .weight(0.4f)
+ .fillMaxHeight(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
) {
- // Up
- RemoteButton(
- onClick = { sendRemoteAction("arrow_up") },
- icon = Icons.Default.ArrowUpward,
- modifier = Modifier
- .align(Alignment.TopCenter)
- .padding(top = 16.dp)
- )
-
- // Down
- RemoteButton(
- onClick = { sendRemoteAction("arrow_down") },
- icon = Icons.Default.ArrowDownward,
- modifier = Modifier
- .align(Alignment.BottomCenter)
- .padding(bottom = 16.dp)
- )
-
- // Left
- RemoteButton(
- onClick = { sendRemoteAction("arrow_left") },
- icon = Icons.Default.ArrowBack,
- modifier = Modifier
- .align(Alignment.CenterStart)
- .padding(start = 16.dp)
- )
-
- // Right
- RemoteButton(
- onClick = { sendRemoteAction("arrow_right") },
- icon = Icons.Default.ArrowForward,
- modifier = Modifier
- .align(Alignment.CenterEnd)
- .padding(end = 16.dp)
- )
-
- // Center (OK/Enter)
- FilledTonalIconButton(
- onClick = { sendRemoteAction("enter") },
- modifier = Modifier.size(64.dp)
- ) {
- Icon(Icons.Default.Circle, "Enter", modifier = Modifier.size(24.dp))
- }
+ ExtraKeys()
+ Spacer(modifier = Modifier.height(24.dp))
+ DPad()
}
+
+ Trackpad(
+ modifier = Modifier
+ .weight(0.6f)
+ .fillMaxHeight()
+ )
}
+ } else {
+ Column(
+ modifier = modifier
+ .fillMaxSize()
+ .padding(horizontal = 16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(24.dp)
+ ) {
+ Trackpad(
+ modifier = Modifier
+ .weight(1f)
+ )
+ DPad()
+ ExtraKeys()
- Spacer(modifier = Modifier.height(80.dp))
+ }
}
}
diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt b/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt
index faaf4163..18ea7509 100644
--- a/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt
+++ b/app/src/main/java/com/sameerasw/airsync/presentation/viewmodel/AirSyncViewModel.kt
@@ -1,6 +1,10 @@
package com.sameerasw.airsync.presentation.viewmodel
+import android.content.BroadcastReceiver
import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.PowerManager
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@@ -70,6 +74,14 @@ class AirSyncViewModel(
private var appContext: Context? = null
+ private val powerSaveReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent?.action == PowerManager.ACTION_POWER_SAVE_MODE_CHANGED) {
+ context?.let { updateBlurState(it) }
+ }
+ }
+ }
+
// Manual connect canceller reference (set in init) for unregistering
private val manualConnectCanceler: () -> Unit = {
// Cancel any active auto-reconnect when user starts manual connection
@@ -86,7 +98,8 @@ class AirSyncViewModel(
isConnected = isConnected,
isConnecting = false,
response = if (isConnected) "Connected successfully!" else "Disconnected",
- activeIp = if (isConnected) WebSocketUtil.currentIpAddress else null
+ activeIp = if (isConnected) WebSocketUtil.currentIpAddress else null,
+ macDeviceStatus = if (isConnected) _uiState.value.macDeviceStatus else null
)
if (isConnected) {
@@ -125,16 +138,42 @@ class AirSyncViewModel(
// No device status notification to update
}
}
+
+ // Observe pitch black theme preference
+ viewModelScope.launch {
+ repository.getPitchBlackThemeEnabled().collect { enabled ->
+ _uiState.value = _uiState.value.copy(isPitchBlackThemeEnabled = enabled)
+ }
+ }
+
+ // Observe sentry reporting preference
+ viewModelScope.launch {
+ repository.getSentryReportingEnabled().collect { enabled ->
+ _uiState.value = _uiState.value.copy(isSentryReportingEnabled = enabled)
+ }
+ }
+
+ // Observe first run preference for onboarding status
+ viewModelScope.launch {
+ repository.getFirstRun().collect { firstRun ->
+ _uiState.value = _uiState.value.copy(isOnboardingCompleted = !firstRun)
+ }
+ }
}
override fun onCleared() {
super.onCleared()
- // Unregister the connection status listener when ViewModel is cleared
+ // Unregister listeners
WebSocketUtil.unregisterConnectionStatusListener(connectionStatusListener)
try {
WebSocketUtil.unregisterManualConnectListener(manualConnectCanceler)
} catch (_: Exception) {
}
+ try {
+ appContext?.unregisterReceiver(powerSaveReceiver)
+ } catch (_: IllegalArgumentException) {
+ // Receiver was not registered
+ }
}
private fun startObservingDeviceChanges(context: Context) {
@@ -218,6 +257,15 @@ class AirSyncViewModel(
repository.getDefaultTab().first()
val isEssentialsConnectionEnabled = repository.getEssentialsConnectionEnabled().first()
val isDeviceDiscoveryEnabled = repository.getDeviceDiscoveryEnabled().first()
+ val isBlurEnabledSetting = repository.getUseBlurEnabled().first()
+ val isPitchBlackThemeEnabled = repository.getPitchBlackThemeEnabled().first()
+ val isSentryReportingEnabled = repository.getSentryReportingEnabled().first()
+ val isFirstRun = repository.getFirstRun().first()
+ val isPowerSaveMode = DeviceInfoUtil.isPowerSaveMode(context)
+ val isBlurProblematic = DeviceInfoUtil.isBlurProblematicDevice()
+
+ // Replicate Essentials logic for initial state
+ val isBlurEnabled = isBlurEnabledSetting && !isPowerSaveMode && !isBlurProblematic
// Rating tracking
repository.getFirstMacConnectionTime().first()
@@ -270,7 +318,13 @@ class AirSyncViewModel(
isMacMediaControlsEnabled = isMacMediaControlsEnabled,
isClipboardHistoryEnabled = isClipboardHistoryEnabled,
isEssentialsConnectionEnabled = isEssentialsConnectionEnabled,
- isDeviceDiscoveryEnabled = isDeviceDiscoveryEnabled
+ isDeviceDiscoveryEnabled = isDeviceDiscoveryEnabled,
+ isBlurSettingEnabled = isBlurEnabledSetting,
+ isPowerSaveMode = isPowerSaveMode,
+ isPitchBlackThemeEnabled = isPitchBlackThemeEnabled,
+ isBlurEnabled = isBlurEnabled,
+ isSentryReportingEnabled = isSentryReportingEnabled,
+ isOnboardingCompleted = !isFirstRun
)
updateRatingPromptDisplay()
@@ -293,6 +347,12 @@ class AirSyncViewModel(
// Start observing device changes for real-time updates
startObservingDeviceChanges(context)
+ // Register power save receiver
+ context.registerReceiver(
+ powerSaveReceiver,
+ IntentFilter(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED)
+ )
+
// Start AirSync Service in scanning mode (which handles UDP Discovery and WakeupService)
com.sameerasw.airsync.service.AirSyncService.startScanning(context)
isNetworkMonitoringActive = true
@@ -504,6 +564,45 @@ class AirSyncViewModel(
}
}
+ fun setUseBlurEnabled(enabled: Boolean, context: Context) {
+ viewModelScope.launch {
+ repository.setUseBlurEnabled(enabled)
+ updateBlurState(context)
+ }
+ }
+
+ private fun updateBlurState(context: Context) {
+ viewModelScope.launch {
+ val isBlurEnabledSetting = repository.getUseBlurEnabled().first()
+ val isPowerSaveMode = DeviceInfoUtil.isPowerSaveMode(context)
+ val isBlurProblematic = DeviceInfoUtil.isBlurProblematicDevice()
+
+ // 1:1 Logic from Essentials: turned off if power saving is on or device is problematic
+ val isBlurEnabled = isBlurEnabledSetting && !isPowerSaveMode && !isBlurProblematic
+
+ _uiState.value = _uiState.value.copy(
+ isBlurSettingEnabled = isBlurEnabledSetting,
+ isPowerSaveMode = isPowerSaveMode,
+ isBlurEnabled = isBlurEnabled
+ )
+ }
+ }
+
+ fun setSentryReportingEnabled(enabled: Boolean) {
+ _uiState.value = _uiState.value.copy(isSentryReportingEnabled = enabled)
+ viewModelScope.launch {
+ repository.setSentryReportingEnabled(enabled)
+ // Note: Changes typically take effect on next launch as Sentry is initialized in Application.onCreate
+ }
+ }
+
+ fun setPitchBlackThemeEnabled(enabled: Boolean) {
+ _uiState.value = _uiState.value.copy(isPitchBlackThemeEnabled = enabled)
+ viewModelScope.launch {
+ repository.setPitchBlackThemeEnabled(enabled)
+ }
+ }
+
fun setDeviceDiscoveryEnabled(context: Context, enabled: Boolean) {
_uiState.value = _uiState.value.copy(isDeviceDiscoveryEnabled = enabled)
viewModelScope.launch {
@@ -927,6 +1026,14 @@ class AirSyncViewModel(
}
}
+ fun setUseBlurEnabled(enabled: Boolean) {
+ val finalEnabled = if (DeviceInfoUtil.isBlurProblematicDevice()) false else enabled
+ _uiState.value = _uiState.value.copy(isBlurEnabled = finalEnabled)
+ viewModelScope.launch {
+ repository.setUseBlurEnabled(enabled)
+ }
+ }
+
fun resetOnboarding() {
viewModelScope.launch {
repository.setFirstRun(true)
@@ -937,4 +1044,11 @@ class AirSyncViewModel(
}
}
+ fun setOnboardingCompleted(completed: Boolean) {
+ _uiState.value = _uiState.value.copy(isOnboardingCompleted = completed)
+ viewModelScope.launch {
+ repository.setFirstRun(!completed)
+ }
+ }
+
}
diff --git a/app/src/main/java/com/sameerasw/airsync/service/AirSyncTileService.kt b/app/src/main/java/com/sameerasw/airsync/service/AirSyncTileService.kt
index a86f3ff5..d5917d94 100644
--- a/app/src/main/java/com/sameerasw/airsync/service/AirSyncTileService.kt
+++ b/app/src/main/java/com/sameerasw/airsync/service/AirSyncTileService.kt
@@ -127,7 +127,7 @@ class AirSyncTileService : TileService() {
manualAttempt = true,
onHandshakeTimeout = {
try {
- val v = getSystemService(VIBRATOR_SERVICE) as android.os.Vibrator
+ val v = getSystemService(android.os.Vibrator::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
v.vibrate(
android.os.VibrationEffect.createOneShot(
@@ -187,12 +187,7 @@ class AirSyncTileService : TileService() {
com.sameerasw.airsync.utils.DeviceIconResolver.getIconRes(lastDevice)
icon = Icon.createWithResource(this@AirSyncTileService, dynamicIcon)
- if (isAuto) {
- // Auto-reconnect in progress or waiting
- state = if (isConnecting) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
- label = "Trying to reconnect"
- subtitle = "Tap to stop"
- } else if (isConnected && lastDevice != null) {
+ if (isConnected && lastDevice != null) {
// Connected state
state = Tile.STATE_ACTIVE
label = lastDevice.name
@@ -207,6 +202,11 @@ class AirSyncTileService : TileService() {
"Connected"
}
} ?: "Connected"
+ } else if (isAuto) {
+ // Auto-reconnect in progress or waiting
+ state = if (isConnecting) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
+ label = "Trying to reconnect"
+ subtitle = "Tap to stop"
} else if (lastDevice != null) {
// Disconnected but has last device
state = Tile.STATE_INACTIVE
diff --git a/app/src/main/java/com/sameerasw/airsync/service/CallReceiver.kt b/app/src/main/java/com/sameerasw/airsync/service/CallReceiver.kt
index b959ebc8..543e415a 100644
--- a/app/src/main/java/com/sameerasw/airsync/service/CallReceiver.kt
+++ b/app/src/main/java/com/sameerasw/airsync/service/CallReceiver.kt
@@ -18,6 +18,7 @@ class CallReceiver : BroadcastReceiver() {
private var savedNumber: String? = null
}
+ @Suppress("DEPRECATION")
override fun onReceive(context: Context, intent: Intent) {
Log.d(TAG, "Broadcast received: ${intent.action}")
diff --git a/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt b/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt
index ba08ec2d..7526353c 100644
--- a/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt
+++ b/app/src/main/java/com/sameerasw/airsync/service/MacMediaPlayerService.kt
@@ -154,11 +154,6 @@ class MacMediaPlayerService : Service() {
try {
if (mediaSession == null) {
mediaSession = MediaSessionCompat(this, "MacMediaPlayer").apply {
- setFlags(
- MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or
- MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS
- )
-
setCallback(object : MediaSessionCompat.Callback() {
override fun onPlay() {
sendMacMediaControl("play")
@@ -312,10 +307,9 @@ class MacMediaPlayerService : Service() {
private fun stopMacMediaSession() {
try {
- mediaSession?.isActive = false
mediaSession?.release()
mediaSession = null
- stopForeground(true)
+ stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
Log.d(TAG, "Mac media session stopped")
} catch (e: Exception) {
diff --git a/app/src/main/java/com/sameerasw/airsync/service/WakeupService.kt b/app/src/main/java/com/sameerasw/airsync/service/WakeupService.kt
index f18a5277..493d6321 100644
--- a/app/src/main/java/com/sameerasw/airsync/service/WakeupService.kt
+++ b/app/src/main/java/com/sameerasw/airsync/service/WakeupService.kt
@@ -147,8 +147,8 @@ class WakeupService : Service() {
var line: String?
while (input.readLine().also { line = it } != null) {
if (line!!.isEmpty()) break // End of headers
- if (line!!.lowercase().startsWith("content-length:")) {
- contentLength = line!!.substring(15).trim().toIntOrNull() ?: 0
+ if (line.lowercase().startsWith("content-length:")) {
+ contentLength = line.substring(15).trim().toIntOrNull() ?: 0
}
}
diff --git a/app/src/main/java/com/sameerasw/airsync/ui/theme/Theme.kt b/app/src/main/java/com/sameerasw/airsync/ui/theme/Theme.kt
index 4e96923b..14e616cf 100644
--- a/app/src/main/java/com/sameerasw/airsync/ui/theme/Theme.kt
+++ b/app/src/main/java/com/sameerasw/airsync/ui/theme/Theme.kt
@@ -35,6 +35,7 @@ private val LightColorScheme = lightColorScheme(
@Composable
fun AirSyncTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
+ pitchBlackTheme: Boolean = false,
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
@@ -42,10 +43,35 @@ fun AirSyncTheme(
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
- if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ val dynamicScheme =
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ if (darkTheme && pitchBlackTheme) {
+ dynamicScheme.copy(
+ background = androidx.compose.ui.graphics.Color.Black,
+ surface = androidx.compose.ui.graphics.Color.Black,
+ surfaceContainer = androidx.compose.ui.graphics.Color.Black,
+ surfaceContainerLowest = androidx.compose.ui.graphics.Color.Black,
+ surfaceContainerLow = androidx.compose.ui.graphics.Color.Black
+ )
+ } else {
+ dynamicScheme
+ }
+ }
+
+ darkTheme -> {
+ if (pitchBlackTheme) {
+ DarkColorScheme.copy(
+ background = androidx.compose.ui.graphics.Color.Black,
+ surface = androidx.compose.ui.graphics.Color.Black,
+ surfaceContainer = androidx.compose.ui.graphics.Color.Black,
+ surfaceContainerLowest = androidx.compose.ui.graphics.Color.Black,
+ surfaceContainerLow = androidx.compose.ui.graphics.Color.Black
+ )
+ } else {
+ DarkColorScheme
+ }
}
- darkTheme -> DarkColorScheme
else -> LightColorScheme
}
diff --git a/app/src/main/java/com/sameerasw/airsync/ui/theme/Type.kt b/app/src/main/java/com/sameerasw/airsync/ui/theme/Type.kt
index 60fd77b0..e31e7156 100644
--- a/app/src/main/java/com/sameerasw/airsync/ui/theme/Type.kt
+++ b/app/src/main/java/com/sameerasw/airsync/ui/theme/Type.kt
@@ -2,33 +2,122 @@ package com.sameerasw.airsync.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
+import com.sameerasw.airsync.R
-// Set of Material typography styles to start with
+// Create a FontFamily backed by the google_sans_flex font in res/font
+val GoogleSansFlex = FontFamily(
+ Font(R.font.google_sans_flex, weight = FontWeight.Normal)
+)
+
+// Set of Material typography styles using GoogleSansFlex throughout
val Typography = Typography(
- bodyLarge = TextStyle(
- fontFamily = FontFamily.Default,
+ displayLarge = TextStyle(
+ fontFamily = GoogleSansFlex,
fontWeight = FontWeight.Normal,
- fontSize = 16.sp,
- lineHeight = 24.sp,
- letterSpacing = 0.5.sp
- )
- /* Other default text styles to override
+ fontSize = 57.sp,
+ lineHeight = 64.sp,
+ letterSpacing = (-0.25).sp
+ ),
+ displayMedium = TextStyle(
+ fontFamily = GoogleSansFlex,
+ fontWeight = FontWeight.Normal,
+ fontSize = 45.sp,
+ lineHeight = 52.sp,
+ letterSpacing = 0.sp
+ ),
+ displaySmall = TextStyle(
+ fontFamily = GoogleSansFlex,
+ fontWeight = FontWeight.Normal,
+ fontSize = 36.sp,
+ lineHeight = 44.sp,
+ letterSpacing = 0.sp
+ ),
+ headlineLarge = TextStyle(
+ fontFamily = GoogleSansFlex,
+ fontWeight = FontWeight.Normal,
+ fontSize = 32.sp,
+ lineHeight = 40.sp,
+ letterSpacing = 0.sp
+ ),
+ headlineMedium = TextStyle(
+ fontFamily = GoogleSansFlex,
+ fontWeight = FontWeight.Normal,
+ fontSize = 28.sp,
+ lineHeight = 36.sp,
+ letterSpacing = 0.sp
+ ),
+ headlineSmall = TextStyle(
+ fontFamily = GoogleSansFlex,
+ fontWeight = FontWeight.Normal,
+ fontSize = 24.sp,
+ lineHeight = 32.sp,
+ letterSpacing = 0.sp
+ ),
titleLarge = TextStyle(
- fontFamily = FontFamily.Default,
+ fontFamily = GoogleSansFlex,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
+ titleMedium = TextStyle(
+ fontFamily = GoogleSansFlex,
+ fontWeight = FontWeight.Medium,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.15.sp
+ ),
+ titleSmall = TextStyle(
+ fontFamily = GoogleSansFlex,
+ fontWeight = FontWeight.Medium,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.1.sp
+ ),
+ bodyLarge = TextStyle(
+ fontFamily = GoogleSansFlex,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ ),
+ bodyMedium = TextStyle(
+ fontFamily = GoogleSansFlex,
+ fontWeight = FontWeight.Normal,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.25.sp
+ ),
+ bodySmall = TextStyle(
+ fontFamily = GoogleSansFlex,
+ fontWeight = FontWeight.Normal,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.4.sp
+ ),
+ labelLarge = TextStyle(
+ fontFamily = GoogleSansFlex,
+ fontWeight = FontWeight.Medium,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ letterSpacing = 0.1.sp
+ ),
+ labelMedium = TextStyle(
+ fontFamily = GoogleSansFlex,
+ fontWeight = FontWeight.Medium,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.5.sp
+ ),
labelSmall = TextStyle(
- fontFamily = FontFamily.Default,
+ fontFamily = GoogleSansFlex,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
- */
)
\ No newline at end of file
diff --git a/app/src/main/java/com/sameerasw/airsync/utils/ContactLookupHelper.kt b/app/src/main/java/com/sameerasw/airsync/utils/ContactLookupHelper.kt
index b3f42dcd..327d5039 100644
--- a/app/src/main/java/com/sameerasw/airsync/utils/ContactLookupHelper.kt
+++ b/app/src/main/java/com/sameerasw/airsync/utils/ContactLookupHelper.kt
@@ -2,6 +2,7 @@ package com.sameerasw.airsync.utils
import android.content.Context
import android.net.Uri
+import android.os.Build
import android.provider.ContactsContract
import android.util.Log
import com.google.i18n.phonenumbers.PhoneNumberUtil
@@ -66,7 +67,13 @@ class ContactLookupHelper(private val context: Context) {
*/
private fun getDeviceCountryCode(): String {
return try {
- context.resources.configuration.locale.country.takeIf { it.isNotEmpty() } ?: "US"
+ val locale = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ context.resources.configuration.locales.get(0)
+ } else {
+ @Suppress("DEPRECATION")
+ context.resources.configuration.locale
+ }
+ locale?.country?.takeIf { it.isNotEmpty() } ?: "US"
} catch (e: Exception) {
"US" // Fallback
}
diff --git a/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt
index 0868ff2e..8aac4a56 100644
--- a/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt
+++ b/app/src/main/java/com/sameerasw/airsync/utils/DeviceInfoUtil.kt
@@ -87,6 +87,7 @@ object DeviceInfoUtil {
@Suppress("DEPRECATION")
val wifiInfo = wifiManager.connectionInfo
+ @Suppress("DEPRECATION")
val ipAddress = wifiInfo.ipAddress
if (ipAddress != 0) {
String.format(
@@ -191,6 +192,13 @@ object DeviceInfoUtil {
)
}
+ fun isBlurProblematicDevice(): Boolean {
+ // Samsung devices on One UI 7 (Android 15) or below have a broken blur implementation
+ // that causes a gray screen overlay. Disable it for them. (╯°□°)╯︵ ┻━┻
+ return Build.MANUFACTURER.equals("samsung", ignoreCase = true) &&
+ Build.VERSION.SDK_INT <= 35 // Android 15
+ }
+
fun getDeviceId(context: Context): String {
return try {
Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID)
@@ -199,4 +207,9 @@ object DeviceInfoUtil {
UUID.randomUUID().toString()
}
}
+
+ fun isPowerSaveMode(context: Context): Boolean {
+ val powerManager = context.getSystemService(Context.POWER_SERVICE) as? android.os.PowerManager
+ return powerManager?.isPowerSaveMode == true
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/sameerasw/airsync/utils/FileReceiver.kt b/app/src/main/java/com/sameerasw/airsync/utils/FileReceiver.kt
index 12c1672f..1096eaa3 100644
--- a/app/src/main/java/com/sameerasw/airsync/utils/FileReceiver.kt
+++ b/app/src/main/java/com/sameerasw/airsync/utils/FileReceiver.kt
@@ -90,8 +90,23 @@ object FileReceiver {
ensureChannel(context)
CoroutineScope(Dispatchers.IO).launch {
try {
+ var finalName = name
+ if (!isClipboard) {
+ var counter = 1
+ val dotIndex = name.lastIndexOf('.')
+ val baseName = if (dotIndex != -1) name.substring(0, dotIndex) else name
+ val extension = if (dotIndex != -1) name.substring(dotIndex) else ""
+
+ var file = java.io.File(android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS), finalName)
+ while (file.exists()) {
+ finalName = "$baseName($counter)$extension"
+ file = java.io.File(android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS), finalName)
+ counter++
+ }
+ }
+
val values = ContentValues().apply {
- put(MediaStore.Downloads.DISPLAY_NAME, name)
+ put(MediaStore.Downloads.DISPLAY_NAME, finalName)
put(MediaStore.Downloads.MIME_TYPE, mime)
put(MediaStore.Downloads.IS_PENDING, 1)
}
@@ -108,7 +123,7 @@ object FileReceiver {
if (uri != null && pfd != null) {
incoming[id] = IncomingFileState(
- name = name,
+ name = finalName,
size = size,
mime = mime,
chunkSize = chunkSize,
diff --git a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt
index 4c01e23d..cfdb4c3c 100644
--- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt
+++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt
@@ -321,6 +321,7 @@ object WebSocketUtil {
onConnectionStatusChanged?.invoke(true)
notifyConnectionStatusListeners(true)
+ cancelAutoReconnect()
try {
AirSyncWidgetProvider.updateAllWidgets(context)
} catch (_: Exception) {
@@ -354,6 +355,11 @@ object WebSocketUtil {
)
} catch (_: Exception) {
}
+ try {
+ com.sameerasw.airsync.service.MacMediaPlayerService.stopMacMedia(context)
+ com.sameerasw.airsync.utils.MacDeviceStatusManager.cleanup(context)
+ } catch (_: Exception) {
+ }
onConnectionStatusChanged?.invoke(false)
notifyConnectionStatusListeners(false)
tryStartAutoReconnect(context)
@@ -386,6 +392,11 @@ object WebSocketUtil {
)
} catch (_: Exception) {
}
+ try {
+ com.sameerasw.airsync.service.MacMediaPlayerService.stopMacMedia(context)
+ com.sameerasw.airsync.utils.MacDeviceStatusManager.cleanup(context)
+ } catch (_: Exception) {
+ }
onConnectionStatusChanged?.invoke(false)
notifyConnectionStatusListeners(false)
tryStartAutoReconnect(context)
diff --git a/app/src/main/res/drawable/airsync_scan.gif b/app/src/main/res/drawable/airsync_scan.gif
new file mode 100644
index 00000000..9201f8c7
Binary files /dev/null and b/app/src/main/res/drawable/airsync_scan.gif differ
diff --git a/app/src/main/res/drawable/app_logo.xml b/app/src/main/res/drawable/app_logo.xml
new file mode 100644
index 00000000..0eef118e
--- /dev/null
+++ b/app/src/main/res/drawable/app_logo.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/avatar.png b/app/src/main/res/drawable/avatar.png
index 6ffd2be9..8ba97965 100644
Binary files a/app/src/main/res/drawable/avatar.png and b/app/src/main/res/drawable/avatar.png differ
diff --git a/app/src/main/res/drawable/ic_branding_avatar.xml b/app/src/main/res/drawable/ic_branding_avatar.xml
new file mode 100644
index 00000000..82ea350b
--- /dev/null
+++ b/app/src/main/res/drawable/ic_branding_avatar.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_branding_avatar_content.xml b/app/src/main/res/drawable/ic_branding_avatar_content.xml
new file mode 100644
index 00000000..79c272fa
--- /dev/null
+++ b/app/src/main/res/drawable/ic_branding_avatar_content.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/rounded_arrow_back_24.xml b/app/src/main/res/drawable/rounded_arrow_back_24.xml
new file mode 100644
index 00000000..f6012a30
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_arrow_back_24.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/rounded_arrow_forward_24.xml b/app/src/main/res/drawable/rounded_arrow_forward_24.xml
new file mode 100644
index 00000000..c72c75d8
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_arrow_forward_24.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/rounded_blur_on_24.xml b/app/src/main/res/drawable/rounded_blur_on_24.xml
new file mode 100644
index 00000000..760ffba9
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_blur_on_24.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/rounded_brightness_5_24.xml b/app/src/main/res/drawable/rounded_brightness_5_24.xml
new file mode 100644
index 00000000..eb982e63
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_brightness_5_24.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/rounded_brightness_7_24.xml b/app/src/main/res/drawable/rounded_brightness_7_24.xml
new file mode 100644
index 00000000..1343d0da
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_brightness_7_24.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/rounded_check_24.xml b/app/src/main/res/drawable/rounded_check_24.xml
new file mode 100644
index 00000000..eb2564df
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_check_24.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/rounded_invert_colors_24.xml b/app/src/main/res/drawable/rounded_invert_colors_24.xml
new file mode 100644
index 00000000..4a137ecc
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_invert_colors_24.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/rounded_laptop_mac_24.xml b/app/src/main/res/drawable/rounded_laptop_mac_24.xml
index 21f8b353..b8c1fd91 100644
--- a/app/src/main/res/drawable/rounded_laptop_mac_24.xml
+++ b/app/src/main/res/drawable/rounded_laptop_mac_24.xml
@@ -1,5 +1,10 @@
-
-
-
-
+
+
diff --git a/app/src/main/res/drawable/rounded_lock_24.xml b/app/src/main/res/drawable/rounded_lock_24.xml
new file mode 100644
index 00000000..8eeaf62b
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_lock_24.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/rounded_mobile_check_24.xml b/app/src/main/res/drawable/rounded_mobile_check_24.xml
new file mode 100644
index 00000000..a1b8d88b
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_mobile_check_24.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/rounded_mobile_vibrate_24.xml b/app/src/main/res/drawable/rounded_mobile_vibrate_24.xml
new file mode 100644
index 00000000..dcbcaf79
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_mobile_vibrate_24.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/rounded_screenshot_monitor_24.xml b/app/src/main/res/drawable/rounded_screenshot_monitor_24.xml
new file mode 100644
index 00000000..b26baa75
--- /dev/null
+++ b/app/src/main/res/drawable/rounded_screenshot_monitor_24.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/font/google_sans_flex.ttf b/app/src/main/res/font/google_sans_flex.ttf
new file mode 100644
index 00000000..83f272d2
Binary files /dev/null and b/app/src/main/res/font/google_sans_flex.ttf differ
diff --git a/app/src/main/res/values-night-v31/colors_app.xml b/app/src/main/res/values-night-v31/colors_app.xml
new file mode 100644
index 00000000..ec76dbc2
--- /dev/null
+++ b/app/src/main/res/values-night-v31/colors_app.xml
@@ -0,0 +1,4 @@
+
+
+ @android:color/system_accent1_700
+
diff --git a/app/src/main/res/values-night-v31/splash.xml b/app/src/main/res/values-night-v31/splash.xml
index 7f561d22..12b0cd44 100644
--- a/app/src/main/res/values-night-v31/splash.xml
+++ b/app/src/main/res/values-night-v31/splash.xml
@@ -9,6 +9,8 @@
- false
- @mipmap/ic_launcher
+
+ - @drawable/ic_branding_avatar
- 3000
diff --git a/app/src/main/res/values-night/colors_app.xml b/app/src/main/res/values-night/colors_app.xml
index 29dd2c6a..b56d03ef 100644
--- a/app/src/main/res/values-night/colors_app.xml
+++ b/app/src/main/res/values-night/colors_app.xml
@@ -2,4 +2,5 @@
@color/widget_background
+ #FFD0BCFF
diff --git a/app/src/main/res/values-night/splash.xml b/app/src/main/res/values-night/splash.xml
index 5de1d6dc..9383dcad 100644
--- a/app/src/main/res/values-night/splash.xml
+++ b/app/src/main/res/values-night/splash.xml
@@ -10,6 +10,8 @@
- @color/app_window_background
- @mipmap/ic_launcher
+
+ - @drawable/ic_branding_avatar
- 3000
diff --git a/app/src/main/res/values-v31/colors_app.xml b/app/src/main/res/values-v31/colors_app.xml
new file mode 100644
index 00000000..0e27cd0f
--- /dev/null
+++ b/app/src/main/res/values-v31/colors_app.xml
@@ -0,0 +1,4 @@
+
+
+ @android:color/system_accent1_100
+
diff --git a/app/src/main/res/values-v31/splash.xml b/app/src/main/res/values-v31/splash.xml
index fde00f22..b990febb 100644
--- a/app/src/main/res/values-v31/splash.xml
+++ b/app/src/main/res/values-v31/splash.xml
@@ -9,6 +9,8 @@
- false
- @mipmap/ic_launcher
+
+ - @drawable/ic_branding_avatar
- 3000
diff --git a/app/src/main/res/values/colors_app.xml b/app/src/main/res/values/colors_app.xml
index 39364802..c9d438cc 100644
--- a/app/src/main/res/values/colors_app.xml
+++ b/app/src/main/res/values/colors_app.xml
@@ -2,4 +2,5 @@
@color/widget_background
+ #FF6650a4
diff --git a/app/src/main/res/values/splash.xml b/app/src/main/res/values/splash.xml
index 20843fbc..a2caf00e 100644
--- a/app/src/main/res/values/splash.xml
+++ b/app/src/main/res/values/splash.xml
@@ -11,6 +11,8 @@
- @color/app_window_background
- @mipmap/ic_launcher
+
+ - @drawable/ic_branding_avatar
- 3000
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7c43eec6..99740bdc 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -26,4 +26,57 @@
Failed to Send Clipboard
Your Mac
Disconnected
+
+
+ AirSync
+ Remote
+ Clipboard
+ Settings
+
+ Help and guides
+ Learn how to use AirSync
+ Lock
+ Screensaver
+ Brightness Up
+ Brightness Down
+
+ App
+ Connection Tile
+ Clipboard Tile
+ Added
+ Add
+ Use Blur
+ Progressive blur for the UI
+ Disabled to save power
+ Disabled due to compatibility issues with this Samsung device
+ Pitch Black Theme
+ Pure black background in dark mode
+ Error Reporting
+ Automatically report crashes when they happen
+
+
+ Welcome to AirSync
+ Continuity With Your Mac
+ by sameerasw.com
+ Let\'s Begin
+ Permissions
+ This app allows you to sync notifications, clipboard, and media controls with your Mac. To provide these features, AirSync requires certain permissions.\n\nYou have full control over which features are enabled and which permissions are granted. We do not collect or store any of your personal data.\n\nAirSync is open source and this app is completly free to use. Always ensure you download it from Play Store or GitHub.
+ If you need any help, feel free to check the guides or reach out to the developer.
+ I Understand
+ From the scan button in app or by long pressing the connection quick settings tile, scan the QR code displayed on the app on MacOS.
+ You can always find help and guides in the app settings.
+ Let Me In Already
+ Preferences
+ Configure some basic settings to get started.
+ App Settings
+ Haptic Feedback
+ Updates
+ Help & Guides
+ How to Connect?
+ Back
+
+
+ Crash reporting
+ Off
+ Auto
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index 952b9306..18318bec 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,6 +1,5 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
- alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
index 20e2a015..ff7583ae 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -20,4 +20,12 @@ kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
-android.nonTransitiveRClass=true
\ No newline at end of file
+android.nonTransitiveRClass=true
+android.defaults.buildfeatures.resvalues=false
+android.sdk.defaultTargetSdkToCompileSdkIfUnset=true
+android.enableAppCompileTimeRClass=true
+android.usesSdkInManifest.disallowed=true
+android.uniquePackageNames=false
+android.dependency.useConstraints=false
+android.r8.strictFullModeForKeepRules=false
+android.r8.optimizedResourceShrinking=true
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index ce30e9e1..da592622 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,6 +1,6 @@
[versions]
-agp = "8.13.2"
-kotlin = "2.0.21"
+agp = "9.0.1"
+kotlin = "2.3.0"
coreKtx = "1.10.1"
junit = "4.13.2"
junitVersion = "1.1.5"
@@ -15,6 +15,8 @@ libphonenumber = "8.13.17"
kotlinxCoroutines = "1.7.3"
okhttp = "4.11.0"
playReview = "2.0.2"
+ksp = "2.3.5"
+sentry = "8.0.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -46,11 +48,13 @@ okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhtt
# Google Play Review
play-review = { group = "com.google.android.play", name = "review", version.ref = "playReview" }
play-review-ktx = { group = "com.google.android.play", name = "review-ktx", version.ref = "playReview" }
+sentry-android = { group = "io.sentry", name = "sentry-android", version.ref = "sentry" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+google-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 103fa3a1..d858fdf8 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
#Mon Jul 28 23:54:01 IST 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists