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