| @@ -0,0 +1,6 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <project version="4"> | |||
| <component name="VcsDirectoryMappings"> | |||
| <mapping directory="$PROJECT_DIR$" vcs="Git" /> | |||
| </component> | |||
| </project> | |||
| @@ -11,6 +11,7 @@ import kotlinx.serialization.encoding.Encoder | |||
| import kotlinx.serialization.json.JsonDecoder | |||
| import kotlinx.serialization.json.JsonPrimitive | |||
| import kotlinx.serialization.json.booleanOrNull | |||
| import kotlinx.serialization.json.intOrNull | |||
| @Serializable | |||
| data class LedColor( | |||
| @@ -20,16 +21,47 @@ data class LedColor( | |||
| val w: Int = 0 | |||
| ) | |||
| @Serializable | |||
| @Serializable(with = LedModeTypeSerializer::class) | |||
| enum class LedModeType { | |||
| SINGLE_COLOR, | |||
| RAINBOW, | |||
| SPECTRAL, | |||
| FADING, | |||
| FLASH, | |||
| SEGMENTS | |||
| SINGLE_COLOR, // 0 | |||
| RAINBOW, // 1 | |||
| SPECTRAL, // 2 | |||
| FADING, // 3 | |||
| FLASH, // 4 | |||
| SEGMENTS // 5 | |||
| } | |||
| object LedModeTypeSerializer : KSerializer<LedModeType> { | |||
| override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LedModeType", PrimitiveKind.INT) | |||
| override fun deserialize(decoder: Decoder): LedModeType { | |||
| val input = decoder as? JsonDecoder ?: return LedModeType.SINGLE_COLOR | |||
| val element = input.decodeJsonElement() | |||
| val modeInt = if (element is JsonPrimitive) { | |||
| element.intOrNull ?: element.content.toIntOrNull() ?: 0 | |||
| } else 0 | |||
| return LedModeType.entries.getOrNull(modeInt) ?: LedModeType.SINGLE_COLOR | |||
| } | |||
| override fun serialize(encoder: Encoder, value: LedModeType) { | |||
| encoder.encodeInt(value.ordinal) | |||
| } | |||
| } | |||
| @Serializable | |||
| data class TimeInterval( | |||
| val start: Long = 0, | |||
| val end: Long = 0 | |||
| ) | |||
| @Serializable | |||
| data class LedTimer( | |||
| val on: Boolean = false, | |||
| val timeInterval1: TimeInterval = TimeInterval(), | |||
| val timeInterval2: TimeInterval = TimeInterval() | |||
| ) | |||
| @Serializable | |||
| data class LedDevice( | |||
| val id: String, | |||
| @@ -38,6 +70,27 @@ data class LedDevice( | |||
| val port: Int = 81 | |||
| ) | |||
| @Serializable | |||
| data class LedConfig( | |||
| val version: Int = 0, | |||
| val ssid: String = "", | |||
| val pass: String = "", | |||
| val wifiValid: Boolean = false, | |||
| val mode: LedModeType = LedModeType.SINGLE_COLOR, | |||
| val brightness: Int = 0, | |||
| val singleColor: LedColor = LedColor(), | |||
| val segmentColor1: LedColor = LedColor(), | |||
| val segmentColor2: LedColor = LedColor(), | |||
| val segmentColor3: LedColor = LedColor(), | |||
| val rainbowDelay: Long = 0, | |||
| val spectralStepTime: Long = 0, | |||
| val fadingHoldTime: Long = 0, | |||
| val fadingFadeTime: Long = 0, | |||
| val flashOnTime: Long = 0, | |||
| val flashOffTime: Long = 0, | |||
| val timer: LedTimer = LedTimer() | |||
| ) | |||
| @Serializable | |||
| data class LedState( | |||
| @Serializable(with = PowerBooleanSerializer::class) | |||
| @@ -80,17 +133,59 @@ sealed class LedCommand { | |||
| ) : LedCommand() | |||
| @Serializable | |||
| data class SetColor(val color: LedColor) : LedCommand() | |||
| data class ChangeMode( | |||
| val cmd: String = "CHG_MODE", | |||
| val `val`: String // LM_SINGLE_COLOR, etc. | |||
| ) : LedCommand() | |||
| @Serializable | |||
| data class SetBrightness(val brightness: Int) : LedCommand() | |||
| data class ChangeColor( | |||
| val cmd: String = "CHG_COLOR", | |||
| val `val`: String, // singleColor, segmentColor1, etc. | |||
| val clr: String // r,g,b,w | |||
| ) : LedCommand() | |||
| @Serializable | |||
| data class SetMode(val mode: LedModeType) : LedCommand() | |||
| data class ChangeBrightness( | |||
| val cmd: String = "CHG_BRIGHTNESS", | |||
| val `val`: String // "255" | |||
| ) : LedCommand() | |||
| @Serializable | |||
| data class SetSpeed(val speed: Int) : LedCommand() | |||
| data class ChangeConfigParam( | |||
| val cmd: String = "CHG_CFG_PARAM", | |||
| val `val`: String, // flashOnTime, etc. | |||
| val amt: String // "2000" | |||
| ) : LedCommand() | |||
| @Serializable | |||
| data class ChangeTimer( | |||
| val cmd: String = "CHG_TIMER", | |||
| val status: String // on/off | |||
| ) : LedCommand() | |||
| @Serializable | |||
| data class ChangeTimerInterval( | |||
| val cmd: String = "CHG_TIMER_INTERVAL", | |||
| val interval: String, // timeInterval1, etc. | |||
| val timerange: String // "12:47:00 - 12:48:10" | |||
| ) : LedCommand() | |||
| @Serializable | |||
| data class GetConfig( | |||
| val cmd: String = "GET_CONFIG" | |||
| ) : LedCommand() | |||
| @Serializable | |||
| data object RequestState : LedCommand() | |||
| // Legacy support | |||
| @Serializable | |||
| data class SetColor(val color: LedColor) : LedCommand() | |||
| @Serializable | |||
| data class SetBrightness(val brightness: Int) : LedCommand() | |||
| @Serializable | |||
| data class SetMode(val mode: LedModeType) : LedCommand() | |||
| @Serializable | |||
| data class SetSpeed(val speed: Int) : LedCommand() | |||
| } | |||
| @@ -1,13 +1,13 @@ | |||
| package ch.spherIC.ledlampcontrol.data.network | |||
| import ch.spherIC.ledlampcontrol.data.model.LedCommand | |||
| import ch.spherIC.ledlampcontrol.data.model.LedConfig | |||
| import ch.spherIC.ledlampcontrol.data.model.LedState | |||
| import kotlinx.coroutines.flow.* | |||
| import kotlinx.serialization.encodeToString | |||
| import kotlinx.serialization.json.Json | |||
| import kotlinx.serialization.json.encodeToJsonElement | |||
| import okhttp3.* | |||
| import okio.ByteString | |||
| class LedWebSocketManager(private val client: OkHttpClient) { | |||
| @@ -20,6 +20,9 @@ class LedWebSocketManager(private val client: OkHttpClient) { | |||
| private val _ledState = MutableStateFlow(LedState()) | |||
| val ledState: StateFlow<LedState> = _ledState.asStateFlow() | |||
| private val _ledConfig = MutableStateFlow(LedConfig()) | |||
| val ledConfig: StateFlow<LedConfig> = _ledConfig.asStateFlow() | |||
| private val json = Json { | |||
| ignoreUnknownKeys = true | |||
| encodeDefaults = true | |||
| @@ -41,15 +44,15 @@ class LedWebSocketManager(private val client: OkHttpClient) { | |||
| webSocket = client.newWebSocket(request, object : WebSocketListener() { | |||
| override fun onOpen(webSocket: WebSocket, response: Response) { | |||
| if (currentUrl != url) return // Ignore if URL changed during connection | |||
| if (currentUrl != url) return | |||
| _connectionState.value = ConnectionState.Connected | |||
| sendCommand(LedCommand.RequestState) | |||
| // After opening, get full config | |||
| sendCommand(LedCommand.GetConfig()) | |||
| } | |||
| override fun onMessage(webSocket: WebSocket, text: String) { | |||
| if (currentUrl != url) return | |||
| // Try to handle raw status strings first | |||
| val rawText = text.trim().uppercase() | |||
| if (rawText == "LED ON" || rawText == "LED OFF") { | |||
| val isPowerOn = rawText == "LED ON" | |||
| @@ -58,8 +61,22 @@ class LedWebSocketManager(private val client: OkHttpClient) { | |||
| } | |||
| try { | |||
| val state = json.decodeFromString<LedState>(text) | |||
| _ledState.value = state | |||
| // Try parsing as Config first if it contains "version" or many keys | |||
| if (text.contains("\"version\"")) { | |||
| val config = json.decodeFromString<LedConfig>(text) | |||
| _ledConfig.value = config | |||
| // Sync basic state from config | |||
| _ledState.update { | |||
| it.copy( | |||
| brightness = config.brightness, | |||
| mode = config.mode, | |||
| color = config.singleColor | |||
| ) | |||
| } | |||
| } else { | |||
| val state = json.decodeFromString<LedState>(text) | |||
| _ledState.value = state | |||
| } | |||
| } catch (e: Exception) { | |||
| // Handle parsing error | |||
| } | |||
| @@ -89,13 +106,26 @@ class LedWebSocketManager(private val client: OkHttpClient) { | |||
| fun sendCommand(command: LedCommand) { | |||
| val jsonElement = when (command) { | |||
| is LedCommand.SetPower -> json.encodeToJsonElement(command) | |||
| is LedCommand.SetColor -> json.encodeToJsonElement(command) | |||
| is LedCommand.ChangeMode -> json.encodeToJsonElement(command) | |||
| is LedCommand.ChangeColor -> json.encodeToJsonElement(command) | |||
| is LedCommand.ChangeBrightness -> json.encodeToJsonElement(command) | |||
| is LedCommand.ChangeConfigParam -> json.encodeToJsonElement(command) | |||
| is LedCommand.ChangeTimer -> json.encodeToJsonElement(command) | |||
| is LedCommand.ChangeTimerInterval -> json.encodeToJsonElement(command) | |||
| is LedCommand.GetConfig -> json.encodeToJsonElement(command) | |||
| is LedCommand.RequestState -> json.encodeToJsonElement(command) | |||
| is LedCommand.SetBrightness -> json.encodeToJsonElement(command) | |||
| is LedCommand.SetColor -> json.encodeToJsonElement(command) | |||
| is LedCommand.SetMode -> json.encodeToJsonElement(command) | |||
| is LedCommand.SetSpeed -> json.encodeToJsonElement(command) | |||
| is LedCommand.RequestState -> json.encodeToJsonElement(command) | |||
| } | |||
| webSocket?.send(jsonElement.toString()) | |||
| // Follow up with GET_CONFIG to stay in sync if it wasn't a GET_CONFIG itself | |||
| if (command !is LedCommand.GetConfig) { | |||
| val getConfigMessage = json.encodeToJsonElement(LedCommand.GetConfig()).toString() | |||
| webSocket?.send(getConfigMessage) | |||
| } | |||
| } | |||
| sealed class ConnectionState { | |||
| @@ -2,16 +2,17 @@ package ch.spherIC.ledlampcontrol.data.repository | |||
| import ch.spherIC.ledlampcontrol.data.model.LedColor | |||
| import ch.spherIC.ledlampcontrol.data.model.LedCommand | |||
| import ch.spherIC.ledlampcontrol.data.model.LedConfig | |||
| import ch.spherIC.ledlampcontrol.data.model.LedModeType | |||
| import ch.spherIC.ledlampcontrol.data.model.LedState | |||
| import ch.spherIC.ledlampcontrol.data.network.LedWebSocketManager | |||
| import kotlinx.coroutines.flow.SharedFlow | |||
| import kotlinx.coroutines.flow.StateFlow | |||
| class LedRepository(private val webSocketManager: LedWebSocketManager) { | |||
| val connectionState: StateFlow<LedWebSocketManager.ConnectionState> = webSocketManager.connectionState | |||
| val ledState: StateFlow<LedState> = webSocketManager.ledState | |||
| val ledConfig: StateFlow<LedConfig> = webSocketManager.ledConfig | |||
| fun connect(url: String) { | |||
| webSocketManager.connect(url) | |||
| @@ -25,23 +26,32 @@ class LedRepository(private val webSocketManager: LedWebSocketManager) { | |||
| webSocketManager.sendCommand(LedCommand.SetPower(status = if (power) "on" else "off")) | |||
| } | |||
| fun setColor(color: LedColor) { | |||
| webSocketManager.sendCommand(LedCommand.SetColor(color)) | |||
| fun updateMode(mode: LedModeType) { | |||
| webSocketManager.sendCommand(LedCommand.ChangeMode(`val` = "LM_${mode.name}")) | |||
| } | |||
| fun setBrightness(brightness: Int) { | |||
| webSocketManager.sendCommand(LedCommand.SetBrightness(brightness)) | |||
| fun updateColor(key: String, color: LedColor) { | |||
| val clrString = "${color.r},${color.g},${color.b},${color.w}" | |||
| webSocketManager.sendCommand(LedCommand.ChangeColor(`val` = key, clr = clrString)) | |||
| } | |||
| fun setMode(mode: LedModeType) { | |||
| webSocketManager.sendCommand(LedCommand.SetMode(mode)) | |||
| fun updateBrightness(brightness: Int) { | |||
| webSocketManager.sendCommand(LedCommand.ChangeBrightness(`val` = brightness.toString())) | |||
| } | |||
| fun setSpeed(speed: Int) { | |||
| webSocketManager.sendCommand(LedCommand.SetSpeed(speed)) | |||
| fun updateConfigParam(param: String, value: String) { | |||
| webSocketManager.sendCommand(LedCommand.ChangeConfigParam(`val` = param, amt = value)) | |||
| } | |||
| fun updateTimer(enabled: Boolean) { | |||
| webSocketManager.sendCommand(LedCommand.ChangeTimer(status = if (enabled) "on" else "off")) | |||
| } | |||
| fun updateTimerInterval(intervalKey: String, timeRange: String) { | |||
| webSocketManager.sendCommand(LedCommand.ChangeTimerInterval(interval = intervalKey, timerange = timeRange)) | |||
| } | |||
| fun requestState() { | |||
| webSocketManager.sendCommand(LedCommand.RequestState) | |||
| fun getConfig() { | |||
| webSocketManager.sendCommand(LedCommand.GetConfig()) | |||
| } | |||
| } | |||
| @@ -7,16 +7,13 @@ import androidx.compose.foundation.layout.BoxWithConstraints | |||
| import androidx.compose.foundation.layout.aspectRatio | |||
| import androidx.compose.foundation.layout.fillMaxSize | |||
| import androidx.compose.foundation.layout.padding | |||
| 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.runtime.* | |||
| import androidx.compose.ui.Modifier | |||
| import androidx.compose.ui.geometry.Offset | |||
| import androidx.compose.ui.graphics.Brush | |||
| import androidx.compose.ui.graphics.Color | |||
| import androidx.compose.ui.graphics.drawscope.Stroke | |||
| import androidx.compose.ui.graphics.toArgb | |||
| import androidx.compose.ui.input.pointer.pointerInput | |||
| import androidx.compose.ui.unit.dp | |||
| import kotlin.math.atan2 | |||
| @@ -29,9 +26,20 @@ import kotlin.math.sqrt | |||
| @Composable | |||
| fun ColorWheel( | |||
| modifier: Modifier = Modifier, | |||
| selectedColor: Color, | |||
| onColorChanged: (r: Int, g: Int, b: Int) -> Unit | |||
| ) { | |||
| var hsv by remember { mutableStateOf(floatArrayOf(0f, 1f, 1f)) } | |||
| var hsv by remember { mutableStateOf(floatArrayOf(0f, 0f, 1f)) } | |||
| // Update local hsv when selectedColor changes externally | |||
| LaunchedEffect(selectedColor) { | |||
| val hsvOut = FloatArray(3) | |||
| android.graphics.Color.colorToHSV(selectedColor.toArgb(), hsvOut) | |||
| // Only update if it's not a pure white/black color to avoid jumping to 0,0 | |||
| if (hsvOut[1] > 0 || selectedColor != Color.Black) { | |||
| hsv = hsvOut | |||
| } | |||
| } | |||
| BoxWithConstraints(modifier = modifier.aspectRatio(1f)) { | |||
| val radius = min(constraints.maxWidth, constraints.maxHeight) / 2f | |||
| @@ -71,7 +79,7 @@ fun ColorWheel( | |||
| ) { | |||
| val sweepGradient = Brush.sweepGradient( | |||
| colors = listOf( | |||
| Color.Red, Color.Magenta, Color.Blue, Color.Cyan, Color.Green, Color.Yellow, Color.Red | |||
| Color.Red, Color.Yellow, Color.Green, Color.Cyan, Color.Blue, Color.Magenta, Color.Red | |||
| ), | |||
| center = center | |||
| ) | |||
| @@ -17,9 +17,7 @@ import androidx.compose.ui.Modifier | |||
| import androidx.compose.ui.graphics.Color | |||
| import androidx.compose.ui.tooling.preview.Preview | |||
| import androidx.compose.ui.unit.dp | |||
| import ch.spherIC.ledlampcontrol.data.model.LedDevice | |||
| import ch.spherIC.ledlampcontrol.data.model.LedModeType | |||
| import ch.spherIC.ledlampcontrol.data.model.LedState | |||
| import ch.spherIC.ledlampcontrol.data.model.* | |||
| import ch.spherIC.ledlampcontrol.data.network.LedWebSocketManager | |||
| import ch.spherIC.ledlampcontrol.ui.components.ColorWheel | |||
| import ch.spherIC.ledlampcontrol.ui.theme.LEDLampControlTheme | |||
| @@ -30,6 +28,7 @@ import kotlinx.coroutines.launch | |||
| @Composable | |||
| fun DashboardScreen(viewModel: LedViewModel) { | |||
| val ledState by viewModel.ledState.collectAsState() | |||
| val ledConfig by viewModel.ledConfig.collectAsState() | |||
| var selectedTab by remember { mutableIntStateOf(0) } | |||
| val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) | |||
| val scope = rememberCoroutineScope() | |||
| @@ -136,12 +135,18 @@ fun DashboardScreen(viewModel: LedViewModel) { | |||
| NavigationBarItem( | |||
| selected = selectedTab == 2, | |||
| onClick = { selectedTab = 2 }, | |||
| icon = { Icon(Icons.Default.Router, contentDescription = "Devices") }, | |||
| label = { Text("Devices") } | |||
| icon = { Icon(Icons.Default.Timer, contentDescription = "Timer") }, | |||
| label = { Text("Timer") } | |||
| ) | |||
| NavigationBarItem( | |||
| selected = selectedTab == 3, | |||
| onClick = { selectedTab = 3 }, | |||
| icon = { Icon(Icons.Default.Router, contentDescription = "Devices") }, | |||
| label = { Text("Devices") } | |||
| ) | |||
| NavigationBarItem( | |||
| selected = selectedTab == 4, | |||
| onClick = { selectedTab = 4 }, | |||
| icon = { Icon(Icons.Default.Palette, contentDescription = "Appearance") }, | |||
| label = { Text("Appearance") } | |||
| ) | |||
| @@ -151,27 +156,46 @@ fun DashboardScreen(viewModel: LedViewModel) { | |||
| Box( | |||
| modifier = Modifier | |||
| .padding(paddingValues) | |||
| .imePadding() // Moves the content up when the keyboard appears | |||
| .imePadding() | |||
| .fillMaxSize() | |||
| ) { | |||
| when (selectedTab) { | |||
| 0 -> { | |||
| AdjustPane( | |||
| ledConfig = ledConfig, | |||
| ledState = ledState, | |||
| onPowerToggle = { viewModel.togglePower() }, | |||
| onColorChanged = { r, g, b -> viewModel.updateColor(r, g, b, ledState.color.w) }, | |||
| onWhiteChanged = { w -> viewModel.updateColor(ledState.color.r, ledState.color.g, ledState.color.b, w) }, | |||
| onColorChanged = { r, g, b -> | |||
| viewModel.updateColor("singleColor", r, g, b, 0) | |||
| }, | |||
| onWhiteChanged = { w -> | |||
| if (w > 0) { | |||
| viewModel.updateColor("singleColor", 0, 0, 0, w) | |||
| } else { | |||
| // If W is set to 0, just send 0,0,0,0 or stay as is? | |||
| // Based on requirements, if W > 0, RGB must be 0. | |||
| viewModel.updateColor("singleColor", 0, 0, 0, 0) | |||
| } | |||
| }, | |||
| onBrightnessChanged = { b -> viewModel.updateBrightness(b) } | |||
| ) | |||
| } | |||
| 1 -> { | |||
| StylePane( | |||
| ledState = ledState, | |||
| ledConfig = ledConfig, | |||
| onModeSelected = { viewModel.updateMode(it) }, | |||
| onSpeedChanged = { viewModel.updateSpeed(it) } | |||
| onParamChanged = { param, value -> viewModel.updateConfigParam(param, value) }, | |||
| onSegmentColorChanged = { key, r, g, b, w -> viewModel.updateColor(key, r, g, b, w) } | |||
| ) | |||
| } | |||
| 2 -> { | |||
| TimerPane( | |||
| timer = ledConfig.timer, | |||
| onToggle = { viewModel.toggleTimer() }, | |||
| onIntervalChange = { key, start, end -> viewModel.updateTimerInterval(key, start, end) } | |||
| ) | |||
| } | |||
| 3 -> { | |||
| val discoveredDevices by viewModel.discoveredDevices.collectAsState() | |||
| val savedDevices by viewModel.devices.collectAsState() | |||
| val scanStatus by viewModel.scanStatus.collectAsState() | |||
| @@ -188,7 +212,7 @@ fun DashboardScreen(viewModel: LedViewModel) { | |||
| onStopDiscovery = { viewModel.stopDiscovery() } | |||
| ) | |||
| } | |||
| 3 -> { | |||
| 4 -> { | |||
| val themeMode by viewModel.themeMode.collectAsState() | |||
| val dynamicColor by viewModel.dynamicColor.collectAsState() | |||
| @@ -207,12 +231,22 @@ fun DashboardScreen(viewModel: LedViewModel) { | |||
| @Composable | |||
| fun AdjustPane( | |||
| ledConfig: LedConfig, | |||
| ledState: LedState, | |||
| onPowerToggle: () -> Unit, | |||
| onColorChanged: (Int, Int, Int) -> Unit, | |||
| onWhiteChanged: (Int) -> Unit, | |||
| onBrightnessChanged: (Int) -> Unit | |||
| ) { | |||
| var localBrightness by remember(ledConfig.brightness) { mutableFloatStateOf(ledConfig.brightness.toFloat()) } | |||
| var localWhite by remember(ledConfig.singleColor.w) { mutableFloatStateOf(ledConfig.singleColor.w.toFloat()) } | |||
| // When an RGB color is picked (wheel or presets), we must reset the local white slider | |||
| val handleRgbChange: (Int, Int, Int) -> Unit = { r, g, b -> | |||
| localWhite = 0f | |||
| onColorChanged(r, g, b) | |||
| } | |||
| Column( | |||
| modifier = Modifier | |||
| .fillMaxSize() | |||
| @@ -238,16 +272,20 @@ fun AdjustPane( | |||
| modifier = Modifier | |||
| .size(320.dp) | |||
| .padding(8.dp), | |||
| onColorChanged = onColorChanged | |||
| selectedColor = Color(ledConfig.singleColor.r, ledConfig.singleColor.g, ledConfig.singleColor.b), | |||
| onColorChanged = handleRgbChange | |||
| ) | |||
| Spacer(modifier = Modifier.height(24.dp)) | |||
| // Brightness Slider | |||
| Text("Brightness", style = MaterialTheme.typography.titleMedium) | |||
| Text("Brightness: ${localBrightness.toInt()}", style = MaterialTheme.typography.titleMedium) | |||
| Slider( | |||
| value = ledState.brightness.toFloat(), | |||
| onValueChange = { onBrightnessChanged(it.toInt()) }, | |||
| value = localBrightness, | |||
| onValueChange = { | |||
| localBrightness = it | |||
| onBrightnessChanged(it.toInt()) | |||
| }, | |||
| valueRange = 0f..255f, | |||
| modifier = Modifier.fillMaxWidth() | |||
| ) | |||
| @@ -255,10 +293,13 @@ fun AdjustPane( | |||
| Spacer(modifier = Modifier.height(8.dp)) | |||
| // White Channel Slider | |||
| Text("White Channel", style = MaterialTheme.typography.titleMedium) | |||
| Text("White Channel: ${localWhite.toInt()}", style = MaterialTheme.typography.titleMedium) | |||
| Slider( | |||
| value = ledState.color.w.toFloat(), | |||
| onValueChange = { onWhiteChanged(it.toInt()) }, | |||
| value = localWhite, | |||
| onValueChange = { | |||
| localWhite = it | |||
| onWhiteChanged(it.toInt()) | |||
| }, | |||
| valueRange = 0f..255f, | |||
| modifier = Modifier.fillMaxWidth() | |||
| ) | |||
| @@ -275,9 +316,9 @@ fun AdjustPane( | |||
| ColorItem(Color(0xFFFFEB3B), "CCT"), | |||
| ColorItem(Color(0xFFBDBDBD), "W") | |||
| ), | |||
| currentColor = Color(ledState.color.r, ledState.color.g, ledState.color.b), | |||
| currentColor = Color(ledConfig.singleColor.r, ledConfig.singleColor.g, ledConfig.singleColor.b), | |||
| onColorSelected = { color -> | |||
| onColorChanged( | |||
| handleRgbChange( | |||
| (color.red * 255).toInt(), | |||
| (color.green * 255).toInt(), | |||
| (color.blue * 255).toInt() | |||
| @@ -297,9 +338,9 @@ fun AdjustPane( | |||
| ColorItem(Color.Green), | |||
| ColorItem(Color.Blue) | |||
| ), | |||
| currentColor = Color(ledState.color.r, ledState.color.g, ledState.color.b), | |||
| currentColor = Color(ledConfig.singleColor.r, ledConfig.singleColor.g, ledConfig.singleColor.b), | |||
| onColorSelected = { color -> | |||
| onColorChanged( | |||
| handleRgbChange( | |||
| (color.red * 255).toInt(), | |||
| (color.green * 255).toInt(), | |||
| (color.blue * 255).toInt() | |||
| @@ -311,9 +352,10 @@ fun AdjustPane( | |||
| @Composable | |||
| fun StylePane( | |||
| ledState: LedState, | |||
| ledConfig: LedConfig, | |||
| onModeSelected: (LedModeType) -> Unit, | |||
| onSpeedChanged: (Int) -> Unit | |||
| onParamChanged: (String, String) -> Unit, | |||
| onSegmentColorChanged: (String, Int, Int, Int, Int) -> Unit | |||
| ) { | |||
| Column( | |||
| modifier = Modifier | |||
| @@ -329,7 +371,7 @@ fun StylePane( | |||
| val displayName = mode.name.replace("_", " ").lowercase().replaceFirstChar { it.uppercase() } | |||
| NavigationDrawerItem( | |||
| label = { Text(displayName) }, | |||
| selected = ledState.mode == mode, | |||
| selected = ledConfig.mode == mode, | |||
| onClick = { onModeSelected(mode) }, | |||
| modifier = Modifier.padding(vertical = 4.dp), | |||
| colors = NavigationDrawerItemDefaults.colors( | |||
| @@ -339,17 +381,222 @@ fun StylePane( | |||
| } | |||
| Spacer(modifier = Modifier.height(32.dp)) | |||
| Text("Animation Speed", style = MaterialTheme.typography.titleMedium) | |||
| // Mode specific parameters | |||
| when (ledConfig.mode) { | |||
| LedModeType.RAINBOW -> { | |||
| ParamSlider( | |||
| label = "Rainbow Delay", | |||
| value = ledConfig.rainbowDelay, | |||
| range = 0f..500f, | |||
| onValueChange = { onParamChanged("rainbowDelay", it.toString()) } | |||
| ) | |||
| } | |||
| LedModeType.SPECTRAL -> { | |||
| ParamSlider( | |||
| label = "Spectral Step Time", | |||
| value = ledConfig.spectralStepTime, | |||
| range = 100f..30000f, | |||
| onValueChange = { onParamChanged("spectralStepTime", it.toString()) } | |||
| ) | |||
| } | |||
| LedModeType.FADING -> { | |||
| ParamSlider( | |||
| label = "Hold Time", | |||
| value = ledConfig.fadingHoldTime, | |||
| range = 100f..10000f, | |||
| onValueChange = { onParamChanged("fadingHoldTime", it.toString()) } | |||
| ) | |||
| ParamSlider( | |||
| label = "Fade Time", | |||
| value = ledConfig.fadingFadeTime, | |||
| range = 100f..5000f, | |||
| onValueChange = { onParamChanged("fadingFadeTime", it.toString()) } | |||
| ) | |||
| } | |||
| LedModeType.FLASH -> { | |||
| ParamSlider( | |||
| label = "On Time", | |||
| value = ledConfig.flashOnTime, | |||
| range = 100f..5000f, | |||
| onValueChange = { onParamChanged("flashOnTime", it.toString()) } | |||
| ) | |||
| ParamSlider( | |||
| label = "Off Time", | |||
| value = ledConfig.flashOffTime, | |||
| range = 100f..5000f, | |||
| onValueChange = { onParamChanged("flashOffTime", it.toString()) } | |||
| ) | |||
| } | |||
| LedModeType.SEGMENTS -> { | |||
| SegmentColorPicker("Segment 1", "segmentColor1", ledConfig.segmentColor1, onSegmentColorChanged) | |||
| Spacer(modifier = Modifier.height(16.dp)) | |||
| SegmentColorPicker("Segment 2", "segmentColor2", ledConfig.segmentColor2, onSegmentColorChanged) | |||
| Spacer(modifier = Modifier.height(16.dp)) | |||
| SegmentColorPicker("Segment 3", "segmentColor3", ledConfig.segmentColor3, onSegmentColorChanged) | |||
| } | |||
| else -> {} | |||
| } | |||
| } | |||
| } | |||
| @Composable | |||
| fun ParamSlider(label: String, value: Long, range: ClosedFloatingPointRange<Float>, onValueChange: (Int) -> Unit) { | |||
| var localValue by remember(value) { mutableFloatStateOf(value.toFloat()) } | |||
| Column { | |||
| Text("$label: ${localValue.toInt()}ms", style = MaterialTheme.typography.titleMedium) | |||
| Slider( | |||
| value = ledState.speed.toFloat(), | |||
| onValueChange = { onSpeedChanged(it.toInt()) }, | |||
| valueRange = 0f..255f, | |||
| value = localValue, | |||
| onValueChange = { | |||
| localValue = it | |||
| onValueChange(it.toInt()) | |||
| }, | |||
| valueRange = range, | |||
| modifier = Modifier.fillMaxWidth() | |||
| ) | |||
| Spacer(modifier = Modifier.height(16.dp)) | |||
| } | |||
| } | |||
| @Composable | |||
| fun SegmentColorPicker(label: String, key: String, color: LedColor, onColorChanged: (String, Int, Int, Int, Int) -> Unit) { | |||
| var showDialog by remember { mutableStateOf(false) } | |||
| Card(modifier = Modifier.fillMaxWidth()) { | |||
| Row( | |||
| modifier = Modifier.padding(16.dp), | |||
| verticalAlignment = Alignment.CenterVertically, | |||
| horizontalArrangement = Arrangement.SpaceBetween | |||
| ) { | |||
| Text(label, style = MaterialTheme.typography.titleMedium) | |||
| Surface( | |||
| modifier = Modifier.size(40.dp), | |||
| color = Color(color.r, color.g, color.b), | |||
| shape = CircleShape, | |||
| border = BorderStroke(1.dp, Color.Gray), | |||
| onClick = { showDialog = true } | |||
| ) {} | |||
| } | |||
| } | |||
| if (showDialog) { | |||
| AlertDialog( | |||
| onDismissRequest = { showDialog = false }, | |||
| title = { Text("Select Color for $label") }, | |||
| text = { | |||
| Column { | |||
| // Simple color wheel or grid here. For now, a few presets. | |||
| val presets = listOf(Color.Red, Color.Green, Color.Blue, Color.Yellow, Color.Magenta, Color.Cyan, Color.White) | |||
| Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { | |||
| presets.forEach { preset -> | |||
| Surface( | |||
| modifier = Modifier.size(32.dp), | |||
| color = preset, | |||
| shape = CircleShape, | |||
| onClick = { | |||
| onColorChanged( | |||
| key, | |||
| (preset.red * 255).toInt(), | |||
| (preset.green * 255).toInt(), | |||
| (preset.blue * 255).toInt(), | |||
| 0 | |||
| ) | |||
| showDialog = false | |||
| } | |||
| ) {} | |||
| } | |||
| } | |||
| } | |||
| }, | |||
| confirmButton = { TextButton(onClick = { showDialog = false }) { Text("Close") } } | |||
| ) | |||
| } | |||
| } | |||
| @Composable | |||
| fun TimerPane( | |||
| timer: LedTimer, | |||
| onToggle: () -> Unit, | |||
| onIntervalChange: (String, Long, Long) -> Unit | |||
| ) { | |||
| Column( | |||
| modifier = Modifier | |||
| .fillMaxSize() | |||
| .padding(16.dp) | |||
| .verticalScroll(rememberScrollState()), | |||
| horizontalAlignment = Alignment.CenterHorizontally | |||
| ) { | |||
| Text("Timer Settings", style = MaterialTheme.typography.headlineMedium) | |||
| Spacer(modifier = Modifier.height(24.dp)) | |||
| Row( | |||
| modifier = Modifier.fillMaxWidth(), | |||
| horizontalArrangement = Arrangement.SpaceBetween, | |||
| verticalAlignment = Alignment.CenterVertically | |||
| ) { | |||
| Text("Enable Timer", style = MaterialTheme.typography.titleLarge) | |||
| Switch(checked = timer.on, onCheckedChange = { onToggle() }) | |||
| } | |||
| Spacer(modifier = Modifier.height(32.dp)) | |||
| TimerIntervalItem("Interval 1", "timeInterval1", timer.timeInterval1, onIntervalChange) | |||
| Spacer(modifier = Modifier.height(24.dp)) | |||
| TimerIntervalItem("Interval 2", "timeInterval2", timer.timeInterval2, onIntervalChange) | |||
| } | |||
| } | |||
| @Composable | |||
| fun TimerIntervalItem(label: String, key: String, interval: TimeInterval, onUpdate: (String, Long, Long) -> Unit) { | |||
| var showDialog by remember { mutableStateOf(false) } | |||
| Card(modifier = Modifier.fillMaxWidth()) { | |||
| Column(modifier = Modifier.padding(16.dp)) { | |||
| Text(label, style = MaterialTheme.typography.titleMedium) | |||
| Spacer(modifier = Modifier.height(8.dp)) | |||
| Text("From: ${formatSeconds(interval.start)} To: ${formatSeconds(interval.end)}") | |||
| Spacer(modifier = Modifier.height(16.dp)) | |||
| Button(onClick = { showDialog = true }) { | |||
| Text("Edit Range") | |||
| } | |||
| } | |||
| } | |||
| if (showDialog) { | |||
| // Very simplified Time Range picker. In production you'd use a real range picker. | |||
| AlertDialog( | |||
| onDismissRequest = { showDialog = false }, | |||
| title = { Text("Edit $label") }, | |||
| text = { | |||
| Column { | |||
| Text("Implementation placeholder for Time Range Picker") | |||
| Text("Currently: ${formatSeconds(interval.start)} - ${formatSeconds(interval.end)}") | |||
| } | |||
| }, | |||
| confirmButton = { | |||
| TextButton(onClick = { | |||
| // Simulate update for testing | |||
| onUpdate(key, interval.start + 60, interval.end + 60) | |||
| showDialog = false | |||
| }) { Text("Test +1m") } | |||
| }, | |||
| dismissButton = { | |||
| TextButton(onClick = { showDialog = false }) { Text("Cancel") } | |||
| } | |||
| ) | |||
| } | |||
| } | |||
| private fun formatSeconds(seconds: Long): String { | |||
| val h = seconds / 3600 | |||
| val m = (seconds % 3600) / 60 | |||
| val s = seconds % 60 | |||
| return "%02d:%02d:%02d".format(h, m, s) | |||
| } | |||
| @Composable | |||
| fun DevicesPane( | |||
| savedDevices: List<LedDevice>, | |||
| @@ -421,6 +668,7 @@ fun DevicesPane( | |||
| value = ipText, | |||
| onValueChange = { ipText = it }, | |||
| label = { Text("IP Address") }, | |||
| placeholder = { Text("e.g. 192.168.1.100 (Port 81 is default)") }, | |||
| modifier = Modifier.fillMaxWidth() | |||
| ) | |||
| Spacer(modifier = Modifier.height(8.dp)) | |||
| @@ -2,14 +2,13 @@ package ch.spherIC.ledlampcontrol.ui.viewmodel | |||
| import androidx.lifecycle.ViewModel | |||
| import androidx.lifecycle.viewModelScope | |||
| import ch.spherIC.ledlampcontrol.data.model.LedColor | |||
| import ch.spherIC.ledlampcontrol.data.model.LedDevice | |||
| import ch.spherIC.ledlampcontrol.data.model.LedModeType | |||
| import ch.spherIC.ledlampcontrol.data.model.LedState | |||
| import ch.spherIC.ledlampcontrol.data.model.* | |||
| import ch.spherIC.ledlampcontrol.data.network.LedDiscoveryManager | |||
| import ch.spherIC.ledlampcontrol.data.network.LedWebSocketManager | |||
| import ch.spherIC.ledlampcontrol.data.repository.LedRepository | |||
| import ch.spherIC.ledlampcontrol.data.repository.SettingsRepository | |||
| import kotlinx.coroutines.Job | |||
| import kotlinx.coroutines.delay | |||
| import kotlinx.coroutines.flow.* | |||
| import kotlinx.coroutines.launch | |||
| import java.util.UUID | |||
| @@ -21,6 +20,7 @@ class LedViewModel( | |||
| ) : ViewModel() { | |||
| private val _ignoredIps = MutableStateFlow<Set<String>>(emptySet()) | |||
| private val updateJobs = mutableMapOf<String, Job>() | |||
| val devices: StateFlow<List<LedDevice>> = settingsRepository.devicesFlow | |||
| .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) | |||
| @@ -48,8 +48,8 @@ class LedViewModel( | |||
| .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) | |||
| val connectionState: StateFlow<LedWebSocketManager.ConnectionState> = repository.connectionState | |||
| val ledState: StateFlow<LedState> = repository.ledState | |||
| val ledConfig: StateFlow<LedConfig> = repository.ledConfig | |||
| init { | |||
| viewModelScope.launch { | |||
| @@ -93,7 +93,6 @@ class LedViewModel( | |||
| fun selectDevice(deviceId: String?) { | |||
| viewModelScope.launch { | |||
| if (deviceId != null && deviceId == selectedDeviceId.value) { | |||
| // If the device is already selected, force a reconnection attempt | |||
| devices.value.find { it.id == deviceId }?.let { device -> | |||
| repository.connect("ws://${device.ip}:${device.port}") | |||
| } | |||
| @@ -134,19 +133,53 @@ class LedViewModel( | |||
| repository.setPower(!currentPower) | |||
| } | |||
| fun updateColor(r: Int, g: Int, b: Int, w: Int) { | |||
| repository.setColor(LedColor(r, g, b, w)) | |||
| fun updateMode(mode: LedModeType) { | |||
| repository.updateMode(mode) | |||
| } | |||
| fun updateColor(key: String, r: Int, g: Int, b: Int, w: Int) { | |||
| sendDebounced("color_$key") { | |||
| if (key == "singleColor" && ledConfig.value.mode != LedModeType.SINGLE_COLOR) { | |||
| repository.updateMode(LedModeType.SINGLE_COLOR) | |||
| } | |||
| repository.updateColor(key, LedColor(r, g, b, w)) | |||
| } | |||
| } | |||
| fun updateBrightness(brightness: Int) { | |||
| repository.setBrightness(brightness) | |||
| sendDebounced("brightness") { | |||
| repository.updateBrightness(brightness) | |||
| } | |||
| } | |||
| fun updateMode(mode: LedModeType) { | |||
| repository.setMode(mode) | |||
| fun updateConfigParam(param: String, value: String) { | |||
| sendDebounced("param_$param") { | |||
| repository.updateConfigParam(param, value) | |||
| } | |||
| } | |||
| private fun sendDebounced(key: String, delayMillis: Long = 200, block: () -> Unit) { | |||
| updateJobs[key]?.cancel() | |||
| updateJobs[key] = viewModelScope.launch { | |||
| delay(delayMillis) | |||
| block() | |||
| updateJobs.remove(key) | |||
| } | |||
| } | |||
| fun toggleTimer() { | |||
| repository.updateTimer(!ledConfig.value.timer.on) | |||
| } | |||
| fun updateTimerInterval(key: String, startTime: Long, endTime: Long) { | |||
| val range = "${formatSeconds(startTime)} - ${formatSeconds(endTime)}" | |||
| repository.updateTimerInterval(key, range) | |||
| } | |||
| fun updateSpeed(speed: Int) { | |||
| repository.setSpeed(speed) | |||
| private fun formatSeconds(seconds: Long): String { | |||
| val h = seconds / 3600 | |||
| val m = (seconds % 3600) / 60 | |||
| val s = seconds % 60 | |||
| return "%02d:%02d:%02d".format(h, m, s) | |||
| } | |||
| } | |||