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 f9a2d9e4..5afd5d4b 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,9 @@ class DataStoreManager(private val context: Context) { private val PITCH_BLACK_THEME = booleanPreferencesKey("pitch_black_theme") private val SENTRY_REPORTING_ENABLED = booleanPreferencesKey("sentry_reporting_enabled") + // Widget preferences + private val WIDGET_TRANSPARENCY = androidx.datastore.preferences.core.floatPreferencesKey("widget_transparency") + private const val NETWORK_DEVICES_PREFIX = "network_device_" private const val NETWORK_CONNECTIONS_PREFIX = "network_connections_" @@ -576,6 +579,18 @@ class DataStoreManager(private val context: Context) { } } + suspend fun setWidgetTransparency(alpha: Float) { + context.dataStore.edit { preferences -> + preferences[WIDGET_TRANSPARENCY] = alpha + } + } + + fun getWidgetTransparency(): Flow { + return context.dataStore.data.map { preferences -> + preferences[WIDGET_TRANSPARENCY] ?: 1f + } + } + // Network-aware device connections suspend fun saveNetworkDeviceConnection( deviceName: String, 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 c4504f06..2715377b 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 @@ -232,6 +232,14 @@ class AirSyncRepositoryImpl( return dataStoreManager.getSentryReportingEnabled() } + override suspend fun setWidgetTransparency(alpha: Float) { + dataStoreManager.setWidgetTransparency(alpha) + } + + override fun getWidgetTransparency(): Flow { + return dataStoreManager.getWidgetTransparency() + } + override suspend fun setEssentialsConnectionEnabled(enabled: Boolean) { dataStoreManager.setEssentialsConnectionEnabled(enabled) } diff --git a/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt b/app/src/main/java/com/sameerasw/airsync/domain/model/UiState.kt index cf95e684..2a53f26b 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,5 +47,6 @@ data class UiState( val isPitchBlackThemeEnabled: Boolean = false, val isBlurEnabled: Boolean = true, val isSentryReportingEnabled: Boolean = true, - val isOnboardingCompleted: Boolean = true + val isOnboardingCompleted: Boolean = true, + val widgetTransparency: Float = 1f ) \ 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 31e1a42f..a6385dee 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 @@ -105,6 +105,10 @@ interface AirSyncRepository { suspend fun setSentryReportingEnabled(enabled: Boolean) fun getSentryReportingEnabled(): Flow + // Widget specific settings + suspend fun setWidgetTransparency(alpha: Float) + fun getWidgetTransparency(): Flow + // Essentials Bridge suspend fun setEssentialsConnectionEnabled(enabled: Boolean) fun getEssentialsConnectionEnabled(): 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 c02f6fdb..2cfded78 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 @@ -327,6 +327,18 @@ fun SettingsView( } } + // Widget Section + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SettingsCategoryTitle("Widget") + RoundedCardContainer { + com.sameerasw.airsync.presentation.ui.components.sliders.ConfigSliderItem( + title = "Widget Transparency", + value = uiState.widgetTransparency, + onValueChange = { viewModel.setWidgetTransparency(it) } + ) + } + } + // Connection Section Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { SettingsCategoryTitle("Connection") diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sliders/ConfigSliderItem.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sliders/ConfigSliderItem.kt new file mode 100644 index 00000000..5a8ca661 --- /dev/null +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/components/sliders/ConfigSliderItem.kt @@ -0,0 +1,132 @@ +package com.sameerasw.airsync.presentation.ui.components.sliders + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.ui.platform.LocalHapticFeedback +import com.sameerasw.airsync.R +import com.sameerasw.airsync.utils.HapticUtil +import java.math.BigDecimal +import java.math.RoundingMode + +@Composable +fun ConfigSliderItem( + title: String, + value: Float, + onValueChange: (Float) -> Unit, + modifier: Modifier = Modifier, + valueRange: ClosedFloatingPointRange = 0f..1f, + steps: Int = 0, + increment: Float = 0.1f, + onValueChangeFinished: (() -> Unit)? = null, + enabled: Boolean = true +) { + + val haptics = LocalHapticFeedback.current + Column( + modifier = modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.surfaceBright, + shape = RoundedCornerShape(MaterialTheme.shapes.extraSmall.bottomEnd) + ) + .padding(top = 16.dp, start = 16.dp, end = 16.dp, bottom = 8.dp) + ) { + var sliderValue by remember(value) { mutableFloatStateOf(value) } + val view = LocalView.current + + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = { + val newValue = (BigDecimal.valueOf(sliderValue.toDouble()) + .subtract(BigDecimal.valueOf(increment.toDouble())) + .setScale(2, RoundingMode.HALF_UP)) + .toFloat() + val clamped = newValue.coerceIn(valueRange) + sliderValue = clamped + onValueChange(clamped) + onValueChangeFinished?.invoke() + HapticUtil.performClick(haptics) + }, + modifier = Modifier.padding(end = 4.dp), + enabled = enabled + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_remove_24), + contentDescription = "Decrease", + tint = MaterialTheme.colorScheme.primary + ) + } + + Slider( + value = sliderValue, + onValueChange = { + sliderValue = it + HapticUtil.performLightTick(haptics) + }, + valueRange = valueRange, + steps = steps, + onValueChangeFinished = { + onValueChange(sliderValue) + onValueChangeFinished?.invoke() + }, + modifier = Modifier.weight(1f), + enabled = enabled + ) + + IconButton( + onClick = { + val newValue = (BigDecimal.valueOf(sliderValue.toDouble()) + .add(BigDecimal.valueOf(increment.toDouble())) + .setScale(2, RoundingMode.HALF_UP)) + .toFloat() + val clamped = newValue.coerceIn(valueRange) + sliderValue = clamped + onValueChange(clamped) + onValueChangeFinished?.invoke() + HapticUtil.performClick(haptics) + }, + modifier = Modifier.padding(start = 4.dp), + enabled = enabled + ) { + Icon( + painter = painterResource(id = R.drawable.rounded_add_24), + contentDescription = "Increase", + tint = MaterialTheme.colorScheme.primary + ) + } + } + } +} diff --git a/app/src/main/java/com/sameerasw/airsync/presentation/ui/composables/WelcomeScreen.kt b/app/src/main/java/com/sameerasw/airsync/presentation/ui/composables/WelcomeScreen.kt index 387385e1..8b4c20e4 100644 --- a/app/src/main/java/com/sameerasw/airsync/presentation/ui/composables/WelcomeScreen.kt +++ b/app/src/main/java/com/sameerasw/airsync/presentation/ui/composables/WelcomeScreen.kt @@ -67,8 +67,6 @@ fun WelcomeScreen( onBeginClick: () -> Unit ) { val haptics = LocalHapticFeedback.current - val context = LocalContext.current - val scope = rememberCoroutineScope() val uiState by viewModel.uiState.collectAsState() var currentStep by remember { mutableStateOf(OnboardingStep.WELCOME) } 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 18ea7509..a4094ae6 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 @@ -153,6 +153,13 @@ class AirSyncViewModel( } } + // Observe widget transparency preference + viewModelScope.launch { + repository.getWidgetTransparency().collect { trans -> + _uiState.value = _uiState.value.copy(widgetTransparency = trans) + } + } + // Observe first run preference for onboarding status viewModelScope.launch { repository.getFirstRun().collect { firstRun -> @@ -600,6 +607,17 @@ class AirSyncViewModel( _uiState.value = _uiState.value.copy(isPitchBlackThemeEnabled = enabled) viewModelScope.launch { repository.setPitchBlackThemeEnabled(enabled) + // Note: Currently theme changes check via MainActivity collection instead of restart + } + } + + fun setWidgetTransparency(alpha: Float) { + _uiState.value = _uiState.value.copy(widgetTransparency = alpha) + viewModelScope.launch { + repository.setWidgetTransparency(alpha) + appContext?.let { context -> + com.sameerasw.airsync.widget.AirSyncWidgetProvider.updateAllWidgets(context) + } } } diff --git a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt index cfdb4c3c..1beb006c 100644 --- a/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt +++ b/app/src/main/java/com/sameerasw/airsync/utils/WebSocketUtil.kt @@ -343,6 +343,12 @@ object WebSocketUtil { reason: String ) { if (webSocket == WebSocketUtil.webSocket) { + if (code != 1000) { + CoroutineScope(Dispatchers.Main).launch { + val msg = reason.ifEmpty { "Unknown Server Disconnect" } + android.widget.Toast.makeText(context, "Disconnected: $msg", android.widget.Toast.LENGTH_SHORT).show() + } + } isConnected.set(false) isSocketOpen.set(false) isConnecting.set(false) @@ -377,8 +383,22 @@ object WebSocketUtil { ) { val totalToTry = ipList.size val failedCount = failedAttempts.incrementAndGet() - - if (webSocket == WebSocketUtil.webSocket || (!connectionStarted.get() && failedCount >= totalToTry)) { + val wasActive = webSocket == WebSocketUtil.webSocket + val isFinalManualAttempt = manualAttempt && !connectionStarted.get() && failedCount >= totalToTry + + if (wasActive || isFinalManualAttempt) { + if (manualAttempt || isSocketOpen.get()) { + CoroutineScope(Dispatchers.Main).launch { + val msg = when (t) { + is java.net.ConnectException -> "Connection Refused (Is AirSync Mac running?)" + is java.net.SocketTimeoutException -> "Could not discover your mac" + is java.net.UnknownHostException -> "Could not reach your mac" + is java.io.EOFException, is java.net.SocketException -> "Lost connection to your mac" + else -> t.message ?: "Unknown connection error" + } + android.widget.Toast.makeText(context, "AirSync: $msg", android.widget.Toast.LENGTH_LONG).show() + } + } isConnected.set(false) isConnecting.set(false) isSocketOpen.set(false) diff --git a/app/src/main/java/com/sameerasw/airsync/widget/AirSyncWidgetProvider.kt b/app/src/main/java/com/sameerasw/airsync/widget/AirSyncWidgetProvider.kt index aeac2b28..4fec2bf2 100644 --- a/app/src/main/java/com/sameerasw/airsync/widget/AirSyncWidgetProvider.kt +++ b/app/src/main/java/com/sameerasw/airsync/widget/AirSyncWidgetProvider.kt @@ -103,6 +103,12 @@ class AirSyncWidgetProvider : AppWidgetProvider() { val isConnected = WebSocketUtil.isConnected() val isConnecting = WebSocketUtil.isConnecting() val lastDevice = runBlocking { ds.getLastConnectedDevice().first() } + val widgetAlpha = runBlocking { ds.getWidgetTransparency().first() } + + // Apply background transparency + val baseBg = androidx.core.content.ContextCompat.getColor(context, R.color.widget_background) + val bgWithAlpha = androidx.core.graphics.ColorUtils.setAlphaComponent(baseBg, (widgetAlpha * 255).toInt().coerceIn(0, 255)) + views.setInt(R.id.widget_container, "setBackgroundColor", bgWithAlpha) // Device image (large preview) and name val previewRes = DevicePreviewResolver.getPreviewRes(lastDevice) diff --git a/app/src/main/res/drawable/rounded_add_24.xml b/app/src/main/res/drawable/rounded_add_24.xml new file mode 100644 index 00000000..6ec9fed9 --- /dev/null +++ b/app/src/main/res/drawable/rounded_add_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/rounded_remove_24.xml b/app/src/main/res/drawable/rounded_remove_24.xml new file mode 100644 index 00000000..d0b84241 --- /dev/null +++ b/app/src/main/res/drawable/rounded_remove_24.xml @@ -0,0 +1,5 @@ + + + + +