From ac456bd844f620425881fcb6116ce29ef52caa4e Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 5 Sep 2026 17:06:13 +0530 Subject: [PATCH 1/9] feat: add ListExpandToggleButton component and integrate it into ManualConnectionCard --- .../buttons/ListExpandToggleButton.kt | 102 +++++++++++ .../components/cards/ManualConnectionCard.kt | 162 ++++++++---------- .../ui/screens/AirSyncMainScreen.kt | 63 ++++--- .../rounded_keyboard_arrow_down_24.xml | 12 ++ app/src/main/res/values/strings.xml | 9 + 5 files changed, 234 insertions(+), 114 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/airsync/presentation/ui/components/buttons/ListExpandToggleButton.kt create mode 100644 app/src/main/res/drawable/rounded_keyboard_arrow_down_24.xml diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/buttons/ListExpandToggleButton.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/buttons/ListExpandToggleButton.kt new file mode 100644 index 00000000..183ccd20 --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/buttons/ListExpandToggleButton.kt @@ -0,0 +1,102 @@ +package com.sameerasw.airsync.presentation.ui.components.buttons + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +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.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +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.unit.dp +import com.sameerasw.airsync.R +import com.sameerasw.airsync.utils.HapticUtil + +@Composable +fun ListExpandToggleButton( + isExpanded: Boolean, + onToggle: () -> Unit, + modifier: Modifier = Modifier, + title: Any = R.string.action_manual_connection, + description: Any? = null, + expandedText: String? = null, + collapsedText: String? = null, + iconRes: Int? = null, +) { + val haptics = LocalHapticFeedback.current + + val rotationDegree by animateFloatAsState( + targetValue = if (isExpanded) 180f else 0f, + label = "list_expand_chevron_rotation", + ) + + val expText = + expandedText ?: when (title) { + is Int -> stringResource(title) + is String -> title + else -> title.toString() + } + val colText = + collapsedText ?: when (description) { + is Int -> stringResource(description) + is String -> description + null -> expText + else -> description.toString() + } + + Box( + modifier = modifier.fillMaxWidth() + ) { + Button( + onClick = { + HapticUtil.performClick(haptics) + onToggle() + }, + modifier = Modifier.padding(start = 4.dp, top = 4.dp), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surfaceBright, + contentColor = MaterialTheme.colorScheme.primary, + ), + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 14.dp), + ) { + Icon( + painter = + painterResource( + id = iconRes ?: R.drawable.rounded_keyboard_arrow_down_24, + ), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .size(22.dp) + .then( + if (iconRes == null) { + Modifier.graphicsLayer { rotationZ = rotationDegree } + } else { + Modifier + }, + ), + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = if (isExpanded) expText else colText, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + } + } +} diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ManualConnectionCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ManualConnectionCard.kt index 9e1185c2..fc47f6d7 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ManualConnectionCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ManualConnectionCard.kt @@ -1,6 +1,5 @@ package com.sameerasw.airsync.presentation.ui.components.cards -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -18,14 +17,11 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.sameerasw.airsync.R @@ -47,7 +43,6 @@ fun ManualConnectionCard( modifier: Modifier = Modifier ) { val haptics = LocalHapticFeedback.current - var expanded by remember { mutableStateOf(false) } Card( modifier = modifier.fillMaxWidth(), @@ -56,97 +51,80 @@ fun ManualConnectionCard( containerColor = MaterialTheme.colorScheme.surfaceBright ) ) { - Column(modifier = Modifier.fillMaxWidth()) { - IconToggleItem( - iconRes = R.drawable.rounded_devices_24, - title = "Manual Connection", - description = if (expanded) "Hide connection details" else "Enter connection details manually", - showToggle = false, - onClick = { - HapticUtil.performLightTick(haptics) - expanded = !expanded - }, - trailingIcon = if (expanded) R.drawable.outline_expand_circle_up_24 else R.drawable.outline_expand_circle_down_24 - ) - - AnimatedVisibility(visible = expanded) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp) + ) { + if (onQrScanClick != null) { + Button( + onClick = { + HapticUtil.performClick(haptics) + onQrScanClick() + }, + modifier = Modifier.fillMaxWidth(), ) { - if (onQrScanClick != null) { - Button( - onClick = { - HapticUtil.performClick(haptics) - onQrScanClick() - }, - modifier = Modifier.fillMaxWidth(), - ) { - Icon( - painter = painterResource(id = R.drawable.rounded_qr_code_scanner_24), - contentDescription = "Scan QR Code", - modifier = Modifier - .size(20.dp) - .padding(end = 8.dp) - ) - Text("Scan QR Code") - } - } - OutlinedTextField( - value = uiState.ipAddress, - onValueChange = onIpChange, - label = { Text("IP Address") }, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + Icon( + painter = painterResource(id = R.drawable.rounded_qr_code_scanner_24), + contentDescription = stringResource(R.string.scan_qr_code), + modifier = Modifier + .size(20.dp) + .padding(end = 8.dp) ) - OutlinedTextField( - value = uiState.port, - onValueChange = onPortChange, - label = { Text("Port") }, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) - ) - OutlinedTextField( - value = uiState.manualPcName, - onValueChange = onPcNameChange, - label = { Text("PC Name (Optional)") }, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - ) - OutlinedTextField( - value = uiState.symmetricKey ?: "", - onValueChange = onSymmetricKeyChange, - label = { Text("Encryption Key") }, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.medium, - ) - Row(verticalAlignment = Alignment.CenterVertically) { - Text("AirSync+", color = MaterialTheme.colorScheme.onSurface) - Spacer(Modifier.weight(1f)) - Switch( - checked = uiState.manualIsPlus, - onCheckedChange = { enabled -> - if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff( - haptics - ) - onIsPlusChange(enabled) - } + Text(stringResource(R.string.scan_qr_code)) + } + } + OutlinedTextField( + value = uiState.ipAddress, + onValueChange = onIpChange, + label = { Text(stringResource(R.string.label_ip_address)) }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + OutlinedTextField( + value = uiState.port, + onValueChange = onPortChange, + label = { Text(stringResource(R.string.label_port)) }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + OutlinedTextField( + value = uiState.manualPcName, + onValueChange = onPcNameChange, + label = { Text(stringResource(R.string.label_pc_name_optional)) }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + ) + OutlinedTextField( + value = uiState.symmetricKey ?: "", + onValueChange = onSymmetricKeyChange, + label = { Text(stringResource(R.string.label_encryption_key)) }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.label_airsync_plus), color = MaterialTheme.colorScheme.onSurface) + Spacer(Modifier.weight(1f)) + Switch( + checked = uiState.manualIsPlus, + onCheckedChange = { enabled -> + if (enabled) HapticUtil.performToggleOn(haptics) else HapticUtil.performToggleOff( + haptics ) + onIsPlusChange(enabled) } - Button( - onClick = { - HapticUtil.performClick(haptics) - onConnect() - }, - modifier = Modifier.fillMaxWidth(), - ) { - Text("Connect") - } - } + ) + } + Button( + onClick = { + HapticUtil.performClick(haptics) + onConnect() + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.connect)) } } } } - 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 75a7ab95..53ddc2eb 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 @@ -103,6 +103,7 @@ import com.sameerasw.airsync.presentation.ui.components.AirSyncFloatingToolbar import com.sameerasw.airsync.presentation.ui.components.FloatingMediaPlayer import com.sameerasw.airsync.presentation.ui.components.RoundedCardContainer import com.sameerasw.airsync.presentation.ui.components.SettingsView +import com.sameerasw.airsync.presentation.ui.components.buttons.ListExpandToggleButton 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 @@ -239,6 +240,7 @@ fun AirSyncMainScreen( var showKeyboard by remember { mutableStateOf(false) } // State for Keyboard Sheet in Remote Tab var showHelpSheet by remember { mutableStateOf(false) } val onDismissHelp = { showHelpSheet = false } + var showManualConnection by remember { mutableStateOf(false) } var loadingHapticsJob by remember { mutableStateOf(null) } // Initial tab navigation logic @@ -1076,34 +1078,51 @@ fun AirSyncMainScreen( } } } + } - AnimatedVisibility( - visible = !uiState.isConnected, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() + AnimatedVisibility( + visible = !uiState.isConnected, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { - Column { - ManualConnectionCard( - isConnected = uiState.isConnected, - lastConnected = uiState.lastConnectedDevice != null, - uiState = uiState, - onIpChange = { viewModel.updateIpAddress(it) }, - onPortChange = { viewModel.updatePort(it) }, - onPcNameChange = { viewModel.updateManualPcName(it) }, - onIsPlusChange = { viewModel.updateManualIsPlus(it) }, - onSymmetricKeyChange = { - viewModel.updateSymmetricKey( - it - ) - }, - onConnect = { viewModel.prepareForManualConnection() }, - onQrScanClick = { launchScanner(context) } - ) + ListExpandToggleButton( + isExpanded = showManualConnection, + onToggle = { showManualConnection = !showManualConnection }, + title = R.string.action_manual_connection, + ) + + AnimatedVisibility( + visible = showManualConnection, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + RoundedCardContainer { + ManualConnectionCard( + isConnected = uiState.isConnected, + lastConnected = uiState.lastConnectedDevice != null, + uiState = uiState, + onIpChange = { viewModel.updateIpAddress(it) }, + onPortChange = { viewModel.updatePort(it) }, + onPcNameChange = { viewModel.updateManualPcName(it) }, + onIsPlusChange = { viewModel.updateManualIsPlus(it) }, + onSymmetricKeyChange = { + viewModel.updateSymmetricKey( + it + ) + }, + onConnect = { viewModel.prepareForManualConnection() }, + onQrScanClick = { launchScanner(context) } + ) + } } } } - Spacer(modifier = Modifier.height(100.dp)) + Spacer(modifier = Modifier.height(140.dp)) } } diff --git a/app/src/main/res/drawable/rounded_keyboard_arrow_down_24.xml b/app/src/main/res/drawable/rounded_keyboard_arrow_down_24.xml new file mode 100644 index 00000000..149abce2 --- /dev/null +++ b/app/src/main/res/drawable/rounded_keyboard_arrow_down_24.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3f3f1da4..e6e58aac 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -127,4 +127,13 @@ Log saved to Downloads Please attach the log from Downloads to the issue Failed to save log file + + Manual connection + Scan QR Code + Connect + IP Address + Port + PC Name (Optional) + Encryption Key + AirSync+ \ No newline at end of file From bcba16b0d6f7ea4c2450924fce522f8a453c6b47 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 5 Sep 2026 17:19:36 +0530 Subject: [PATCH 2/9] refactor: manual connection to settings --- .../ui/components/SettingsView.kt | 39 ++++++++++++++++ .../buttons/ListExpandToggleButton.kt | 2 +- .../ui/screens/AirSyncMainScreen.kt | 45 ------------------- 3 files changed, 40 insertions(+), 46 deletions(-) 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 64835854..9b2acc3a 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 @@ -51,12 +51,14 @@ import androidx.compose.ui.unit.dp import com.sameerasw.airsync.R import com.sameerasw.airsync.domain.model.DeviceInfo import com.sameerasw.airsync.domain.model.UiState +import com.sameerasw.airsync.presentation.ui.components.buttons.ListExpandToggleButton import com.sameerasw.airsync.presentation.ui.components.cards.ClipboardFeaturesCard 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.IconToggleItem +import com.sameerasw.airsync.presentation.ui.components.cards.ManualConnectionCard 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 @@ -109,6 +111,7 @@ fun SettingsView( val haptics = LocalHapticFeedback.current var showAppSelectionSheet by remember { mutableStateOf(false) } var showMediaAppSelectionSheet by remember { mutableStateOf(false) } + var showManualConnection by remember { mutableStateOf(false) } val density = androidx.compose.ui.platform.LocalDensity.current val minHeaderHeight = 200.dp @@ -220,6 +223,42 @@ fun SettingsView( ExpandNetworkingCard(context) } + AnimatedVisibility( + visible = !uiState.isConnected, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ListExpandToggleButton( + isExpanded = showManualConnection, + onToggle = { showManualConnection = !showManualConnection }, + title = R.string.action_manual_connection, + ) + + AnimatedVisibility( + visible = showManualConnection, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + RoundedCardContainer { + ManualConnectionCard( + isConnected = uiState.isConnected, + lastConnected = uiState.lastConnectedDevice != null, + uiState = uiState, + onIpChange = { viewModel.updateIpAddress(it) }, + onPortChange = { viewModel.updatePort(it) }, + onPcNameChange = { viewModel.updateManualPcName(it) }, + onIsPlusChange = { viewModel.updateManualIsPlus(it) }, + onSymmetricKeyChange = { + viewModel.updateSymmetricKey(it) + }, + onConnect = { viewModel.prepareForManualConnection() } + ) + } + } + } + } + // Settings Categories Section SettingsCategory(title = "Settings") { IconToggleItem( diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/buttons/ListExpandToggleButton.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/buttons/ListExpandToggleButton.kt index 183ccd20..3276ba41 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/buttons/ListExpandToggleButton.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/buttons/ListExpandToggleButton.kt @@ -65,7 +65,7 @@ fun ListExpandToggleButton( HapticUtil.performClick(haptics) onToggle() }, - modifier = Modifier.padding(start = 4.dp, top = 4.dp), + modifier = Modifier.padding(start = 4.dp), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.surfaceBright, 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 53ddc2eb..7b251883 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 @@ -103,10 +103,8 @@ import com.sameerasw.airsync.presentation.ui.components.AirSyncFloatingToolbar import com.sameerasw.airsync.presentation.ui.components.FloatingMediaPlayer import com.sameerasw.airsync.presentation.ui.components.RoundedCardContainer import com.sameerasw.airsync.presentation.ui.components.SettingsView -import com.sameerasw.airsync.presentation.ui.components.buttons.ListExpandToggleButton 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.RateAppCard import com.sameerasw.airsync.presentation.ui.components.cards.RemoteFunctionsCard import com.sameerasw.airsync.presentation.ui.components.dialogs.ConnectionDialog @@ -240,7 +238,6 @@ fun AirSyncMainScreen( var showKeyboard by remember { mutableStateOf(false) } // State for Keyboard Sheet in Remote Tab var showHelpSheet by remember { mutableStateOf(false) } val onDismissHelp = { showHelpSheet = false } - var showManualConnection by remember { mutableStateOf(false) } var loadingHapticsJob by remember { mutableStateOf(null) } // Initial tab navigation logic @@ -1080,48 +1077,6 @@ fun AirSyncMainScreen( } } - AnimatedVisibility( - visible = !uiState.isConnected, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - ListExpandToggleButton( - isExpanded = showManualConnection, - onToggle = { showManualConnection = !showManualConnection }, - title = R.string.action_manual_connection, - ) - - AnimatedVisibility( - visible = showManualConnection, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - RoundedCardContainer { - ManualConnectionCard( - isConnected = uiState.isConnected, - lastConnected = uiState.lastConnectedDevice != null, - uiState = uiState, - onIpChange = { viewModel.updateIpAddress(it) }, - onPortChange = { viewModel.updatePort(it) }, - onPcNameChange = { viewModel.updateManualPcName(it) }, - onIsPlusChange = { viewModel.updateManualIsPlus(it) }, - onSymmetricKeyChange = { - viewModel.updateSymmetricKey( - it - ) - }, - onConnect = { viewModel.prepareForManualConnection() }, - onQrScanClick = { launchScanner(context) } - ) - } - } - } - } - Spacer(modifier = Modifier.height(140.dp)) } } From c2720da6558e1b578486183f1a23a3c45435b016 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 5 Sep 2026 17:36:01 +0530 Subject: [PATCH 3/9] refactor: migrate device configuration settings from LastConnectedDeviceCard to botom expandable section --- .../cards/LastConnectedDeviceCard.kt | 32 +-------- .../ui/screens/AirSyncMainScreen.kt | 65 ++++++++++++++++--- .../main/res/drawable/rounded_settings_24.xml | 10 +++ app/src/main/res/values/strings.xml | 2 + 4 files changed, 71 insertions(+), 38 deletions(-) create mode 100644 app/src/main/res/drawable/rounded_settings_24.xml diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt index 2c512408..834b70ee 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/LastConnectedDeviceCard.kt @@ -19,12 +19,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource @@ -32,20 +30,16 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.sameerasw.airsync.R import com.sameerasw.airsync.domain.model.ConnectedDevice -import com.sameerasw.airsync.presentation.ui.components.sheets.ConnectionSettingsBottomSheet import com.sameerasw.airsync.utils.DevicePreviewResolver import com.sameerasw.airsync.utils.HapticUtil @Composable fun LastConnectedDeviceCard( device: ConnectedDevice, - isAutoReconnectEnabled: Boolean, - onToggleAutoReconnect: (Boolean) -> Unit, onQuickConnect: () -> Unit, modifier: Modifier = Modifier ) { val haptics = LocalHapticFeedback.current - var showBottomSheet by remember { mutableStateOf(false) } Card( modifier = modifier.fillMaxWidth(), @@ -77,7 +71,7 @@ fun LastConnectedDeviceCard( contentDescription = "Connected Mac preview", modifier = Modifier.fillMaxWidth(0.45f), contentScale = ContentScale.Fit, - colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(MaterialTheme.colorScheme.primary) + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary) ) } @@ -140,7 +134,7 @@ fun LastConnectedDeviceCard( .requiredHeight(48.dp) ) { Icon( - painter = painterResource(id = com.sameerasw.airsync.R.drawable.rounded_sync_desktop_24), + painter = painterResource(id = R.drawable.rounded_sync_desktop_24), contentDescription = "Quick connect", modifier = Modifier.size(18.dp) ) @@ -148,25 +142,5 @@ fun LastConnectedDeviceCard( Text("Quick Connect") } } - - - IconToggleItem( - iconRes = R.drawable.rounded_compare_arrows_24, - title = stringResource(R.string.bluetooth_settings_card_title), - description = stringResource(R.string.bluetooth_settings_card_desc), - showToggle = false, - onClick = { - HapticUtil.performClick(haptics) - showBottomSheet = true - } - ) - - if (showBottomSheet) { - ConnectionSettingsBottomSheet( - isAutoReconnectEnabled = isAutoReconnectEnabled, - onToggleAutoReconnect = onToggleAutoReconnect, - onDismissRequest = { showBottomSheet = false } - ) - } } } \ No newline at end of file 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 7b251883..72b3975f 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 @@ -88,6 +88,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType 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 androidx.compose.ui.zIndex import androidx.core.net.toUri @@ -98,12 +99,15 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import com.sameerasw.airsync.R +import com.sameerasw.airsync.data.local.DataStoreManager import com.sameerasw.airsync.presentation.ui.activities.QRScannerActivity import com.sameerasw.airsync.presentation.ui.components.AirSyncFloatingToolbar import com.sameerasw.airsync.presentation.ui.components.FloatingMediaPlayer import com.sameerasw.airsync.presentation.ui.components.RoundedCardContainer import com.sameerasw.airsync.presentation.ui.components.SettingsView +import com.sameerasw.airsync.presentation.ui.components.buttons.ListExpandToggleButton import com.sameerasw.airsync.presentation.ui.components.cards.ConnectionStatusCard +import com.sameerasw.airsync.presentation.ui.components.cards.IconToggleItem import com.sameerasw.airsync.presentation.ui.components.cards.LastConnectedDeviceCard import com.sameerasw.airsync.presentation.ui.components.cards.RateAppCard import com.sameerasw.airsync.presentation.ui.components.cards.RemoteFunctionsCard @@ -149,6 +153,9 @@ fun AirSyncMainScreen( onTitleChange: (String) -> Unit = {} ) { val context = LocalContext.current + val dataStoreManager = remember { DataStoreManager.getInstance(context) } + val bleSyncEnabled by dataStoreManager.getBleSyncEnabled().collectAsState(initial = false) + var showConfigureLastDevice by rememberSaveable { mutableStateOf(false) } val viewModel: AirSyncViewModel = androidx.lifecycle.viewmodel.compose.viewModel { AirSyncViewModel.create(context) } @@ -810,7 +817,12 @@ fun AirSyncMainScreen( } } - RoundedCardContainer { + AnimatedVisibility( + visible = !uiState.isConnected, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + RoundedCardContainer { // Nearby Devices (UDP Discovery) val discoveredDevices by viewModel.discoveredDevices.collectAsState() @@ -823,12 +835,6 @@ fun AirSyncMainScreen( uiState.lastConnectedDevice?.let { device -> LastConnectedDeviceCard( device = device, - isAutoReconnectEnabled = uiState.isAutoReconnectEnabled, - onToggleAutoReconnect = { enabled -> - viewModel.setAutoReconnectEnabled( - enabled - ) - }, onQuickConnect = { // Check if we can use network-aware connection first val networkAwareDevice = @@ -883,7 +889,7 @@ fun AirSyncMainScreen( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Available Devices", + text = stringResource(R.string.label_device_discovery), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary ) @@ -1076,8 +1082,49 @@ fun AirSyncMainScreen( } } } + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ListExpandToggleButton( + isExpanded = showConfigureLastDevice, + onToggle = { showConfigureLastDevice = !showConfigureLastDevice }, + title = R.string.action_configure, + iconRes = R.drawable.rounded_settings_24 + ) + + AnimatedVisibility( + visible = showConfigureLastDevice, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + RoundedCardContainer { + IconToggleItem( + iconRes = R.drawable.rounded_sync_desktop_24, + title = stringResource(R.string.setting_auto_reconnect_title), + description = stringResource(R.string.setting_auto_reconnect_desc), + isChecked = uiState.isAutoReconnectEnabled, + onCheckedChange = { enabled -> + viewModel.setAutoReconnectEnabled(enabled) + } + ) + + IconToggleItem( + iconRes = R.drawable.rounded_bluetooth_24, + title = stringResource(R.string.setting_nearby_connection_title), + description = stringResource(R.string.setting_nearby_connection_desc), + isChecked = bleSyncEnabled, + onCheckedChange = { enabled -> + scope.launch { + dataStoreManager.setBleSyncEnabled(enabled) + dataStoreManager.setBleAutoConnectEnabled(enabled) + } + } + ) + } + } + } - Spacer(modifier = Modifier.height(140.dp)) + Spacer(modifier = Modifier.height(200.dp)) } } diff --git a/app/src/main/res/drawable/rounded_settings_24.xml b/app/src/main/res/drawable/rounded_settings_24.xml new file mode 100644 index 00000000..d65a1013 --- /dev/null +++ b/app/src/main/res/drawable/rounded_settings_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e6e58aac..f0c7f762 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -136,4 +136,6 @@ PC Name (Optional) Encryption Key AirSync+ + Configure + Device discovery \ No newline at end of file From baa823d4697dc9191170b4e21dc07d438f7e7667 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 5 Sep 2026 17:54:03 +0530 Subject: [PATCH 4/9] feat: implement liquid ripple effect --- .../airsync/data/local/DataStoreManager.kt | 8 + .../data/repository/AirSyncRepositoryImpl.kt | 8 + .../sameerasw/airsync/domain/model/UiState.kt | 1 + .../domain/repository/AirSyncRepository.kt | 4 + .../ui/components/AboutSection.kt | 31 +++- .../ui/components/SettingsView.kt | 22 ++- .../ui/components/cards/IconToggleItem.kt | 24 ++- .../ui/modifiers/LiquidRippleModifier.kt | 139 ++++++++++++++++++ .../ui/screens/AirSyncMainScreen.kt | 57 ++++++- .../viewmodel/AirSyncViewModel.kt | 16 ++ .../res/drawable/rounded_blur_linear_24.xml | 12 ++ app/src/main/res/values/strings.xml | 1 + 12 files changed, 311 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/sameerasw/airsync/presentation/ui/modifiers/LiquidRippleModifier.kt create mode 100644 app/src/main/res/drawable/rounded_blur_linear_24.xml 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 8ee9920c..a156798b 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 @@ -92,6 +92,7 @@ class DataStoreManager(private val context: Context) { 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 USE_RIPPLE = booleanPreferencesKey("use_ripple") private val PITCH_BLACK_THEME = booleanPreferencesKey("pitch_black_theme") private val QUICK_SHARE_ENABLED = booleanPreferencesKey("quick_share_enabled") private val FILE_ACCESS_ENABLED = booleanPreferencesKey("file_access_enabled") @@ -1060,4 +1061,11 @@ class DataStoreManager(private val context: Context) { fun getBleAutoConnectEnabled(): Flow = context.dataStore.data.map { it[BLE_AUTO_CONNECT_ENABLED] ?: true } + + suspend fun setUseRippleEnabled(enabled: Boolean) { + context.dataStore.edit { it[USE_RIPPLE] = enabled } + } + + fun getUseRippleEnabled(): Flow = + context.dataStore.data.map { it[USE_RIPPLE] ?: true } } 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 ce4df9e9..493d6223 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 @@ -224,6 +224,14 @@ class AirSyncRepositoryImpl( return dataStoreManager.getPitchBlackThemeEnabled() } + override suspend fun setUseRippleEnabled(enabled: Boolean) { + dataStoreManager.setUseRippleEnabled(enabled) + } + + override fun getUseRippleEnabled(): Flow { + return dataStoreManager.getUseRippleEnabled() + } + override suspend fun setDefaultTab(tab: String) { dataStoreManager.setDefaultTab(tab) } 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 9c40b45d..1633cc01 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 @@ -47,6 +47,7 @@ data class UiState( val isPowerSaveMode: Boolean = false, val isPitchBlackThemeEnabled: Boolean = false, val isBlurEnabled: Boolean = true, + val isRippleSettingEnabled: Boolean = true, val isOnboardingCompleted: Boolean = true, val widgetTransparency: Float = 1f, val isQuickShareEnabled: Boolean = false, 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 9fb01cb0..77e63820 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 @@ -100,6 +100,10 @@ interface AirSyncRepository { suspend fun setPitchBlackThemeEnabled(enabled: Boolean) fun getPitchBlackThemeEnabled(): Flow + // Ripple animation settings + suspend fun setUseRippleEnabled(enabled: Boolean) + fun getUseRippleEnabled(): Flow + // Default tab settings suspend fun setDefaultTab(tab: String) fun getDefaultTab(): Flow diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AboutSection.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AboutSection.kt index 280e7c64..349faed0 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AboutSection.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/AboutSection.kt @@ -35,7 +35,10 @@ 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.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource @@ -54,6 +57,7 @@ import com.sameerasw.airsync.R fun AboutSection( modifier: Modifier = Modifier, onAvatarLongClick: () -> Unit = {}, + onAvatarLongClickWithPosition: ((Offset) -> Unit)? = null, 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." @@ -61,6 +65,7 @@ fun AboutSection( val context = LocalContext.current val haptics = LocalHapticFeedback.current var showAppLogo by remember { mutableStateOf(false) } + var avatarCenterOffset by remember { mutableStateOf(Offset.Zero) } val versionName = try { context.packageManager.getPackageInfo(context.packageName, 0).versionName @@ -103,9 +108,20 @@ fun AboutSection( haptics = haptics, modifier = Modifier .size(200.dp) + .onGloballyPositioned { coords -> + val pos = coords.positionInRoot() + val size = coords.size + avatarCenterOffset = Offset( + x = pos.x + (size.width / 2f), + y = pos.y + (size.height / 2f) + ) + } .combinedClickable( onClick = { showAppLogo = false }, - onLongClick = { onAvatarLongClick() } + onLongClick = { + onAvatarLongClick() + onAvatarLongClickWithPosition?.invoke(avatarCenterOffset) + } ), isVisible = true ) @@ -116,11 +132,22 @@ fun AboutSection( contentScale = ContentScale.Crop, modifier = Modifier .size(120.dp) + .onGloballyPositioned { coords -> + val pos = coords.positionInRoot() + val size = coords.size + avatarCenterOffset = Offset( + x = pos.x + (size.width / 2f), + y = pos.y + (size.height / 2f) + ) + } .clip(RoundedCornerShape(32.dp)) .background(MaterialTheme.colorScheme.primary) .combinedClickable( onClick = { showAppLogo = true }, - onLongClick = { onAvatarLongClick() } + onLongClick = { + onAvatarLongClick() + onAvatarLongClickWithPosition?.invoke(avatarCenterOffset) + } ) ) } 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 9b2acc3a..f6b5ca2c 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 @@ -106,7 +106,9 @@ fun SettingsView( onImport: () -> Unit = {}, onResetOnboarding: () -> Unit = {}, onShowHelp: () -> Unit = {}, - onToggleDeveloperMode: () -> Unit = {} + onToggleDeveloperMode: () -> Unit = {}, + onAvatarLongClickWithPosition: ((Offset) -> Unit)? = null, + onRippleToggleEnabledWithPosition: ((Offset) -> Unit)? = null ) { val haptics = LocalHapticFeedback.current var showAppSelectionSheet by remember { mutableStateOf(false) } @@ -431,7 +433,8 @@ fun SettingsView( } AboutSection( - onAvatarLongClick = onToggleDeveloperMode + onAvatarLongClick = onToggleDeveloperMode, + onAvatarLongClickWithPosition = onAvatarLongClickWithPosition ) } else { // Sub-Settings category view @@ -503,9 +506,22 @@ fun SettingsView( enabled = !com.sameerasw.airsync.utils.DeviceInfoUtil.isBlurProblematicDevice() ) + IconToggleItem( + title = stringResource(R.string.label_ripple_animation), + iconRes = R.drawable.rounded_blur_linear_24, + isChecked = uiState.isRippleSettingEnabled, + onCheckedChange = { enabled: Boolean -> + viewModel.setUseRippleEnabled(enabled) + }, + onCheckedChangeWithPosition = { isChecked, pos -> + if (isChecked) { + onRippleToggleEnabledWithPosition?.invoke(pos) + } + } + ) + IconToggleItem( title = stringResource(R.string.label_pitch_black_theme), - description = stringResource(R.string.subtitle_pitch_black_theme), iconRes = R.drawable.rounded_dark_mode_24, isChecked = uiState.isPitchBlackThemeEnabled, onCheckedChange = { enabled: Boolean -> 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 index 904fbce6..e62446f0 100644 --- 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 @@ -16,6 +16,13 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp @@ -30,6 +37,7 @@ fun IconToggleItem( description: String? = null, isChecked: Boolean = false, onCheckedChange: ((Boolean) -> Unit)? = null, + onCheckedChangeWithPosition: ((Boolean, Offset) -> Unit)? = null, enabled: Boolean = true, onDisabledClick: (() -> Unit)? = null, showToggle: Boolean = true, @@ -37,6 +45,7 @@ fun IconToggleItem( trailingIcon: Int? = null ) { val haptics = LocalHapticFeedback.current + var switchCenterOffset by remember { mutableStateOf(Offset.Zero) } Card( modifier = modifier.fillMaxWidth(), @@ -56,7 +65,9 @@ fun IconToggleItem( if (onClick != null) { onClick() } else if (onCheckedChange != null && showToggle) { - onCheckedChange(!isChecked) + val nextState = !isChecked + onCheckedChange(nextState) + onCheckedChangeWithPosition?.invoke(nextState, switchCenterOffset) } } else if (onDisabledClick != null) { HapticUtil.performClick(haptics) @@ -109,9 +120,18 @@ fun IconToggleItem( if (enabled) { HapticUtil.performClick(haptics) onCheckedChange(checked) + onCheckedChangeWithPosition?.invoke(checked, switchCenterOffset) } }, - enabled = enabled + enabled = enabled, + modifier = Modifier.onGloballyPositioned { coords -> + val pos = coords.positionInRoot() + val size = coords.size + switchCenterOffset = Offset( + x = pos.x + (size.width / 2f), + y = pos.y + (size.height / 2f) + ) + } ) } else if (onClick != null && !showToggle) { Icon( diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/modifiers/LiquidRippleModifier.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/modifiers/LiquidRippleModifier.kt new file mode 100644 index 00000000..e798c11c --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/modifiers/LiquidRippleModifier.kt @@ -0,0 +1,139 @@ +package com.sameerasw.airsync.presentation.ui.modifiers + +import android.graphics.RenderEffect +import android.graphics.RuntimeShader +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.isSpecified +import androidx.compose.ui.graphics.asComposeRenderEffect +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import org.intellij.lang.annotations.Language + +@Language("AGSL") +private const val LIQUID_RIPPLE_AGSL = """ + uniform shader inputShader; + uniform float2 uResolution; + uniform float2 uOrigin; + uniform float uTime; + uniform float uAmplitude; + uniform float uFrequency; + uniform float uDecay; + uniform float uSpeed; + + half4 main(float2 fragCoord) { + float2 pos = fragCoord; + float distance = length(pos - uOrigin); + float delay = distance / uSpeed; + float time = max(0.0, uTime - delay); + + float wave1 = uAmplitude * sin(uFrequency * time) * exp(-uDecay * time); + + float subTime = max(0.0, time - 0.22); + float wave2 = (uAmplitude * 0.55) * sin(uFrequency * 1.15 * subTime) * exp(-(uDecay * 0.8) * subTime); + + float totalWave = wave1 + wave2; + float2 n = normalize(pos - uOrigin); + float2 newPos = pos + totalWave * n; + + float highlight = 0.16 * (totalWave / max(1.0, uAmplitude)); + + return inputShader.eval(newPos) + half4(highlight, highlight, highlight, 0.0); + } +""" + +fun Modifier.liquidRipple( + trigger: Int, + origin: Offset, + enabled: Boolean = true, + durationMillis: Int = 3000, + amplitudeDp: Float = 32f, + frequency: Float = 12f, + decay: Float = 4.5f, + speedDp: Float = 1400f, +): Modifier = composed { + if (!enabled) return@composed Modifier + + val density = LocalDensity.current + val animTime = remember { Animatable(0f) } + + LaunchedEffect(trigger) { + if (trigger > 0) { + animTime.snapTo(0f) + animTime.animateTo( + targetValue = durationMillis / 1000f, + animationSpec = tween(durationMillis = durationMillis, easing = LinearEasing) + ) + animTime.snapTo(0f) + } + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Api33LiquidRipple.applyLiquidRipple( + modifier = Modifier, + animTime = animTime, + density = density, + durationMillis = durationMillis, + amplitudeDp = amplitudeDp, + frequency = frequency, + decay = decay, + speedDp = speedDp, + origin = origin, + ) + } else { + Modifier + } +} + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +private object Api33LiquidRipple { + @Composable + fun applyLiquidRipple( + modifier: Modifier, + animTime: Animatable, + density: Density, + durationMillis: Int, + amplitudeDp: Float, + frequency: Float, + decay: Float, + speedDp: Float, + origin: Offset, + ): Modifier { + val shader = remember { RuntimeShader(LIQUID_RIPPLE_AGSL) } + + return modifier.graphicsLayer { + val currentTime = animTime.value + val maxTime = durationMillis / 1000f + if (currentTime > 0f && currentTime < maxTime) { + val densityVal = density.density + val amplitude = amplitudeDp * densityVal + val speed = speedDp * densityVal + + val ox = if (origin != Offset.Zero && origin.isSpecified) origin.x else size.width / 2f + val oy = if (origin != Offset.Zero && origin.isSpecified) origin.y else size.height / 2f + + shader.setFloatUniform("uResolution", size.width, size.height) + shader.setFloatUniform("uOrigin", ox, oy) + shader.setFloatUniform("uTime", currentTime) + shader.setFloatUniform("uAmplitude", amplitude) + shader.setFloatUniform("uFrequency", frequency) + shader.setFloatUniform("uDecay", decay) + shader.setFloatUniform("uSpeed", speed) + + renderEffect = RenderEffect.createRuntimeShaderEffect(shader, "inputShader").asComposeRenderEffect() + } else { + renderEffect = null + } + } + } +} 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 72b3975f..8b944778 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 @@ -84,11 +84,14 @@ import androidx.compose.runtime.snapshotFlow 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.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.onSizeChanged 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.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.core.net.toUri @@ -116,6 +119,7 @@ import com.sameerasw.airsync.presentation.ui.components.sheets.HelpSupportBottom import com.sameerasw.airsync.presentation.ui.composables.WelcomeScreen import com.sameerasw.airsync.presentation.ui.models.AirSyncTab import com.sameerasw.airsync.presentation.ui.modifiers.BlurDirection +import com.sameerasw.airsync.presentation.ui.modifiers.liquidRipple import com.sameerasw.airsync.presentation.ui.modifiers.progressiveBlur import com.sameerasw.airsync.presentation.viewmodel.AirSyncViewModel import com.sameerasw.airsync.utils.ClipboardSyncManager @@ -247,6 +251,21 @@ fun AirSyncMainScreen( val onDismissHelp = { showHelpSheet = false } var loadingHapticsJob by remember { mutableStateOf(null) } + var rippleTrigger by remember { mutableStateOf(0) } + var rippleOrigin by remember { mutableStateOf(Offset.Zero) } + var rootSize by remember { mutableStateOf(IntSize.Zero) } + + var wasConnected by remember { mutableStateOf(uiState.isConnected) } + LaunchedEffect(uiState.isConnected) { + if (uiState.isConnected && !wasConnected) { + val originX = if (rootSize.width > 0) rootSize.width / 2f else 500f + val originY = if (rootSize.height > 0) rootSize.height * 0.25f else 400f + rippleOrigin = Offset(originX, originY) + rippleTrigger++ + } + wasConnected = uiState.isConnected + } + // Initial tab navigation logic LaunchedEffect(Unit) { if (!hasAppliedInitialTab) { @@ -745,10 +764,16 @@ fun AirSyncMainScreen( Box( modifier = Modifier .fillMaxSize() + .onSizeChanged { rootSize = it } ) { HorizontalPager( modifier = modifier .fillMaxSize() + .liquidRipple( + trigger = rippleTrigger, + origin = rippleOrigin, + enabled = uiState.isRippleSettingEnabled + ) .progressiveBlur( blurRadius = if (uiState.isBlurEnabled) 40f else 0f, height = statusBarHeightPx * 1.15f, @@ -1159,7 +1184,15 @@ fun AirSyncMainScreen( createDocLauncher.launch("airsync_settings_${System.currentTimeMillis()}.json") }, onImport = { openDocLauncher.launch(arrayOf("application/json")) }, - onShowHelp = { showHelpSheet = true } + onShowHelp = { showHelpSheet = true }, + onAvatarLongClickWithPosition = { pos -> + rippleOrigin = pos + rippleTrigger++ + }, + onRippleToggleEnabledWithPosition = { pos -> + rippleOrigin = pos + rippleTrigger++ + } ) } } @@ -1208,7 +1241,15 @@ fun AirSyncMainScreen( createDocLauncher.launch("airsync_settings_${System.currentTimeMillis()}.json") }, onImport = { openDocLauncher.launch(arrayOf("application/json")) }, - onShowHelp = { showHelpSheet = true } + onShowHelp = { showHelpSheet = true }, + onAvatarLongClickWithPosition = { pos -> + rippleOrigin = pos + rippleTrigger++ + }, + onRippleToggleEnabledWithPosition = { pos -> + rippleOrigin = pos + rippleTrigger++ + } ) } } @@ -1467,7 +1508,9 @@ private fun SettingsNavHost( onSendMessage: (String) -> Unit, pendingExportJson: (String) -> Unit, onImport: () -> Unit, - onShowHelp: () -> Unit + onShowHelp: () -> Unit, + onAvatarLongClickWithPosition: ((Offset) -> Unit)? = null, + onRippleToggleEnabledWithPosition: ((Offset) -> Unit)? = null ) { var predictiveBackScale by remember { androidx.compose.runtime.mutableFloatStateOf(1f) } var predictiveBackOffset by remember { androidx.compose.runtime.mutableFloatStateOf(0f) } @@ -1513,7 +1556,9 @@ private fun SettingsNavHost( onImport = onImport, onResetOnboarding = { viewModel.resetOnboarding() }, onShowHelp = onShowHelp, - onToggleDeveloperMode = { viewModel.toggleDeveloperModeVisibility() } + onToggleDeveloperMode = { viewModel.toggleDeveloperModeVisibility() }, + onAvatarLongClickWithPosition = onAvatarLongClickWithPosition, + onRippleToggleEnabledWithPosition = onRippleToggleEnabledWithPosition ) // Detail sub-page @@ -1561,7 +1606,9 @@ private fun SettingsNavHost( onImport = onImport, onResetOnboarding = { viewModel.resetOnboarding() }, onShowHelp = onShowHelp, - onToggleDeveloperMode = { viewModel.toggleDeveloperModeVisibility() } + onToggleDeveloperMode = { viewModel.toggleDeveloperModeVisibility() }, + onAvatarLongClickWithPosition = onAvatarLongClickWithPosition, + onRippleToggleEnabledWithPosition = onRippleToggleEnabledWithPosition ) } } 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 97f4af08..a0d301b4 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 @@ -211,6 +211,13 @@ class AirSyncViewModel( } } + // Observe ripple preference + viewModelScope.launch { + repository.getUseRippleEnabled().collect { enabled -> + _uiState.value = _uiState.value.copy(isRippleSettingEnabled = enabled) + } + } + // Observe widget transparency preference @@ -384,6 +391,7 @@ class AirSyncViewModel( val isEssentialsConnectionEnabled = repository.getEssentialsConnectionEnabled().first() val isDeviceDiscoveryEnabled = repository.getDeviceDiscoveryEnabled().first() val isBlurEnabledSetting = repository.getUseBlurEnabled().first() + val isRippleEnabledSetting = repository.getUseRippleEnabled().first() val isPitchBlackThemeEnabled = repository.getPitchBlackThemeEnabled().first() val isFirstRun = repository.getFirstRun().first() val isPowerSaveMode = DeviceInfoUtil.isPowerSaveMode(context) @@ -447,6 +455,7 @@ class AirSyncViewModel( isEssentialsConnectionEnabled = isEssentialsConnectionEnabled, isDeviceDiscoveryEnabled = isDeviceDiscoveryEnabled, isBlurSettingEnabled = isBlurEnabledSetting, + isRippleSettingEnabled = isRippleEnabledSetting, isPowerSaveMode = isPowerSaveMode, isPitchBlackThemeEnabled = isPitchBlackThemeEnabled, isBlurEnabled = isBlurEnabled, @@ -1196,6 +1205,13 @@ class AirSyncViewModel( } } + fun setUseRippleEnabled(enabled: Boolean) { + _uiState.value = _uiState.value.copy(isRippleSettingEnabled = enabled) + viewModelScope.launch { + repository.setUseRippleEnabled(enabled) + } + } + fun resetOnboarding() { viewModelScope.launch { repository.setFirstRun(true) diff --git a/app/src/main/res/drawable/rounded_blur_linear_24.xml b/app/src/main/res/drawable/rounded_blur_linear_24.xml new file mode 100644 index 00000000..22cf3cc5 --- /dev/null +++ b/app/src/main/res/drawable/rounded_blur_linear_24.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f0c7f762..88e0238d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -138,4 +138,5 @@ AirSync+ Configure Device discovery + Ripple animation \ No newline at end of file From 54b266adc49551e241f5bf81ed7511f4e9d2afca Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 5 Sep 2026 18:00:07 +0530 Subject: [PATCH 5/9] feat: implement liquid ripple effect on FloatingMediaPlayer --- .../ui/components/FloatingMediaPlayer.kt | 68 +++++++++++++++++-- .../ui/screens/AirSyncMainScreen.kt | 6 +- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/FloatingMediaPlayer.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/FloatingMediaPlayer.kt index 6ed00a72..45811cc9 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/FloatingMediaPlayer.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/FloatingMediaPlayer.kt @@ -57,9 +57,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback @@ -67,6 +71,7 @@ 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 +import com.sameerasw.airsync.presentation.ui.modifiers.liquidRipple import com.sameerasw.airsync.utils.HapticUtil import kotlinx.coroutines.launch @@ -82,6 +87,7 @@ fun FloatingMediaPlayer( onVolumeChange: (Float) -> Unit, onToggleMute: () -> Unit, onMediaAction: (String) -> Unit, + isRippleEnabled: Boolean = true, modifier: Modifier = Modifier ) { val density = LocalDensity.current @@ -137,13 +143,35 @@ fun FloatingMediaPlayer( (currentOffset / -(expandedPx - collapsedPx)).coerceIn(0f, 1f) } + var playerRippleTrigger by remember { mutableStateOf(0) } + var playerRippleOrigin by remember { mutableStateOf(Offset.Zero) } + var cardPositionInRoot by remember { mutableStateOf(Offset.Zero) } + var collapsedPlayButtonCenter by remember { mutableStateOf(Offset.Zero) } + var expandedPlayButtonCenter by remember { mutableStateOf(Offset.Zero) } + + val cardShape = RoundedCornerShape(lerp(64f, 24f, progress).dp) + Card( modifier = modifier .fillMaxWidth() .padding(horizontal = 16.dp) .height(collapsedHeight + (expandedHeight - collapsedHeight) * progress) - .anchoredDraggable(anchoredDraggableState, Orientation.Vertical), - shape = RoundedCornerShape(lerp(64f, 24f, progress).dp), + .anchoredDraggable(anchoredDraggableState, Orientation.Vertical) + .onGloballyPositioned { coords -> + cardPositionInRoot = coords.positionInRoot() + } + .clip(cardShape) + .liquidRipple( + trigger = playerRippleTrigger, + origin = playerRippleOrigin, + enabled = isRippleEnabled, + durationMillis = 2000, + amplitudeDp = 22f, + frequency = 14f, + decay = 5f, + speedDp = 1200f + ), + shape = cardShape, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerHighest ) @@ -218,7 +246,23 @@ fun FloatingMediaPlayer( // Play/Pause Button FilledIconButton( - onClick = { onMediaAction("media_play_pause") }, + onClick = { + playerRippleOrigin = collapsedPlayButtonCenter + playerRippleTrigger++ + onMediaAction("media_play_pause") + }, + modifier = Modifier.onGloballyPositioned { coords -> + val buttonRootPos = coords.positionInRoot() + val size = coords.size + val centerInRoot = Offset( + buttonRootPos.x + size.width / 2f, + buttonRootPos.y + size.height / 2f + ) + collapsedPlayButtonCenter = Offset( + centerInRoot.x - cardPositionInRoot.x, + centerInRoot.y - cardPositionInRoot.y + ) + } ) { Icon( imageVector = if (musicInfo?.isPlaying == true) Icons.Rounded.Pause else Icons.Rounded.PlayArrow, @@ -349,10 +393,26 @@ fun FloatingMediaPlayer( } FilledIconButton( - onClick = { onMediaAction("media_play_pause") }, + onClick = { + playerRippleOrigin = expandedPlayButtonCenter + playerRippleTrigger++ + onMediaAction("media_play_pause") + }, modifier = Modifier .weight(1.5f) .fillMaxHeight() + .onGloballyPositioned { coords -> + val buttonRootPos = coords.positionInRoot() + val size = coords.size + val centerInRoot = Offset( + buttonRootPos.x + size.width / 2f, + buttonRootPos.y + size.height / 2f + ) + expandedPlayButtonCenter = Offset( + centerInRoot.x - cardPositionInRoot.x, + centerInRoot.y - cardPositionInRoot.y + ) + } ) { Icon( imageVector = if (musicInfo?.isPlaying == true) Icons.Rounded.Pause else Icons.Rounded.PlayArrow, 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 8b944778..ab26c3c7 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 @@ -1297,7 +1297,8 @@ fun AirSyncMainScreen( sendRemoteAction("vol_mute") isMuted = !isMuted }, - onMediaAction = { sendRemoteAction(it) } + onMediaAction = { sendRemoteAction(it) }, + isRippleEnabled = uiState.isRippleSettingEnabled ) } @@ -1359,7 +1360,8 @@ fun AirSyncMainScreen( sendRemoteAction("vol_mute") isMuted = !isMuted }, - onMediaAction = { sendRemoteAction(it) } + onMediaAction = { sendRemoteAction(it) }, + isRippleEnabled = uiState.isRippleSettingEnabled ) } From 0b0dae8baaf83ed8e6c5aa1c30dea800fdf20d55 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 5 Sep 2026 18:04:15 +0530 Subject: [PATCH 6/9] feat: persist developer mode --- .../airsync/data/local/DataStoreManager.kt | 13 +++++++++++++ .../data/repository/AirSyncRepositoryImpl.kt | 8 ++++++++ .../airsync/domain/repository/AirSyncRepository.kt | 3 +++ .../presentation/ui/components/SettingsView.kt | 12 ++++++------ .../presentation/viewmodel/AirSyncViewModel.kt | 13 ++++++++++++- 5 files changed, 42 insertions(+), 7 deletions(-) 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 a156798b..17ad68e8 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 @@ -46,6 +46,7 @@ class DataStoreManager(private val context: Context) { private val MAC_WIDGET_REFRESH_AT = longPreferencesKey("mac_widget_refresh_at") private val NOTIFICATION_SYNC_ENABLED = booleanPreferencesKey("notification_sync_enabled") private val DEVELOPER_MODE = booleanPreferencesKey("developer_mode") + private val DEVELOPER_MODE_VISIBLE = booleanPreferencesKey("developer_mode_visible") private val CLIPBOARD_SYNC_ENABLED = booleanPreferencesKey("clipboard_sync_enabled") private val CLIPBOARD_HISTORY_ENABLED = booleanPreferencesKey("clipboard_history_enabled") private val ICON_SYNC_COUNT = stringPreferencesKey("icon_sync_count") @@ -607,6 +608,18 @@ class DataStoreManager(private val context: Context) { } } + suspend fun setDeveloperModeVisible(visible: Boolean) { + context.dataStore.edit { preferences -> + preferences[DEVELOPER_MODE_VISIBLE] = visible + } + } + + fun getDeveloperModeVisible(): Flow { + return context.dataStore.data.map { preferences -> + preferences[DEVELOPER_MODE_VISIBLE] == true // Default to hidden + } + } + suspend fun setUserManuallyDisconnected(disconnected: Boolean) { context.dataStore.edit { preferences -> preferences[USER_MANUALLY_DISCONNECTED] = disconnected 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 493d6223..e6edabcd 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 @@ -67,6 +67,14 @@ class AirSyncRepositoryImpl( return dataStoreManager.getDeveloperMode() } + override suspend fun setDeveloperModeVisible(visible: Boolean) { + dataStoreManager.setDeveloperModeVisible(visible) + } + + override fun getDeveloperModeVisible(): Flow { + return dataStoreManager.getDeveloperModeVisible() + } + override suspend fun saveLastConnectedDevice(device: ConnectedDevice) { dataStoreManager.saveLastConnectedDevice(device) } 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 77e63820..e5747c20 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 @@ -27,6 +27,9 @@ interface AirSyncRepository { suspend fun setDeveloperMode(enabled: Boolean) fun getDeveloperMode(): Flow + suspend fun setDeveloperModeVisible(visible: Boolean) + fun getDeveloperModeVisible(): Flow + suspend fun saveLastConnectedDevice(device: ConnectedDevice) fun getLastConnectedDevice(): Flow 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 f6b5ca2c..1ac00b6d 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 @@ -330,6 +330,11 @@ fun SettingsView( ) } + AboutSection( + onAvatarLongClick = onToggleDeveloperMode, + onAvatarLongClickWithPosition = onAvatarLongClickWithPosition + ) + // Advanced / Developer Section AnimatedVisibility( visible = uiState.isDeveloperModeVisible, @@ -431,11 +436,6 @@ fun SettingsView( ) } } - - AboutSection( - onAvatarLongClick = onToggleDeveloperMode, - onAvatarLongClickWithPosition = onAvatarLongClickWithPosition - ) } else { // Sub-Settings category view Column( @@ -682,7 +682,7 @@ fun SettingsView( } } - Spacer(modifier = Modifier.height(180.dp)) + Spacer(modifier = Modifier.height(220.dp)) } if (showAppSelectionSheet) { 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 a0d301b4..d4dfe54f 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 @@ -218,6 +218,13 @@ class AirSyncViewModel( } } + + viewModelScope.launch { + repository.getDeveloperModeVisible().collect { visible -> + _uiState.value = _uiState.value.copy(isDeveloperModeVisible = visible) + } + } + // Observe widget transparency preference @@ -675,9 +682,13 @@ class AirSyncViewModel( } fun toggleDeveloperModeVisibility() { + val newVisibility = !_uiState.value.isDeveloperModeVisible _uiState.value = _uiState.value.copy( - isDeveloperModeVisible = !_uiState.value.isDeveloperModeVisible + isDeveloperModeVisible = newVisibility ) + viewModelScope.launch { + repository.setDeveloperModeVisible(newVisibility) + } } fun setClipboardSyncEnabled(enabled: Boolean) { From e1283fd210ddb93c309edfef1fda5221fc9bea9a Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 5 Sep 2026 18:15:58 +0530 Subject: [PATCH 7/9] fix: centralize foreground promotion logic into promoteToForeground and add service running checks to lifecycle triggers --- .../airsync/service/AirSyncService.kt | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt b/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt index f6d6afd0..50282e95 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt @@ -82,6 +82,8 @@ class AirSyncService : Service() { updateNotification() } } + + promoteToForeground() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -176,35 +178,36 @@ class AirSyncService : Service() { } } - private fun handleAppForeground() { - if (isScanning) { - Log.d(TAG, "App in foreground, switching to ACTIVE discovery") - DiscoveryOrchestrator.setDiscoveryMode(this, DiscoveryMode.ACTIVE) + private fun promoteToForeground() { + try { + val notification = buildNotification() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { startForeground( NOTIFICATION_ID, - buildNotification(), + notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE ) } else { - startForeground(NOTIFICATION_ID, buildNotification()) + startForeground(NOTIFICATION_ID, notification) } + } catch (e: Exception) { + Log.e(TAG, "Error promoting to foreground", e) + } + } + + private fun handleAppForeground() { + promoteToForeground() + if (isScanning) { + Log.d(TAG, "App in foreground, switching to ACTIVE discovery") + DiscoveryOrchestrator.setDiscoveryMode(this, DiscoveryMode.ACTIVE) } } private fun handleAppBackground() { + promoteToForeground() if (isScanning) { Log.d(TAG, "App in background, switching to PASSIVE discovery") DiscoveryOrchestrator.setDiscoveryMode(this, DiscoveryMode.PASSIVE) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground( - NOTIFICATION_ID, - buildNotification(), - ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE - ) - } else { - startForeground(NOTIFICATION_ID, buildNotification()) - } } } @@ -429,6 +432,7 @@ class AirSyncService : Service() { } fun notifyAppForeground(context: Context) { + if (!isRunning()) return val intent = Intent(context, AirSyncService::class.java).apply { action = ACTION_APP_FOREGROUND } @@ -436,6 +440,7 @@ class AirSyncService : Service() { } fun notifyAppBackground(context: Context) { + if (!isRunning()) return val intent = Intent(context, AirSyncService::class.java).apply { action = ACTION_APP_BACKGROUND } From a2be7cd72ced59528dd08f64ec217838918308ab Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 5 Sep 2026 18:43:45 +0530 Subject: [PATCH 8/9] feat: Pause AirSync --- .../com/sameerasw/airsync/MainActivity.kt | 17 +++- .../airsync/data/ble/BleConnectionManager.kt | 25 +++-- .../airsync/data/local/DataStoreManager.kt | 13 +++ .../data/repository/AirSyncRepositoryImpl.kt | 8 ++ .../sameerasw/airsync/domain/model/UiState.kt | 1 + .../domain/repository/AirSyncRepository.kt | 4 + .../ui/activities/ClipboardActionActivity.kt | 32 +++++++ .../ui/components/SettingsView.kt | 2 +- .../components/cards/ConnectionStatusCard.kt | 62 +++++++++++- .../ui/screens/AirSyncMainScreen.kt | 95 ++++++++++++------- .../viewmodel/AirSyncViewModel.kt | 32 +++++++ .../airsync/service/AirSyncService.kt | 28 ++++-- .../airsync/service/AirSyncTileService.kt | 29 +++++- .../airsync/service/ClipboardTileService.kt | 37 +++++++- .../sameerasw/airsync/utils/ShortcutUtil.kt | 37 +++++++- .../main/res/drawable/rounded_pause_24.xml | 10 ++ .../res/drawable/rounded_play_arrow_24.xml | 12 +++ app/src/main/res/values/strings.xml | 3 + 18 files changed, 379 insertions(+), 68 deletions(-) create mode 100644 app/src/main/res/drawable/rounded_pause_24.xml create mode 100644 app/src/main/res/drawable/rounded_play_arrow_24.xml diff --git a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt index c8a335fc..8343280c 100644 --- a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt +++ b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt @@ -338,8 +338,10 @@ class MainActivity : ComponentActivity() { // Check if this is a QS tile long-press intent and device is not connected if (intent?.action == "android.service.quicksettings.action.QS_TILE_PREFERENCES") { - if (!WebSocketUtil.isConnected()) { - // Not connected, open QR scanner instead + val ds = DataStoreManager.getInstance(applicationContext) + val isPaused = runBlocking { ds.isAppPaused().first() } + if (!isPaused && !WebSocketUtil.isConnected()) { + // Not connected and not paused, open QR scanner instead val qrScannerIntent = Intent(this, QRScannerActivity::class.java) qrScannerLauncher.launch(qrScannerIntent) return @@ -564,8 +566,10 @@ class MainActivity : ComponentActivity() { // Check if this is a QS tile long-press intent if (intent?.action == "android.service.quicksettings.action.QS_TILE_PREFERENCES") { - // Check if device is connected - if (!WebSocketUtil.isConnected()) { + val ds = DataStoreManager.getInstance(applicationContext) + val isPaused = runBlocking { ds.isAppPaused().first() } + // Check if device is connected and not paused + if (!isPaused && !WebSocketUtil.isConnected()) { // Not connected, open QR scanner val qrScannerIntent = Intent(this, QRScannerActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP @@ -579,8 +583,11 @@ class MainActivity : ComponentActivity() { override fun onResume() { super.onResume() if (PermissionUtil.isLocalNetworkPermissionGranted(this)) { - AdbDiscoveryHolder.initialize(this) val ds = DataStoreManager.getInstance(applicationContext) + val isPaused = runBlocking { ds.isAppPaused().first() } + if (isPaused) return + + AdbDiscoveryHolder.initialize(this) val isDiscoveryEnabled = runBlocking { ds.getDeviceDiscoveryEnabled().first() } diff --git a/app/src/main/java/com/sameerasw/airsync/data/ble/BleConnectionManager.kt b/app/src/main/java/com/sameerasw/airsync/data/ble/BleConnectionManager.kt index 1d256c8a..38a8210b 100644 --- a/app/src/main/java/com/sameerasw/airsync/data/ble/BleConnectionManager.kt +++ b/app/src/main/java/com/sameerasw/airsync/data/ble/BleConnectionManager.kt @@ -45,20 +45,29 @@ class BleConnectionManager(private val context: Context) { combine( dataStoreManager.getBleSyncEnabled(), dataStoreManager.getUserManuallyDisconnected(), + dataStoreManager.isAppPaused(), WebSocketUtil.connectionState - ) { enabled, manuallyDisconnected, wsConnected -> - Triple(enabled, manuallyDisconnected, wsConnected) - }.collectLatest { (enabled, manuallyDisconnected, wsConnected) -> - isBleEnabled = enabled - updateBleState(regularConnectionActive = wsConnected, manuallyDisconnected = manuallyDisconnected) + ) { enabled, manuallyDisconnected, isPaused, wsConnected -> + data class BleInputs(val enabled: Boolean, val manuallyDisconnected: Boolean, val isPaused: Boolean, val wsConnected: Boolean) + BleInputs(enabled, manuallyDisconnected, isPaused, wsConnected) + }.collectLatest { inputs -> + isBleEnabled = inputs.enabled + updateBleState( + regularConnectionActive = inputs.wsConnected, + manuallyDisconnected = inputs.manuallyDisconnected, + isPaused = inputs.isPaused + ) } } } - private fun updateBleState(regularConnectionActive: Boolean, manuallyDisconnected: Boolean) { - if (!isBleEnabled) { - Log.d(TAG, "BLE disabled in settings, stopping/pausing server") + private fun updateBleState(regularConnectionActive: Boolean, manuallyDisconnected: Boolean, isPaused: Boolean = false) { + if (isPaused || !isBleEnabled) { + Log.d(TAG, "BLE paused or disabled in settings, stopping/pausing server") bleServer?.pauseAdvertising() + if (isPaused) { + bleServer?.disconnectAllConnectedDevices() + } return } 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 17ad68e8..77bab27a 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 @@ -52,6 +52,7 @@ class DataStoreManager(private val context: Context) { private val ICON_SYNC_COUNT = stringPreferencesKey("icon_sync_count") private val LAST_ICON_SYNC_DATE = stringPreferencesKey("last_icon_sync_date") private val USER_MANUALLY_DISCONNECTED = booleanPreferencesKey("user_manually_disconnected") + private val APP_PAUSED = booleanPreferencesKey("app_paused") // Auto reconnect toggle private val AUTO_RECONNECT_ENABLED = booleanPreferencesKey("auto_reconnect_enabled") @@ -632,6 +633,18 @@ class DataStoreManager(private val context: Context) { } } + suspend fun setAppPaused(paused: Boolean) { + context.dataStore.edit { preferences -> + preferences[APP_PAUSED] = paused + } + } + + fun isAppPaused(): Flow { + return context.dataStore.data.map { preferences -> + preferences[APP_PAUSED] == true + } + } + suspend fun setWidgetTransparency(alpha: Float) { context.dataStore.edit { preferences -> preferences[WIDGET_TRANSPARENCY] = alpha 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 e6edabcd..2a4d2f3e 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 @@ -208,6 +208,14 @@ class AirSyncRepositoryImpl( return dataStoreManager.getUserManuallyDisconnected() } + override suspend fun setAppPaused(paused: Boolean) { + dataStoreManager.setAppPaused(paused) + } + + override fun isAppPaused(): Flow { + return dataStoreManager.isAppPaused() + } + override suspend fun setMacMediaControlsEnabled(enabled: Boolean) { dataStoreManager.setMacMediaControlsEnabled(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 1633cc01..c81ba294 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 @@ -53,5 +53,6 @@ data class UiState( val isQuickShareEnabled: Boolean = false, val isFileAccessEnabled: Boolean = true, val isNotifyOnCrashEnabled: Boolean = true, + val isAppPaused: Boolean = false, val bleConnectionState: com.sameerasw.airsync.data.ble.BleGattServer.BleConnectionState = com.sameerasw.airsync.data.ble.BleGattServer.BleConnectionState.DISCONNECTED ) \ 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 e5747c20..59b0f904 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 @@ -91,6 +91,10 @@ interface AirSyncRepository { suspend fun setUserManuallyDisconnected(disconnected: Boolean) fun getUserManuallyDisconnected(): Flow + // App paused mode + suspend fun setAppPaused(paused: Boolean) + fun isAppPaused(): Flow + // Mac Media controls suspend fun setMacMediaControlsEnabled(enabled: Boolean) fun getMacMediaControlsEnabled(): 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 75c40592..64c8458b 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 @@ -55,6 +55,8 @@ import com.sameerasw.airsync.utils.ClipboardUtil import com.sameerasw.airsync.utils.ShortcutUtil import com.sameerasw.airsync.utils.WebSocketUtil import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking class ClipboardActionActivity : ComponentActivity() { @@ -154,6 +156,34 @@ private fun ClipboardActionScreen( onFinished() } + ShortcutUtil.DASH_ACTION_PAUSE -> { + val ds = DataStoreManager.getInstance(context) + ds.setAppPaused(true) + ds.setUserManuallyDisconnected(true) + WebSocketUtil.stopAutoReconnect(context) + WebSocketUtil.disconnect(context) + com.sameerasw.airsync.utils.discovery.DiscoveryOrchestrator.stop(context) + com.sameerasw.airsync.service.AirSyncService.stop(context) + ShortcutUtil.refreshShortcuts(context, false) + uiState = ClipboardUiState.Success + delay(1200) + onFinished() + } + + ShortcutUtil.DASH_ACTION_RESUME -> { + val ds = DataStoreManager.getInstance(context) + ds.setAppPaused(false) + ds.setUserManuallyDisconnected(false) + val isDiscovery = runBlocking { ds.getDeviceDiscoveryEnabled().first() } + com.sameerasw.airsync.utils.discovery.DiscoveryOrchestrator.start(context, isDiscovery) + com.sameerasw.airsync.service.AirSyncService.startScanning(context) + WebSocketUtil.requestAutoReconnect(context) + ShortcutUtil.refreshShortcuts(context, false) + uiState = ClipboardUiState.Success + delay(1200) + onFinished() + } + ShortcutUtil.DASH_ACTION_DISCONNECT -> { val ds = DataStoreManager.getInstance(context) ds.setUserManuallyDisconnected(true) @@ -253,6 +283,8 @@ private fun ClipboardActionScreenContent( ShortcutUtil.DASH_ACTION_DISCONNECT -> "Disconnected" ShortcutUtil.DASH_ACTION_RECONNECT -> "Reconnect" ShortcutUtil.DASH_ACTION_REMOTE -> "Opening Remote..." + ShortcutUtil.DASH_ACTION_PAUSE -> "Paused" + ShortcutUtil.DASH_ACTION_RESUME -> "Resumed" else -> connectedDevice?.name ?: stringResource(R.string.your_mac) } Text( 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 1ac00b6d..545123b7 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 @@ -226,7 +226,7 @@ fun SettingsView( } AnimatedVisibility( - visible = !uiState.isConnected, + visible = !uiState.isConnected && !uiState.isAppPaused, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut() ) { diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt index ce8c81d3..20ed1daf 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt @@ -29,6 +29,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +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.ui.res.stringResource +import com.sameerasw.airsync.R import com.sameerasw.airsync.domain.model.ConnectedDevice import com.sameerasw.airsync.domain.model.UiState import com.sameerasw.airsync.presentation.ui.components.AirSyncLoadingAnimation @@ -44,6 +51,8 @@ fun ConnectionStatusCard( connectedDevice: ConnectedDevice? = null, lastConnected: Boolean, uiState: UiState, + isPaused: Boolean = false, + onTogglePause: (() -> Unit)? = null, modifier: Modifier = Modifier ) { val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current @@ -135,6 +144,24 @@ fun ConnectionStatusCard( } } + AnimatedVisibility( + visible = isPaused, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp) + ) { + AirSyncLoadingAnimation( + isPlus = connectedDevice?.isPlus == true, + modifier = Modifier.size(110.dp) + ) + } + } + // 3) Connection status row last Row( modifier = Modifier @@ -143,9 +170,10 @@ fun ConnectionStatusCard( verticalAlignment = Alignment.CenterVertically ) { val statusText = when { + isPaused -> stringResource(R.string.paused) isConnecting -> "Connecting..." isConnected -> "Syncing" - else -> "Disconnected" + else -> stringResource(R.string.disconnected) } if (isConnecting) { @@ -164,10 +192,10 @@ fun ConnectionStatusCard( } } else if (!isConnecting) { Icon( - painter = painterResource(id = com.sameerasw.airsync.R.drawable.rounded_devices_off_24), - contentDescription = "Disconnected", + painter = painterResource(id = if (isPaused) R.drawable.rounded_pause_24 else R.drawable.rounded_devices_off_24), + contentDescription = statusText, modifier = Modifier.padding(end = 8.dp), - tint = MaterialTheme.colorScheme.error + tint = if (isPaused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error ) } @@ -190,7 +218,7 @@ fun ConnectionStatusCard( modifier = Modifier.height(48.dp) ) { Icon( - painter = painterResource(id = com.sameerasw.airsync.R.drawable.rounded_devices_off_24), + painter = painterResource(id = R.drawable.rounded_devices_off_24), contentDescription = null, modifier = Modifier.size(18.dp) ) @@ -201,6 +229,30 @@ fun ConnectionStatusCard( maxLines = 1 ) } + } else if (!isConnecting && onTogglePause != null) { + Button( + onClick = { + HapticUtil.performClick(haptics) + onTogglePause() + }, + colors = ButtonDefaults.buttonColors( + containerColor = if (isPaused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainerHighest, + contentColor = if (isPaused) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSecondaryContainer + ), + modifier = Modifier.height(48.dp) + ) { + Icon( + painter = painterResource(id = if (isPaused) R.drawable.rounded_play_arrow_24 else R.drawable.rounded_pause_24), + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.size(6.dp)) + Text( + text = if (isPaused) stringResource(R.string.resume) else stringResource(R.string.pause), + style = MaterialTheme.typography.labelLarge, + maxLines = 1 + ) + } } } } 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 ab26c3c7..1f690e6e 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 @@ -828,6 +828,10 @@ fun AirSyncMainScreen( connectedDevice = uiState.lastConnectedDevice, lastConnected = uiState.lastConnectedDevice != null, uiState = uiState, + isPaused = uiState.isAppPaused, + onTogglePause = { + viewModel.setAppPaused(context, !uiState.isAppPaused) + } ) // Remote Functions Card (Lock Screen, etc.) @@ -843,7 +847,7 @@ fun AirSyncMainScreen( } AnimatedVisibility( - visible = !uiState.isConnected, + visible = !uiState.isConnected && !uiState.isAppPaused, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut() ) { @@ -1109,42 +1113,48 @@ fun AirSyncMainScreen( } } - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - ListExpandToggleButton( - isExpanded = showConfigureLastDevice, - onToggle = { showConfigureLastDevice = !showConfigureLastDevice }, - title = R.string.action_configure, - iconRes = R.drawable.rounded_settings_24 - ) - - AnimatedVisibility( - visible = showConfigureLastDevice, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - RoundedCardContainer { - IconToggleItem( - iconRes = R.drawable.rounded_sync_desktop_24, - title = stringResource(R.string.setting_auto_reconnect_title), - description = stringResource(R.string.setting_auto_reconnect_desc), - isChecked = uiState.isAutoReconnectEnabled, - onCheckedChange = { enabled -> - viewModel.setAutoReconnectEnabled(enabled) - } - ) + AnimatedVisibility( + visible = !uiState.isAppPaused, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ListExpandToggleButton( + isExpanded = showConfigureLastDevice, + onToggle = { showConfigureLastDevice = !showConfigureLastDevice }, + title = R.string.action_configure, + iconRes = R.drawable.rounded_settings_24 + ) - IconToggleItem( - iconRes = R.drawable.rounded_bluetooth_24, - title = stringResource(R.string.setting_nearby_connection_title), - description = stringResource(R.string.setting_nearby_connection_desc), - isChecked = bleSyncEnabled, - onCheckedChange = { enabled -> - scope.launch { - dataStoreManager.setBleSyncEnabled(enabled) - dataStoreManager.setBleAutoConnectEnabled(enabled) + AnimatedVisibility( + visible = showConfigureLastDevice, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut() + ) { + RoundedCardContainer { + IconToggleItem( + iconRes = R.drawable.rounded_sync_desktop_24, + title = stringResource(R.string.setting_auto_reconnect_title), + description = stringResource(R.string.setting_auto_reconnect_desc), + isChecked = uiState.isAutoReconnectEnabled, + onCheckedChange = { enabled -> + viewModel.setAutoReconnectEnabled(enabled) } - } - ) + ) + + IconToggleItem( + iconRes = R.drawable.rounded_bluetooth_24, + title = stringResource(R.string.setting_nearby_connection_title), + description = stringResource(R.string.setting_nearby_connection_desc), + isChecked = bleSyncEnabled, + onCheckedChange = { enabled -> + scope.launch { + dataStoreManager.setBleSyncEnabled(enabled) + dataStoreManager.setBleAutoConnectEnabled(enabled) + } + } + ) + } } } } @@ -1321,6 +1331,7 @@ fun AirSyncMainScreen( MainFAB( currentTab = tabs.getOrNull(pagerState.currentPage), isConnected = uiState.isConnected, + isAppPaused = uiState.isAppPaused, activeSettingsCategory = activeSettingsCategory, onAction = { action -> when (action) { @@ -1329,6 +1340,7 @@ fun AirSyncMainScreen( "disconnect" -> disconnect() "scan" -> launchScanner(context) "back" -> activeSettingsCategory = null + "resume" -> viewModel.setAppPaused(context, false) } } ) @@ -1384,6 +1396,7 @@ fun AirSyncMainScreen( MainFAB( currentTab = tabs.getOrNull(pagerState.currentPage), isConnected = uiState.isConnected, + isAppPaused = uiState.isAppPaused, activeSettingsCategory = activeSettingsCategory, onAction = { action -> when (action) { @@ -1392,6 +1405,7 @@ fun AirSyncMainScreen( "disconnect" -> disconnect() "scan" -> launchScanner(context) "back" -> activeSettingsCategory = null + "resume" -> viewModel.setAppPaused(context, false) } } ) @@ -1451,6 +1465,7 @@ fun AirSyncMainScreen( private fun MainFAB( currentTab: AirSyncTab?, isConnected: Boolean, + isAppPaused: Boolean, activeSettingsCategory: String?, onAction: (String) -> Unit ) { @@ -1466,7 +1481,13 @@ private fun MainFAB( R.string.tab_remote -> onAction("keyboard") R.string.tab_clipboard -> onAction("clear_history") else -> { - if (isConnected) onAction("disconnect") else onAction("scan") + if (isConnected) { + onAction("disconnect") + } else if (isAppPaused) { + onAction("resume") + } else { + onAction("scan") + } } } } @@ -1487,6 +1508,8 @@ private fun MainFAB( else -> { if (isConnected) { Icon(imageVector = Icons.Filled.LinkOff, contentDescription = "Disconnect") + } else if (isAppPaused) { + Icon(painter = painterResource(R.drawable.rounded_play_arrow_24), contentDescription = stringResource(R.string.resume)) } else { Icon(imageVector = Icons.Filled.QrCodeScanner, contentDescription = "Scan QR") } 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 d4dfe54f..3040c3b7 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 @@ -225,6 +225,12 @@ class AirSyncViewModel( } } + viewModelScope.launch { + repository.isAppPaused().collect { paused -> + _uiState.value = _uiState.value.copy(isAppPaused = paused) + } + } + // Observe widget transparency preference @@ -839,6 +845,32 @@ class AirSyncViewModel( repository.setUserManuallyDisconnected(disconnected) } + fun setAppPaused(context: Context, paused: Boolean) { + viewModelScope.launch { + repository.setAppPaused(paused) + if (paused) { + repository.setUserManuallyDisconnected(true) + WebSocketUtil.stopAutoReconnect(context) + WebSocketUtil.disconnect(context) + com.sameerasw.airsync.utils.discovery.DiscoveryOrchestrator.stop(context) + com.sameerasw.airsync.service.AirSyncService.stop(context) + _uiState.value = _uiState.value.copy( + isConnected = false, + isConnecting = false, + response = "Paused" + ) + ShortcutUtil.refreshShortcuts(context, false) + } else { + repository.setUserManuallyDisconnected(false) + val isDiscovery = repository.getDeviceDiscoveryEnabled().first() + com.sameerasw.airsync.utils.discovery.DiscoveryOrchestrator.start(context, isDiscovery) + com.sameerasw.airsync.service.AirSyncService.startScanning(context) + WebSocketUtil.requestAutoReconnect(context) + ShortcutUtil.refreshShortcuts(context, false) + } + } + } + private fun hasNetworkAwareMappingForLastDevice(): ConnectedDevice? { val ourIp = _deviceInfo.value.localIp val last = _uiState.value.lastConnectedDevice ?: return null diff --git a/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt b/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt index 50282e95..4c67b236 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/AirSyncService.kt @@ -417,18 +417,32 @@ class AirSyncService : Service() { fun isRunning(): Boolean = serviceInstance != null fun startScanning(context: Context) { - val intent = Intent(context, AirSyncService::class.java).apply { - action = ACTION_START_SCANNING + runBlocking { + val dataStoreManager = DataStoreManager.getInstance(context) + if (dataStoreManager.isAppPaused().first()) { + Log.d(TAG, "App is paused, ignoring startScanning") + return@runBlocking + } + val intent = Intent(context, AirSyncService::class.java).apply { + action = ACTION_START_SCANNING + } + startAction(context, intent) } - startAction(context, intent) } fun start(context: Context, deviceName: String?) { - val intent = Intent(context, AirSyncService::class.java).apply { - action = ACTION_START_SYNC - putExtra(EXTRA_DEVICE_NAME, deviceName) + runBlocking { + val dataStoreManager = DataStoreManager.getInstance(context) + if (dataStoreManager.isAppPaused().first()) { + Log.d(TAG, "App is paused, ignoring start") + return@runBlocking + } + val intent = Intent(context, AirSyncService::class.java).apply { + action = ACTION_START_SYNC + putExtra(EXTRA_DEVICE_NAME, deviceName) + } + startAction(context, intent) } - startAction(context, intent) } fun notifyAppForeground(context: Context) { 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 c2830de7..a54f7b11 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/AirSyncTileService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/AirSyncTileService.kt @@ -8,6 +8,7 @@ import android.service.quicksettings.Tile import android.service.quicksettings.TileService import android.util.Log import com.sameerasw.airsync.MainActivity +import com.sameerasw.airsync.R import com.sameerasw.airsync.data.ble.BleGattServer import com.sameerasw.airsync.data.local.DataStoreManager import com.sameerasw.airsync.utils.MacDeviceStatusManager @@ -87,6 +88,26 @@ class AirSyncTileService : TileService() { super.onClick() serviceScope.launch { + val isPaused = dataStoreManager.isAppPaused().first() + if (isPaused) { + val intent = Intent(this@AirSyncTileService, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val pendingIntent = PendingIntent.getActivity( + this@AirSyncTileService, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + startActivityAndCollapse(pendingIntent) + } else { + @Suppress("DEPRECATION") + startActivityAndCollapse(intent) + } + return@launch + } + val isWsConnected = WebSocketUtil.isConnected() val isBleConnected = BleGattServer.isAnyAuthenticated() val isConnected = isWsConnected || isBleConnected @@ -186,11 +207,17 @@ class AirSyncTileService : TileService() { val macStatus = MacDeviceStatusManager.macDeviceStatus.value qsTile?.apply { + val isPaused = dataStoreManager.isAppPaused().first() + val dynamicIcon = com.sameerasw.airsync.utils.DeviceIconResolver.getTileIconRes(lastDevice) icon = Icon.createWithResource(this@AirSyncTileService, dynamicIcon) - if (isConnected && lastDevice != null) { + if (isPaused) { + state = Tile.STATE_INACTIVE + label = "AirSync" + subtitle = getString(R.string.paused) + } else if (isConnected && lastDevice != null) { // Connected state state = Tile.STATE_ACTIVE label = lastDevice.name diff --git a/app/src/main/java/com/sameerasw/airsync/service/ClipboardTileService.kt b/app/src/main/java/com/sameerasw/airsync/service/ClipboardTileService.kt index 6681d08d..69a0ba92 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/ClipboardTileService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/ClipboardTileService.kt @@ -57,6 +57,27 @@ class ClipboardTileService : TileService() { super.onClick() serviceScope.launch { + val dataStoreManager = DataStoreManager.getInstance(this@ClipboardTileService) + val isPaused = dataStoreManager.isAppPaused().first() + if (isPaused) { + val mainIntent = android.content.Intent(this@ClipboardTileService, com.sameerasw.airsync.MainActivity::class.java).apply { + flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val pendingIntent = PendingIntent.getActivity( + this@ClipboardTileService, + 0, + mainIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + startActivityAndCollapse(pendingIntent) + } else { + @Suppress("DEPRECATION") + startActivityAndCollapse(mainIntent) + } + return@launch + } + val isConnected = WebSocketUtil.isConnected() if (isConnected) { try { @@ -103,17 +124,25 @@ class ClipboardTileService : TileService() { serviceScope.launch { try { val dataStoreManager = DataStoreManager.getInstance(this@ClipboardTileService) + val isPaused = dataStoreManager.isAppPaused().first() val connectedDevice = dataStoreManager.getLastConnectedDevice().first() val deviceName = connectedDevice?.name ?: getString(R.string.your_mac) qsTile?.apply { - state = if (isConnected) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE + state = if (isConnected && !isPaused) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - subtitle = if (isConnected) deviceName else getString(R.string.disconnected) + subtitle = when { + isPaused -> getString(R.string.paused) + isConnected -> deviceName + else -> getString(R.string.disconnected) + } } else { - val labelText = - if (isConnected) "Send Clipboard ($deviceName)" else "Not Connected" + val labelText = when { + isPaused -> "Clipboard (Paused)" + isConnected -> "Send Clipboard ($deviceName)" + else -> "Not Connected" + } label = labelText } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/ShortcutUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/ShortcutUtil.kt index 6fcc2400..5b7907f7 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/ShortcutUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/ShortcutUtil.kt @@ -6,7 +6,10 @@ import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat import com.sameerasw.airsync.R +import com.sameerasw.airsync.data.local.DataStoreManager import com.sameerasw.airsync.presentation.ui.activities.ClipboardActionActivity +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking object ShortcutUtil { const val SHORTCUT_ID_SCAN = "shortcut_scan" @@ -15,17 +18,37 @@ object ShortcutUtil { const val SHORTCUT_ID_LOCK = "shortcut_lock" const val SHORTCUT_ID_REMOTE = "shortcut_remote" const val SHORTCUT_ID_DISCONNECT = "shortcut_disconnect" + const val SHORTCUT_ID_PAUSE = "shortcut_pause" + const val SHORTCUT_ID_RESUME = "shortcut_resume" const val DASH_ACTION_RECONNECT = "com.sameerasw.airsync.ACTION_RECONNECT" const val DASH_ACTION_CLIPBOARD = "com.sameerasw.airsync.ACTION_CLIPBOARD" const val DASH_ACTION_LOCK = "com.sameerasw.airsync.ACTION_LOCK" const val DASH_ACTION_REMOTE = "com.sameerasw.airsync.ACTION_REMOTE" const val DASH_ACTION_DISCONNECT = "com.sameerasw.airsync.ACTION_DISCONNECT" + const val DASH_ACTION_PAUSE = "com.sameerasw.airsync.ACTION_PAUSE" + const val DASH_ACTION_RESUME = "com.sameerasw.airsync.ACTION_RESUME" fun refreshShortcuts(context: Context, isConnected: Boolean) { val shortcuts = mutableListOf() + val dataStoreManager = DataStoreManager.getInstance(context) + val isPaused = runBlocking { + dataStoreManager.isAppPaused().first() + } - if (!isConnected) { + if (isPaused) { + // When paused: Replace scan and reconnect shortcuts with Resume + shortcuts.add( + ShortcutInfoCompat.Builder(context, SHORTCUT_ID_RESUME) + .setShortLabel("Resume") + .setLongLabel("Resume AirSync") + .setIcon(IconCompat.createWithResource(context, R.drawable.rounded_play_arrow_24)) + .setIntent(Intent(context, ClipboardActionActivity::class.java).apply { + action = DASH_ACTION_RESUME + }) + .build() + ) + } else if (!isConnected) { // 1. Scan (Only when disconnected) shortcuts.add( ShortcutInfoCompat.Builder(context, SHORTCUT_ID_SCAN) @@ -58,6 +81,18 @@ object ShortcutUtil { }) .build() ) + + // 3. Pause + shortcuts.add( + ShortcutInfoCompat.Builder(context, SHORTCUT_ID_PAUSE) + .setShortLabel("Pause") + .setLongLabel("Pause AirSync") + .setIcon(IconCompat.createWithResource(context, R.drawable.rounded_pause_24)) + .setIntent(Intent(context, ClipboardActionActivity::class.java).apply { + action = DASH_ACTION_PAUSE + }) + .build() + ) } else { // 1. Remote (Direct to Remote tab) // Use MainActivity with a specific action diff --git a/app/src/main/res/drawable/rounded_pause_24.xml b/app/src/main/res/drawable/rounded_pause_24.xml new file mode 100644 index 00000000..1df9ed81 --- /dev/null +++ b/app/src/main/res/drawable/rounded_pause_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/rounded_play_arrow_24.xml b/app/src/main/res/drawable/rounded_play_arrow_24.xml new file mode 100644 index 00000000..139f135c --- /dev/null +++ b/app/src/main/res/drawable/rounded_play_arrow_24.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 88e0238d..25c272e9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -26,6 +26,9 @@ Failed to Send Clipboard Your Mac Disconnected + Paused + Pause + Resume AirSync From 6e7444d077e88b2a78d8e1f9482ddfcf5a8d3808 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Sat, 5 Sep 2026 18:52:42 +0530 Subject: [PATCH 9/9] feat: implement toggle pause for AirSync Tile --- .../com/sameerasw/airsync/MainActivity.kt | 29 +++-- .../components/cards/ConnectionStatusCard.kt | 14 ++- .../airsync/service/AirSyncTileService.kt | 105 +++++++++--------- 3 files changed, 84 insertions(+), 64 deletions(-) diff --git a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt index 8343280c..01ee3e5f 100644 --- a/app/src/main/java/com/sameerasw/airsync/MainActivity.kt +++ b/app/src/main/java/com/sameerasw/airsync/MainActivity.kt @@ -341,10 +341,16 @@ class MainActivity : ComponentActivity() { val ds = DataStoreManager.getInstance(applicationContext) val isPaused = runBlocking { ds.isAppPaused().first() } if (!isPaused && !WebSocketUtil.isConnected()) { - // Not connected and not paused, open QR scanner instead - val qrScannerIntent = Intent(this, QRScannerActivity::class.java) - qrScannerLauncher.launch(qrScannerIntent) - return + // Not connected and not paused: pause the app on QS tile long-press + runBlocking { + ds.setAppPaused(true) + ds.setUserManuallyDisconnected(true) + } + WebSocketUtil.stopAutoReconnect(this) + WebSocketUtil.disconnect(this) + com.sameerasw.airsync.utils.discovery.DiscoveryOrchestrator.stop(this) + com.sameerasw.airsync.service.AirSyncService.stop(this) + ShortcutUtil.refreshShortcuts(this, false) } } @@ -568,14 +574,17 @@ class MainActivity : ComponentActivity() { if (intent?.action == "android.service.quicksettings.action.QS_TILE_PREFERENCES") { val ds = DataStoreManager.getInstance(applicationContext) val isPaused = runBlocking { ds.isAppPaused().first() } - // Check if device is connected and not paused + // Check if device is disconnected and not paused if (!isPaused && !WebSocketUtil.isConnected()) { - // Not connected, open QR scanner - val qrScannerIntent = Intent(this, QRScannerActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + runBlocking { + ds.setAppPaused(true) + ds.setUserManuallyDisconnected(true) } - startActivity(qrScannerIntent) - finish() + WebSocketUtil.stopAutoReconnect(this) + WebSocketUtil.disconnect(this) + com.sameerasw.airsync.utils.discovery.DiscoveryOrchestrator.stop(this) + com.sameerasw.airsync.service.AirSyncService.stop(this) + ShortcutUtil.refreshShortcuts(this, false) } } } diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt index 20ed1daf..bcbbacf7 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/cards/ConnectionStatusCard.kt @@ -2,6 +2,7 @@ package com.sameerasw.airsync.presentation.ui.components.cards import androidx.compose.animation.animateContentSize import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow @@ -58,7 +59,18 @@ fun ConnectionStatusCard( val haptics = androidx.compose.ui.platform.LocalHapticFeedback.current Card( - modifier = modifier.fillMaxWidth(), + modifier = modifier + .fillMaxWidth() + .then( + if (isPaused && onTogglePause != null) { + Modifier.clickable { + HapticUtil.performClick(haptics) + onTogglePause() + } + } else { + Modifier + } + ), shape = MaterialTheme.shapes.extraSmall, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceBright 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 a54f7b11..2de351a0 100644 --- a/app/src/main/java/com/sameerasw/airsync/service/AirSyncTileService.kt +++ b/app/src/main/java/com/sameerasw/airsync/service/AirSyncTileService.kt @@ -11,7 +11,9 @@ import com.sameerasw.airsync.MainActivity import com.sameerasw.airsync.R import com.sameerasw.airsync.data.ble.BleGattServer import com.sameerasw.airsync.data.local.DataStoreManager +import com.sameerasw.airsync.utils.discovery.DiscoveryOrchestrator import com.sameerasw.airsync.utils.MacDeviceStatusManager +import com.sameerasw.airsync.utils.ShortcutUtil import com.sameerasw.airsync.utils.WebSocketUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -90,21 +92,15 @@ class AirSyncTileService : TileService() { serviceScope.launch { val isPaused = dataStoreManager.isAppPaused().first() if (isPaused) { - val intent = Intent(this@AirSyncTileService, MainActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - val pendingIntent = PendingIntent.getActivity( - this@AirSyncTileService, - 0, - intent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - startActivityAndCollapse(pendingIntent) - } else { - @Suppress("DEPRECATION") - startActivityAndCollapse(intent) - } + // Resume AirSync + dataStoreManager.setAppPaused(false) + dataStoreManager.setUserManuallyDisconnected(false) + val isDiscovery = dataStoreManager.getDeviceDiscoveryEnabled().first() + DiscoveryOrchestrator.start(this@AirSyncTileService, isDiscovery) + AirSyncService.startScanning(this@AirSyncTileService) + WebSocketUtil.requestAutoReconnect(this@AirSyncTileService) + ShortcutUtil.refreshShortcuts(this@AirSyncTileService, false) + updateTileState() return@launch } @@ -209,49 +205,52 @@ class AirSyncTileService : TileService() { qsTile?.apply { val isPaused = dataStoreManager.isAppPaused().first() - val dynamicIcon = - com.sameerasw.airsync.utils.DeviceIconResolver.getTileIconRes(lastDevice) - icon = Icon.createWithResource(this@AirSyncTileService, dynamicIcon) - if (isPaused) { + icon = Icon.createWithResource(this@AirSyncTileService, R.drawable.rounded_devices_off_24) state = Tile.STATE_INACTIVE label = "AirSync" subtitle = getString(R.string.paused) - } else if (isConnected && lastDevice != null) { - // Connected state - state = Tile.STATE_ACTIVE - label = lastDevice.name - - // Show battery percent (and Charging) if available; otherwise fallback to "Connected" - subtitle = macStatus?.let { status -> - val level = status.battery.level - if (level >= 0) { - val pct = level.coerceIn(0, 100) - if (status.battery.isCharging) "$pct% Charging" else "$pct%" - } else { - "Connected" - } - } ?: "Connected" - } else if (com.sameerasw.airsync.data.ble.BleGattServer.isAnyAuthenticated() && lastDevice != null) { - // BLE Connected state - state = Tile.STATE_ACTIVE - label = lastDevice.name - subtitle = "Connected BT" - } 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 - label = "Reconnect" - subtitle = lastDevice.name } else { - // No last device - state = Tile.STATE_INACTIVE - label = "AirSync" - subtitle = "Tap to setup" + val dynamicIcon = + com.sameerasw.airsync.utils.DeviceIconResolver.getTileIconRes(lastDevice) + icon = Icon.createWithResource(this@AirSyncTileService, dynamicIcon) + + if (isConnected && lastDevice != null) { + // Connected state + state = Tile.STATE_ACTIVE + label = lastDevice.name + + // Show battery percent (and Charging) if available; otherwise fallback to "Connected" + subtitle = macStatus?.let { status -> + val level = status.battery.level + if (level >= 0) { + val pct = level.coerceIn(0, 100) + if (status.battery.isCharging) "$pct% Charging" else "$pct%" + } else { + "Connected" + } + } ?: "Connected" + } else if (com.sameerasw.airsync.data.ble.BleGattServer.isAnyAuthenticated() && lastDevice != null) { + // BLE Connected state + state = Tile.STATE_ACTIVE + label = lastDevice.name + subtitle = "Connected BT" + } 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 + label = "Reconnect" + subtitle = lastDevice.name + } else { + // No last device + state = Tile.STATE_INACTIVE + label = "AirSync" + subtitle = "Tap to setup" + } } updateTile()