| plan.md |
| # Project Plan | |||||
| I want to build an app to configure a SK6812 RGBW-LED-Strip via websocket connection. Similar to duoCol Strip App in Google Playstore. | |||||
| ## Project Brief | |||||
| # Project Brief: LEDLampControl | |||||
| An intuitive Android application designed to control SK6812 RGBW-LED | |||||
| strips in real-time. The app establishes a low-latency WebSocket connection to provide seamless adjustment of colors, brightness, and | |||||
| dynamic lighting effects, mirroring the functionality of professional LED controllers like duoCol Strip. | |||||
| ## Features | |||||
| 1. **Precision | |||||
| Color Control**: Interactive RGBW color wheel for selecting millions of colors, including dedicated control for the White (W) channel | |||||
| . | |||||
| 2. **WebSocket Connectivity**: Low-latency, bi-directional communication with the LED controller for instantaneous lighting | |||||
| updates. | |||||
| 3. **Dynamic Style Library**: A suite of preset lighting modes and animations (e.g., | |||||
| pulse, strobe, rainbow) with adjustable speed and intensity. | |||||
| 4. **Device Discovery & Management**: Easy scanning and connection management | |||||
| for local LED controllers on the network. | |||||
| 5. **Adaptive Dashboard**: A Material 3 interface that dynamically adjusts its | |||||
| layout for optimal use on phones, tablets, and foldables. | |||||
| ## High-Level Technical Stack | |||||
| * ** | |||||
| Language**: Kotlin | |||||
| * **UI Framework**: Jetpack Compose (Material 3) | |||||
| * **Navigation**: Jet | |||||
| pack Navigation 3 (State-driven) | |||||
| * **Adaptive Strategy**: Compose Material Adaptive Library | |||||
| * ** | |||||
| Networking**: OkHttp (WebSocket support) | |||||
| * **Asynchrony**: Kotlin Coroutines & Flow for reactive state management | |||||
| ## | |||||
| UI Design Image | |||||
|  | |||||
| ## Implementation Steps | |||||
| ### Task_1_Networking_Domain: Implement the WebSocket communication layer and core data models for LED control (RGBW, Brightness, Modes). Set up the Repository and ViewModel to handle real-time state updates. | |||||
| - **Status:** COMPLETED | |||||
| - **Updates:** Implemented LedState, LedColor, LedModeType, and LedCommand data models. Created LedWebSocketManager using OkHttp for real-time communication. Implemented LedRepository and LedViewModel for state management. Verified with unit tests and a successful build. | |||||
| - **Acceptance Criteria:** | |||||
| - WebSocket connection established using OkHttp | |||||
| - Data models for RGBW and lighting modes defined | |||||
| - State flow for real-time updates implemented | |||||
| - Project builds successfully | |||||
| ### Task_2_Dashboard_UI: Develop the main adaptive Dashboard UI using Jetpack Compose and Material 3. Include the interactive RGBW color wheel, dedicated White (W) channel slider, and brightness controls. | |||||
| - **Status:** IN_PROGRESS | |||||
| - **Acceptance Criteria:** | |||||
| - UI matches the design in D:/Work/Android/LEDLampControl/input_images/image_0.jpeg | |||||
| - RGBW color selection and brightness sliders are functional | |||||
| - Layout is adaptive for phones and tablets | |||||
| - Material 3 vibrant color scheme applied | |||||
| - **StartTime:** 2026-07-16 18:48:33 CEST | |||||
| ### Task_3_Device_Management_Styles: Implement device discovery/connection management and the dynamic style library (presets like pulse, rainbow). Integrate Room or DataStore for persisting device settings. | |||||
| - **Status:** PENDING | |||||
| - **Acceptance Criteria:** | |||||
| - Device discovery or manual IP entry works | |||||
| - Preset lighting modes are selectable and sent via WebSocket | |||||
| - Recent devices are saved and restorable | |||||
| - App handles connection/disconnection states gracefully | |||||
| ### Task_4_Final_Polish_Verify: Finalize the application with an adaptive app icon, edge-to-edge display, and full stability verification. | |||||
| - **Status:** PENDING | |||||
| - **Acceptance Criteria:** | |||||
| - Adaptive app icon implemented | |||||
| - Full Edge-to-Edge display functional | |||||
| - All existing tests pass | |||||
| - App does not crash during typical usage | |||||
| - Critic agent verifies alignment with project brief and UI design | |||||
| *.iml | |||||
| .gradle | |||||
| /local.properties | |||||
| /.idea/caches | |||||
| /.idea/libraries | |||||
| /.idea/modules.xml | |||||
| /.idea/workspace.xml | |||||
| /.idea/navEditor.xml | |||||
| /.idea/assetWizardSettings.xml | |||||
| .DS_Store | |||||
| /build | |||||
| /captures | |||||
| .externalNativeBuild | |||||
| .cxx | |||||
| local.properties |
| # Default ignored files | |||||
| /shelf/ | |||||
| /workspace.xml |
| <?xml version="1.0" encoding="UTF-8"?> | |||||
| <project version="4"> | |||||
| <component name="AndroidProjectSystem"> | |||||
| <option name="providerId" value="com.android.tools.idea.GradleProjectSystem" /> | |||||
| </component> | |||||
| </project> |
| <?xml version="1.0" encoding="UTF-8"?> | |||||
| <project version="4"> | |||||
| <component name="CompilerConfiguration"> | |||||
| <bytecodeTargetLevel target="21" /> | |||||
| </component> | |||||
| </project> |
| <?xml version="1.0" encoding="UTF-8"?> | |||||
| <project version="4"> | |||||
| <component name="deploymentTargetSelector"> | |||||
| <selectionStates> | |||||
| <SelectionState runConfigName="app"> | |||||
| <option name="selectionMode" value="DROPDOWN" /> | |||||
| <DropdownSelection timestamp="2026-07-17T13:14:43.254466300Z"> | |||||
| <Target type="DEFAULT_BOOT"> | |||||
| <handle> | |||||
| <DeviceId pluginId="PhysicalDevice" identifier="serial=5C060DLCH002WX" /> | |||||
| </handle> | |||||
| </Target> | |||||
| </DropdownSelection> | |||||
| <DialogSelection /> | |||||
| </SelectionState> | |||||
| <SelectionState runConfigName="androidTest"> | |||||
| <option name="selectionMode" value="DROPDOWN" /> | |||||
| <DialogSelection /> | |||||
| </SelectionState> | |||||
| <SelectionState runConfigName="main"> | |||||
| <option name="selectionMode" value="DROPDOWN" /> | |||||
| <DropdownSelection timestamp="2026-07-17T12:12:58.355413100Z"> | |||||
| <Target type="DEFAULT_BOOT"> | |||||
| <handle> | |||||
| <DeviceId pluginId="PhysicalDevice" identifier="serial=5C060DLCH002WX" /> | |||||
| </handle> | |||||
| </Target> | |||||
| </DropdownSelection> | |||||
| <DialogSelection /> | |||||
| </SelectionState> | |||||
| <SelectionState runConfigName="unitTest"> | |||||
| <option name="selectionMode" value="DROPDOWN" /> | |||||
| <DialogSelection /> | |||||
| </SelectionState> | |||||
| </selectionStates> | |||||
| </component> | |||||
| </project> |
| <?xml version="1.0" encoding="UTF-8"?> | |||||
| <project version="4"> | |||||
| <component name="DeviceTable"> | |||||
| <option name="columnSorters"> | |||||
| <list> | |||||
| <ColumnSorterState> | |||||
| <option name="column" value="Name" /> | |||||
| <option name="order" value="ASCENDING" /> | |||||
| </ColumnSorterState> | |||||
| </list> | |||||
| </option> | |||||
| </component> | |||||
| </project> |
| <?xml version="1.0" encoding="UTF-8"?> | |||||
| <project version="4"> | |||||
| <component name="GradleMigrationSettings" migrationVersion="1" /> | |||||
| <component name="GradleSettings"> | |||||
| <option name="linkedExternalProjectsSettings"> | |||||
| <GradleProjectSettings> | |||||
| <option name="testRunner" value="CHOOSE_PER_TEST" /> | |||||
| <option name="externalProjectPath" value="$PROJECT_DIR$" /> | |||||
| <option name="modules"> | |||||
| <set> | |||||
| <option value="$PROJECT_DIR$" /> | |||||
| <option value="$PROJECT_DIR$/app" /> | |||||
| </set> | |||||
| </option> | |||||
| </GradleProjectSettings> | |||||
| </option> | |||||
| </component> | |||||
| </project> |
| <component name="InspectionProjectProfileManager"> | |||||
| <profile version="1.0"> | |||||
| <option name="myName" value="Project Default" /> | |||||
| <inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true"> | |||||
| <option name="composableFile" value="true" /> | |||||
| <option name="previewFile" value="true" /> | |||||
| </inspection_tool> | |||||
| <inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true"> | |||||
| <option name="composableFile" value="true" /> | |||||
| <option name="previewFile" value="true" /> | |||||
| </inspection_tool> | |||||
| <inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true"> | |||||
| <option name="composableFile" value="true" /> | |||||
| <option name="previewFile" value="true" /> | |||||
| </inspection_tool> | |||||
| <inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true"> | |||||
| <option name="composableFile" value="true" /> | |||||
| <option name="previewFile" value="true" /> | |||||
| </inspection_tool> | |||||
| <inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true"> | |||||
| <option name="composableFile" value="true" /> | |||||
| </inspection_tool> | |||||
| <inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true"> | |||||
| <option name="composableFile" value="true" /> | |||||
| </inspection_tool> | |||||
| <inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true"> | |||||
| <option name="composableFile" value="true" /> | |||||
| </inspection_tool> | |||||
| <inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true"> | |||||
| <option name="composableFile" value="true" /> | |||||
| </inspection_tool> | |||||
| <inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true"> | |||||
| <option name="composableFile" value="true" /> | |||||
| <option name="previewFile" value="true" /> | |||||
| </inspection_tool> | |||||
| <inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true"> | |||||
| <option name="composableFile" value="true" /> | |||||
| <option name="previewFile" value="true" /> | |||||
| </inspection_tool> | |||||
| <inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true"> | |||||
| <option name="composableFile" value="true" /> | |||||
| <option name="previewFile" value="true" /> | |||||
| </inspection_tool> | |||||
| <inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true"> | |||||
| <option name="composableFile" value="true" /> | |||||
| <option name="previewFile" value="true" /> | |||||
| </inspection_tool> | |||||
| <inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true"> | |||||
| <option name="composableFile" value="true" /> | |||||
| <option name="previewFile" value="true" /> | |||||
| </inspection_tool> | |||||
| <inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true"> | |||||
| <option name="composableFile" value="true" /> | |||||
| <option name="previewFile" value="true" /> | |||||
| </inspection_tool> | |||||
| <inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true"> | |||||
| <option name="composableFile" value="true" /> | |||||
| <option name="previewFile" value="true" /> | |||||
| </inspection_tool> | |||||
| </profile> | |||||
| </component> |
| <?xml version="1.0" encoding="UTF-8"?> | |||||
| <project version="4"> | |||||
| <component name="MarkdownSettings"> | |||||
| <option name="previewPanelProviderInfo"> | |||||
| <ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" /> | |||||
| </option> | |||||
| </component> | |||||
| </project> |
| <?xml version="1.0" encoding="UTF-8"?> | |||||
| <project version="4"> | |||||
| <component name="ExternalStorageConfigurationManager" enabled="true" /> | |||||
| <component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK"> | |||||
| <output url="file://$PROJECT_DIR$/build/classes" /> | |||||
| </component> | |||||
| <component name="ProjectType"> | |||||
| <option name="id" value="Android" /> | |||||
| </component> | |||||
| </project> |
| <?xml version="1.0" encoding="UTF-8"?> | |||||
| <project version="4"> | |||||
| <component name="RunConfigurationProducerService"> | |||||
| <option name="ignoredProducers"> | |||||
| <set> | |||||
| <option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" /> | |||||
| <option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" /> | |||||
| <option value="com.intellij.execution.junit.PatternConfigurationProducer" /> | |||||
| <option value="com.intellij.execution.junit.TestInClassConfigurationProducer" /> | |||||
| <option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" /> | |||||
| <option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" /> | |||||
| <option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" /> | |||||
| <option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" /> | |||||
| </set> | |||||
| </option> | |||||
| </component> | |||||
| </project> |
| /build |
| plugins { | |||||
| alias(libs.plugins.android.application) | |||||
| alias(libs.plugins.kotlin.compose) | |||||
| alias(libs.plugins.google.devtools.ksp) | |||||
| alias(libs.plugins.jetbrains.kotlin.plugin.serialization) | |||||
| } | |||||
| android { | |||||
| namespace = "ch.spherIC.ledlampcontrol" | |||||
| compileSdk = 37 | |||||
| defaultConfig { | |||||
| applicationId = "ch.spherIC.ledlampcontrol" | |||||
| minSdk = 29 | |||||
| targetSdk = 37 | |||||
| versionCode = 1 | |||||
| versionName = "1.0" | |||||
| testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" | |||||
| } | |||||
| buildTypes { | |||||
| release { | |||||
| optimization { | |||||
| enable = false | |||||
| } | |||||
| } | |||||
| } | |||||
| compileOptions { | |||||
| sourceCompatibility = JavaVersion.VERSION_11 | |||||
| targetCompatibility = JavaVersion.VERSION_11 | |||||
| } | |||||
| buildFeatures { | |||||
| compose = true | |||||
| } | |||||
| } | |||||
| dependencies { | |||||
| implementation(platform(libs.androidx.compose.bom)) | |||||
| implementation(libs.accompanist.permissions) | |||||
| implementation(libs.androidx.activity.compose) | |||||
| implementation(libs.androidx.camera.camera2) | |||||
| implementation(libs.androidx.camera.core) | |||||
| implementation(libs.androidx.camera.lifecycle) | |||||
| implementation(libs.androidx.camera.view) | |||||
| implementation(libs.androidx.compose.adaptive) | |||||
| implementation(libs.androidx.compose.adaptive.layout) | |||||
| implementation(libs.androidx.compose.adaptive.navigation3) | |||||
| implementation(libs.androidx.compose.material.icons.core) | |||||
| implementation(libs.androidx.compose.material.icons.extended) | |||||
| implementation(libs.androidx.compose.material3) | |||||
| implementation(libs.androidx.compose.ui) | |||||
| implementation(libs.androidx.compose.ui.graphics) | |||||
| implementation(libs.androidx.compose.ui.tooling.preview) | |||||
| implementation(libs.androidx.core.ktx) | |||||
| implementation(libs.androidx.datastore.preferences) | |||||
| implementation(libs.androidx.lifecycle.runtime.compose) | |||||
| implementation(libs.androidx.lifecycle.runtime.ktx) | |||||
| implementation(libs.androidx.lifecycle.viewmodel.compose) | |||||
| implementation(libs.androidx.lifecycle.viewmodel.navigation3) | |||||
| implementation(libs.androidx.navigation3.runtime) | |||||
| implementation(libs.androidx.navigation3.ui) | |||||
| implementation(libs.androidx.room.ktx) | |||||
| implementation(libs.androidx.room.runtime) | |||||
| implementation(libs.coil.compose) | |||||
| implementation(libs.converter.moshi) | |||||
| implementation(libs.kotlinx.coroutines.android) | |||||
| implementation(libs.kotlinx.coroutines.core) | |||||
| implementation(libs.kotlinx.serialization.core) | |||||
| implementation(libs.kotlinx.serialization.json) | |||||
| implementation(libs.androidx.datastore.preferences) | |||||
| implementation(libs.logging.interceptor) | |||||
| implementation(libs.material) | |||||
| implementation(libs.moshi.kotlin) | |||||
| implementation(libs.okhttp) | |||||
| implementation(libs.play.services.location) | |||||
| implementation(libs.retrofit) | |||||
| testImplementation(libs.androidx.core) | |||||
| testImplementation(libs.androidx.junit) | |||||
| testImplementation(libs.junit) | |||||
| testImplementation(libs.kotlinx.coroutines.test) | |||||
| androidTestImplementation(platform(libs.androidx.compose.bom)) | |||||
| androidTestImplementation(libs.androidx.compose.ui.test.junit4) | |||||
| androidTestImplementation(libs.androidx.espresso.core) | |||||
| androidTestImplementation(libs.androidx.junit) | |||||
| androidTestImplementation(libs.androidx.runner) | |||||
| debugImplementation(libs.androidx.compose.ui.test.manifest) | |||||
| debugImplementation(libs.androidx.compose.ui.tooling) | |||||
| "ksp"(libs.androidx.room.compiler) | |||||
| "ksp"(libs.moshi.kotlin.codegen) | |||||
| } |
| package ch.spherIC.ledlampcontrol | |||||
| import androidx.test.platform.app.InstrumentationRegistry | |||||
| import androidx.test.ext.junit.runners.AndroidJUnit4 | |||||
| import org.junit.Test | |||||
| import org.junit.runner.RunWith | |||||
| import org.junit.Assert.* | |||||
| /** | |||||
| * Instrumented test, which will execute on an Android device. | |||||
| * | |||||
| * See [testing documentation](http://d.android.com/tools/testing). | |||||
| */ | |||||
| @RunWith(AndroidJUnit4::class) | |||||
| class ExampleInstrumentedTest { | |||||
| @Test | |||||
| fun useAppContext() { | |||||
| // Context of the app under test. | |||||
| val appContext = InstrumentationRegistry.getInstrumentation().targetContext | |||||
| assertEquals("ch.spherIC.ledlampcontrol", appContext.packageName) | |||||
| } | |||||
| } |
| <?xml version="1.0" encoding="utf-8"?> | |||||
| <manifest xmlns:android="http://schemas.android.com/apk/res/android" | |||||
| xmlns:tools="http://schemas.android.com/tools"> | |||||
| <application | |||||
| android:allowBackup="true" | |||||
| android:dataExtractionRules="@xml/data_extraction_rules" | |||||
| android:fullBackupContent="@xml/backup_rules" | |||||
| android:icon="@mipmap/ic_launcher" | |||||
| android:label="@string/app_name" | |||||
| android:roundIcon="@mipmap/ic_launcher_round" | |||||
| android:supportsRtl="true" | |||||
| android:theme="@style/Theme.LEDLampControl" | |||||
| android:usesCleartextTraffic="true"> | |||||
| <activity | |||||
| android:name=".MainActivity" | |||||
| android:exported="true" | |||||
| android:label="@string/app_name" | |||||
| android:theme="@style/Theme.LEDLampControl" | |||||
| android:windowSoftInputMode="adjustResize"> | |||||
| <intent-filter> | |||||
| <action android:name="android.intent.action.MAIN" /> | |||||
| <category android:name="android.intent.category.LAUNCHER" /> | |||||
| </intent-filter> | |||||
| </activity> | |||||
| </application> | |||||
| <uses-permission android:name="android.permission.INTERNET" /> | |||||
| <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> | |||||
| <uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" /> | |||||
| <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> | |||||
| <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> | |||||
| <uses-permission | |||||
| android:name="android.permission.NEARBY_WIFI_DEVICES" | |||||
| android:usesPermissionFlags="neverForLocation" /> | |||||
| </manifest> |
| package ch.spherIC.ledlampcontrol | |||||
| import android.os.Bundle | |||||
| import androidx.activity.ComponentActivity | |||||
| import androidx.activity.compose.setContent | |||||
| import androidx.activity.enableEdgeToEdge | |||||
| import androidx.lifecycle.viewmodel.compose.viewModel | |||||
| import ch.spherIC.ledlampcontrol.data.network.LedWebSocketManager | |||||
| import ch.spherIC.ledlampcontrol.data.network.LedDiscoveryManager | |||||
| import ch.spherIC.ledlampcontrol.data.repository.LedRepository | |||||
| import ch.spherIC.ledlampcontrol.data.repository.SettingsRepository | |||||
| import ch.spherIC.ledlampcontrol.ui.dashboard.DashboardScreen | |||||
| import ch.spherIC.ledlampcontrol.ui.theme.LEDLampControlTheme | |||||
| import ch.spherIC.ledlampcontrol.ui.viewmodel.LedViewModel | |||||
| import okhttp3.OkHttpClient | |||||
| import androidx.lifecycle.ViewModel | |||||
| import androidx.lifecycle.ViewModelProvider | |||||
| import androidx.compose.foundation.isSystemInDarkTheme | |||||
| import androidx.compose.runtime.collectAsState | |||||
| import androidx.compose.runtime.getValue | |||||
| import androidx.activity.result.contract.ActivityResultContracts | |||||
| import android.Manifest | |||||
| class MainActivity : ComponentActivity() { | |||||
| private val client = OkHttpClient() | |||||
| private val webSocketManager = LedWebSocketManager(client) | |||||
| private val repository = LedRepository(webSocketManager) | |||||
| private lateinit var settingsRepository: SettingsRepository | |||||
| private lateinit var discoveryManager: LedDiscoveryManager | |||||
| private val requestPermissionLauncher = registerForActivityResult( | |||||
| ActivityResultContracts.RequestMultiplePermissions() | |||||
| ) { } | |||||
| override fun onCreate(savedInstanceState: Bundle?) { | |||||
| super.onCreate(savedInstanceState) | |||||
| enableEdgeToEdge() | |||||
| requestPermissionLauncher.launch( | |||||
| arrayOf( | |||||
| Manifest.permission.ACCESS_FINE_LOCATION, | |||||
| Manifest.permission.ACCESS_WIFI_STATE, | |||||
| Manifest.permission.CHANGE_WIFI_MULTICAST_STATE, | |||||
| Manifest.permission.NEARBY_WIFI_DEVICES | |||||
| ) | |||||
| ) | |||||
| settingsRepository = SettingsRepository(applicationContext) | |||||
| discoveryManager = LedDiscoveryManager(applicationContext) | |||||
| // In a real app, use Hilt or Koin. Manual DI for simplicity. | |||||
| val viewModelFactory = object : ViewModelProvider.Factory { | |||||
| override fun <T : ViewModel> create(modelClass: Class<T>): T { | |||||
| return LedViewModel(repository, settingsRepository, discoveryManager) as T | |||||
| } | |||||
| } | |||||
| setContent { | |||||
| val viewModel: LedViewModel = viewModel(factory = viewModelFactory) | |||||
| val themeMode by viewModel.themeMode.collectAsState() | |||||
| val dynamicColor by viewModel.dynamicColor.collectAsState() | |||||
| val darkTheme = when (themeMode) { | |||||
| 1 -> false // Light | |||||
| 2 -> true // Dark | |||||
| else -> isSystemInDarkTheme() // System | |||||
| } | |||||
| LEDLampControlTheme( | |||||
| darkTheme = darkTheme, | |||||
| dynamicColor = dynamicColor | |||||
| ) { | |||||
| DashboardScreen(viewModel = viewModel) | |||||
| } | |||||
| } | |||||
| } | |||||
| override fun onDestroy() { | |||||
| super.onDestroy() | |||||
| webSocketManager.disconnect() | |||||
| } | |||||
| } |
| package ch.spherIC.ledlampcontrol.data.model | |||||
| import kotlinx.serialization.KSerializer | |||||
| import kotlinx.serialization.SerialName | |||||
| import kotlinx.serialization.Serializable | |||||
| import kotlinx.serialization.descriptors.PrimitiveKind | |||||
| import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor | |||||
| import kotlinx.serialization.descriptors.SerialDescriptor | |||||
| import kotlinx.serialization.encoding.Decoder | |||||
| import kotlinx.serialization.encoding.Encoder | |||||
| import kotlinx.serialization.json.JsonDecoder | |||||
| import kotlinx.serialization.json.JsonPrimitive | |||||
| import kotlinx.serialization.json.booleanOrNull | |||||
| @Serializable | |||||
| data class LedColor( | |||||
| val r: Int = 0, | |||||
| val g: Int = 0, | |||||
| val b: Int = 0, | |||||
| val w: Int = 0 | |||||
| ) | |||||
| @Serializable | |||||
| enum class LedModeType { | |||||
| SINGLE_COLOR, | |||||
| RAINBOW, | |||||
| SPECTRAL, | |||||
| FADING, | |||||
| FLASH, | |||||
| SEGMENTS | |||||
| } | |||||
| @Serializable | |||||
| data class LedDevice( | |||||
| val id: String, | |||||
| val name: String, | |||||
| val ip: String, | |||||
| val port: Int = 81 | |||||
| ) | |||||
| @Serializable | |||||
| data class LedState( | |||||
| @Serializable(with = PowerBooleanSerializer::class) | |||||
| val power: Boolean = false, | |||||
| val color: LedColor = LedColor(), | |||||
| val brightness: Int = 255, | |||||
| val mode: LedModeType = LedModeType.SINGLE_COLOR, | |||||
| val speed: Int = 100 | |||||
| ) | |||||
| object PowerBooleanSerializer : KSerializer<Boolean> { | |||||
| override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("PowerBoolean", PrimitiveKind.STRING) | |||||
| override fun deserialize(decoder: Decoder): Boolean { | |||||
| val input = decoder as? JsonDecoder ?: return decoder.decodeBoolean() | |||||
| val element = input.decodeJsonElement() | |||||
| return if (element is JsonPrimitive) { | |||||
| if (element.isString) { | |||||
| val content = element.content.lowercase() | |||||
| content == "on" || content == "true" || content.contains("led on") | |||||
| } else { | |||||
| element.booleanOrNull ?: false | |||||
| } | |||||
| } else { | |||||
| false | |||||
| } | |||||
| } | |||||
| override fun serialize(encoder: Encoder, value: Boolean) { | |||||
| encoder.encodeBoolean(value) | |||||
| } | |||||
| } | |||||
| @Serializable | |||||
| sealed class LedCommand { | |||||
| @Serializable | |||||
| data class SetPower( | |||||
| val cmd: String = "SWITCH_LED", | |||||
| @SerialName("val") val status: String | |||||
| ) : LedCommand() | |||||
| @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() | |||||
| @Serializable | |||||
| data object RequestState : LedCommand() | |||||
| } |
| package ch.spherIC.ledlampcontrol.data.network | |||||
| import android.content.Context | |||||
| import android.net.nsd.NsdManager | |||||
| import android.net.nsd.NsdServiceInfo | |||||
| import android.net.wifi.WifiManager | |||||
| import ch.spherIC.ledlampcontrol.data.model.LedDevice | |||||
| import kotlinx.coroutines.flow.MutableStateFlow | |||||
| import kotlinx.coroutines.flow.StateFlow | |||||
| import kotlinx.coroutines.flow.asStateFlow | |||||
| import kotlinx.coroutines.flow.update | |||||
| class LedDiscoveryManager(context: Context) { | |||||
| private val nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager | |||||
| private val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager | |||||
| private var multicastLock: WifiManager.MulticastLock? = null | |||||
| private val _discoveredDevices = MutableStateFlow<List<LedDevice>>(emptyList()) | |||||
| val discoveredDevices: StateFlow<List<LedDevice>> = _discoveredDevices.asStateFlow() | |||||
| private val _scanStatus = MutableStateFlow("Idle") | |||||
| val scanStatus: StateFlow<String> = _scanStatus.asStateFlow() | |||||
| private val _isScanning = MutableStateFlow(false) | |||||
| val isScanning: StateFlow<Boolean> = _isScanning.asStateFlow() | |||||
| // Service type from your ESP8266 | |||||
| private val SERVICE_TYPE = "_LEDLamp._tcp" | |||||
| private val resolvedNames = mutableSetOf<String>() | |||||
| private val discoveryListener = object : NsdManager.DiscoveryListener { | |||||
| override fun onDiscoveryStarted(regType: String) { | |||||
| _scanStatus.value = "Scanning..." | |||||
| _isScanning.value = true | |||||
| } | |||||
| override fun onServiceFound(service: NsdServiceInfo) { | |||||
| // Prevent duplicate resolve requests for the same name | |||||
| if (resolvedNames.contains(service.serviceName)) return | |||||
| _scanStatus.value = "Found: ${service.serviceName}" | |||||
| nsdManager.resolveService(service, object : NsdManager.ResolveListener { | |||||
| override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { | |||||
| _scanStatus.value = "Error resolving ${serviceInfo.serviceName}" | |||||
| } | |||||
| override fun onServiceResolved(serviceInfo: NsdServiceInfo) { | |||||
| val ip = serviceInfo.host?.hostAddress ?: "" | |||||
| if (ip.isNotBlank() && !resolvedNames.contains(serviceInfo.serviceName)) { | |||||
| resolvedNames.add(serviceInfo.serviceName) | |||||
| // Extract name from TXT record if available, else use service name | |||||
| val displayName = serviceInfo.attributes["name"]?.let { String(it) } | |||||
| ?: serviceInfo.serviceName | |||||
| val device = LedDevice( | |||||
| id = serviceInfo.serviceName, | |||||
| name = displayName, | |||||
| ip = ip, | |||||
| port = 81 | |||||
| ) | |||||
| _discoveredDevices.update { (it + device).distinctBy { d -> d.ip } } | |||||
| _scanStatus.value = "Discovered: $displayName" | |||||
| } | |||||
| } | |||||
| }) | |||||
| } | |||||
| override fun onServiceLost(service: NsdServiceInfo) { | |||||
| resolvedNames.remove(service.serviceName) | |||||
| _discoveredDevices.update { devices -> | |||||
| devices.filterNot { it.id == service.serviceName } | |||||
| } | |||||
| } | |||||
| override fun onDiscoveryStopped(regType: String) { | |||||
| _scanStatus.value = "Idle" | |||||
| _isScanning.value = false | |||||
| } | |||||
| override fun onStartDiscoveryFailed(regType: String, errorCode: Int) { | |||||
| _scanStatus.value = "Failed to start" | |||||
| _isScanning.value = false | |||||
| stopDiscovery() | |||||
| } | |||||
| override fun onStopDiscoveryFailed(regType: String, errorCode: Int) { | |||||
| _isScanning.value = false | |||||
| } | |||||
| } | |||||
| fun startDiscovery() { | |||||
| stopDiscovery() | |||||
| resolvedNames.clear() | |||||
| _discoveredDevices.value = emptyList() | |||||
| if (multicastLock == null) { | |||||
| multicastLock = wifiManager.createMulticastLock("ledDiscoveryLock") | |||||
| multicastLock?.setReferenceCounted(false) | |||||
| } | |||||
| multicastLock?.acquire() | |||||
| try { | |||||
| nsdManager.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, discoveryListener) | |||||
| } catch (e: Exception) { | |||||
| _scanStatus.value = "Discovery error" | |||||
| _isScanning.value = false | |||||
| } | |||||
| } | |||||
| fun stopDiscovery() { | |||||
| try { | |||||
| nsdManager.stopServiceDiscovery(discoveryListener) | |||||
| } catch (e: Exception) { | |||||
| // Already stopped or not started | |||||
| } | |||||
| _isScanning.value = false | |||||
| if (multicastLock?.isHeld == true) { | |||||
| multicastLock?.release() | |||||
| } | |||||
| } | |||||
| } |
| package ch.spherIC.ledlampcontrol.data.network | |||||
| import ch.spherIC.ledlampcontrol.data.model.LedCommand | |||||
| 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) { | |||||
| private var webSocket: WebSocket? = null | |||||
| private var currentUrl: String? = null | |||||
| private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected) | |||||
| val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow() | |||||
| private val _ledState = MutableStateFlow(LedState()) | |||||
| val ledState: StateFlow<LedState> = _ledState.asStateFlow() | |||||
| private val json = Json { | |||||
| ignoreUnknownKeys = true | |||||
| encodeDefaults = true | |||||
| } | |||||
| fun connect(url: String) { | |||||
| if (currentUrl == url && (_connectionState.value is ConnectionState.Connecting || _connectionState.value is ConnectionState.Connected)) { | |||||
| return | |||||
| } | |||||
| // If switching URLs, disconnect existing first | |||||
| if (currentUrl != url) { | |||||
| disconnect() | |||||
| } | |||||
| currentUrl = url | |||||
| val request = Request.Builder().url(url).build() | |||||
| _connectionState.value = ConnectionState.Connecting | |||||
| webSocket = client.newWebSocket(request, object : WebSocketListener() { | |||||
| override fun onOpen(webSocket: WebSocket, response: Response) { | |||||
| if (currentUrl != url) return // Ignore if URL changed during connection | |||||
| _connectionState.value = ConnectionState.Connected | |||||
| sendCommand(LedCommand.RequestState) | |||||
| } | |||||
| 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" | |||||
| _ledState.update { it.copy(power = isPowerOn) } | |||||
| return | |||||
| } | |||||
| try { | |||||
| val state = json.decodeFromString<LedState>(text) | |||||
| _ledState.value = state | |||||
| } catch (e: Exception) { | |||||
| // Handle parsing error | |||||
| } | |||||
| } | |||||
| override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { | |||||
| if (currentUrl == url) { | |||||
| _connectionState.value = ConnectionState.Disconnected | |||||
| } | |||||
| } | |||||
| override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { | |||||
| if (currentUrl == url) { | |||||
| _connectionState.value = ConnectionState.Error(t.message ?: "Unknown error") | |||||
| } | |||||
| } | |||||
| }) | |||||
| } | |||||
| fun disconnect() { | |||||
| webSocket?.close(1000, "User disconnected") | |||||
| webSocket = null | |||||
| currentUrl = null | |||||
| _connectionState.value = ConnectionState.Disconnected | |||||
| } | |||||
| fun sendCommand(command: LedCommand) { | |||||
| val jsonElement = when (command) { | |||||
| is LedCommand.SetPower -> json.encodeToJsonElement(command) | |||||
| is LedCommand.SetColor -> json.encodeToJsonElement(command) | |||||
| is LedCommand.SetBrightness -> 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()) | |||||
| } | |||||
| sealed class ConnectionState { | |||||
| object Disconnected : ConnectionState() | |||||
| object Connecting : ConnectionState() | |||||
| object Connected : ConnectionState() | |||||
| data class Error(val message: String) : ConnectionState() | |||||
| } | |||||
| } |
| 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.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 | |||||
| fun connect(url: String) { | |||||
| webSocketManager.connect(url) | |||||
| } | |||||
| fun disconnect() { | |||||
| webSocketManager.disconnect() | |||||
| } | |||||
| fun setPower(power: Boolean) { | |||||
| webSocketManager.sendCommand(LedCommand.SetPower(status = if (power) "on" else "off")) | |||||
| } | |||||
| fun setColor(color: LedColor) { | |||||
| webSocketManager.sendCommand(LedCommand.SetColor(color)) | |||||
| } | |||||
| fun setBrightness(brightness: Int) { | |||||
| webSocketManager.sendCommand(LedCommand.SetBrightness(brightness)) | |||||
| } | |||||
| fun setMode(mode: LedModeType) { | |||||
| webSocketManager.sendCommand(LedCommand.SetMode(mode)) | |||||
| } | |||||
| fun setSpeed(speed: Int) { | |||||
| webSocketManager.sendCommand(LedCommand.SetSpeed(speed)) | |||||
| } | |||||
| fun requestState() { | |||||
| webSocketManager.sendCommand(LedCommand.RequestState) | |||||
| } | |||||
| } |
| package ch.spherIC.ledlampcontrol.data.repository | |||||
| import android.content.Context | |||||
| import androidx.datastore.core.DataStore | |||||
| import androidx.datastore.preferences.core.Preferences | |||||
| import androidx.datastore.preferences.core.booleanPreferencesKey | |||||
| import androidx.datastore.preferences.core.edit | |||||
| import androidx.datastore.preferences.core.intPreferencesKey | |||||
| import androidx.datastore.preferences.core.stringPreferencesKey | |||||
| import androidx.datastore.preferences.preferencesDataStore | |||||
| import ch.spherIC.ledlampcontrol.data.model.LedDevice | |||||
| import kotlinx.serialization.encodeToString | |||||
| import kotlinx.serialization.json.Json | |||||
| import kotlinx.coroutines.flow.Flow | |||||
| import kotlinx.coroutines.flow.map | |||||
| private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings") | |||||
| class SettingsRepository(private val context: Context) { | |||||
| private val KEY_LED_DEVICES = stringPreferencesKey("led_devices") | |||||
| private val KEY_SELECTED_DEVICE_ID = stringPreferencesKey("selected_device_id") | |||||
| private val KEY_THEME_MODE = intPreferencesKey("theme_mode") | |||||
| private val KEY_DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color") | |||||
| val devicesFlow: Flow<List<LedDevice>> = context.dataStore.data | |||||
| .map { preferences -> | |||||
| val jsonString = preferences[KEY_LED_DEVICES] ?: "[]" | |||||
| try { | |||||
| Json.decodeFromString<List<LedDevice>>(jsonString) | |||||
| } catch (e: Exception) { | |||||
| emptyList() | |||||
| } | |||||
| } | |||||
| val selectedDeviceIdFlow: Flow<String?> = context.dataStore.data | |||||
| .map { preferences -> | |||||
| preferences[KEY_SELECTED_DEVICE_ID] | |||||
| } | |||||
| val themeModeFlow: Flow<Int> = context.dataStore.data | |||||
| .map { preferences -> | |||||
| preferences[KEY_THEME_MODE] ?: 2 // Default to DARK (2) | |||||
| } | |||||
| val dynamicColorFlow: Flow<Boolean> = context.dataStore.data | |||||
| .map { preferences -> | |||||
| preferences[KEY_DYNAMIC_COLOR] ?: true | |||||
| } | |||||
| suspend fun saveDevices(devices: List<LedDevice>) { | |||||
| context.dataStore.edit { preferences -> | |||||
| preferences[KEY_LED_DEVICES] = Json.encodeToString(devices) | |||||
| } | |||||
| } | |||||
| suspend fun selectDevice(deviceId: String?) { | |||||
| context.dataStore.edit { preferences -> | |||||
| if (deviceId == null) { | |||||
| preferences.remove(KEY_SELECTED_DEVICE_ID) | |||||
| } else { | |||||
| preferences[KEY_SELECTED_DEVICE_ID] = deviceId | |||||
| } | |||||
| } | |||||
| } | |||||
| suspend fun saveThemeMode(mode: Int) { | |||||
| context.dataStore.edit { preferences -> | |||||
| preferences[KEY_THEME_MODE] = mode | |||||
| } | |||||
| } | |||||
| suspend fun saveDynamicColor(enabled: Boolean) { | |||||
| context.dataStore.edit { preferences -> | |||||
| preferences[KEY_DYNAMIC_COLOR] = enabled | |||||
| } | |||||
| } | |||||
| } |
| package ch.spherIC.ledlampcontrol.ui.components | |||||
| import androidx.compose.foundation.Canvas | |||||
| import androidx.compose.foundation.gestures.detectDragGestures | |||||
| import androidx.compose.foundation.gestures.detectTapGestures | |||||
| 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.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.input.pointer.pointerInput | |||||
| import androidx.compose.ui.unit.dp | |||||
| import kotlin.math.atan2 | |||||
| import kotlin.math.cos | |||||
| import kotlin.math.min | |||||
| import kotlin.math.pow | |||||
| import kotlin.math.sin | |||||
| import kotlin.math.sqrt | |||||
| @Composable | |||||
| fun ColorWheel( | |||||
| modifier: Modifier = Modifier, | |||||
| onColorChanged: (r: Int, g: Int, b: Int) -> Unit | |||||
| ) { | |||||
| var hsv by remember { mutableStateOf(floatArrayOf(0f, 1f, 1f)) } | |||||
| BoxWithConstraints(modifier = modifier.aspectRatio(1f)) { | |||||
| val radius = min(constraints.maxWidth, constraints.maxHeight) / 2f | |||||
| val center = Offset(radius, radius) | |||||
| Canvas( | |||||
| modifier = Modifier | |||||
| .fillMaxSize() | |||||
| .pointerInput(Unit) { | |||||
| detectTapGestures { offset -> | |||||
| val (h, s) = calculateHsvFromOffset(offset, center, radius) | |||||
| if (s <= 1f) { | |||||
| hsv = floatArrayOf(h, s, 1f) | |||||
| val color = Color.hsv(h, s, 1f) | |||||
| onColorChanged( | |||||
| (color.red * 255).toInt(), | |||||
| (color.green * 255).toInt(), | |||||
| (color.blue * 255).toInt() | |||||
| ) | |||||
| } | |||||
| } | |||||
| } | |||||
| .pointerInput(Unit) { | |||||
| detectDragGestures { change, _ -> | |||||
| val (h, s) = calculateHsvFromOffset(change.position, center, radius) | |||||
| if (s <= 1f) { | |||||
| hsv = floatArrayOf(h, s, 1f) | |||||
| val color = Color.hsv(h, s, 1f) | |||||
| onColorChanged( | |||||
| (color.red * 255).toInt(), | |||||
| (color.green * 255).toInt(), | |||||
| (color.blue * 255).toInt() | |||||
| ) | |||||
| } | |||||
| } | |||||
| } | |||||
| ) { | |||||
| val sweepGradient = Brush.sweepGradient( | |||||
| colors = listOf( | |||||
| Color.Red, Color.Magenta, Color.Blue, Color.Cyan, Color.Green, Color.Yellow, Color.Red | |||||
| ), | |||||
| center = center | |||||
| ) | |||||
| drawCircle( | |||||
| brush = sweepGradient, | |||||
| radius = radius, | |||||
| center = center | |||||
| ) | |||||
| val saturationGradient = Brush.radialGradient( | |||||
| colors = listOf(Color.White, Color.Transparent), | |||||
| center = center, | |||||
| radius = radius | |||||
| ) | |||||
| drawCircle( | |||||
| brush = saturationGradient, | |||||
| radius = radius, | |||||
| center = center | |||||
| ) | |||||
| // Draw selection indicator | |||||
| val angle = Math.toRadians(hsv[0].toDouble()) | |||||
| val x = center.x + hsv[1] * radius * cos(angle).toFloat() | |||||
| val y = center.y + hsv[1] * radius * sin(angle).toFloat() | |||||
| drawCircle( | |||||
| color = Color.Black, | |||||
| radius = 12.dp.toPx(), | |||||
| center = Offset(x, y), | |||||
| style = Stroke(width = 2.dp.toPx()) | |||||
| ) | |||||
| drawCircle( | |||||
| color = Color.White, | |||||
| radius = 10.dp.toPx(), | |||||
| center = Offset(x, y), | |||||
| style = Stroke(width = 2.dp.toPx()) | |||||
| ) | |||||
| } | |||||
| } | |||||
| } | |||||
| private fun calculateHsvFromOffset(offset: Offset, center: Offset, radius: Float): Pair<Float, Float> { | |||||
| val dx = offset.x - center.x | |||||
| val dy = offset.y - center.y | |||||
| val distance = sqrt(dx.pow(2) + dy.pow(2)) | |||||
| val saturation = (distance / radius).coerceIn(0f, 1f) | |||||
| var angle = Math.toDegrees(atan2(dy.toDouble(), dx.toDouble())).toFloat() | |||||
| if (angle < 0) angle += 360f | |||||
| return angle to saturation | |||||
| } |
| package ch.spherIC.ledlampcontrol.ui.dashboard | |||||
| import androidx.compose.foundation.BorderStroke | |||||
| import androidx.compose.foundation.background | |||||
| import androidx.compose.foundation.layout.* | |||||
| import androidx.compose.foundation.rememberScrollState | |||||
| import androidx.compose.foundation.shape.CircleShape | |||||
| import androidx.compose.foundation.shape.RoundedCornerShape | |||||
| import androidx.compose.foundation.verticalScroll | |||||
| import androidx.compose.material.icons.Icons | |||||
| import androidx.compose.material.icons.filled.* | |||||
| import androidx.compose.material3.* | |||||
| import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi | |||||
| import androidx.compose.runtime.* | |||||
| import androidx.compose.ui.Alignment | |||||
| 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.network.LedWebSocketManager | |||||
| import ch.spherIC.ledlampcontrol.ui.components.ColorWheel | |||||
| import ch.spherIC.ledlampcontrol.ui.theme.LEDLampControlTheme | |||||
| import ch.spherIC.ledlampcontrol.ui.viewmodel.LedViewModel | |||||
| import kotlinx.coroutines.launch | |||||
| @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveApi::class) | |||||
| @Composable | |||||
| fun DashboardScreen(viewModel: LedViewModel) { | |||||
| val ledState by viewModel.ledState.collectAsState() | |||||
| var selectedTab by remember { mutableIntStateOf(0) } | |||||
| val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) | |||||
| val scope = rememberCoroutineScope() | |||||
| val devices by viewModel.devices.collectAsState() | |||||
| val selectedDeviceId by viewModel.selectedDeviceId.collectAsState() | |||||
| val connectionState by viewModel.connectionState.collectAsState() | |||||
| ModalNavigationDrawer( | |||||
| drawerState = drawerState, | |||||
| drawerContent = { | |||||
| ModalDrawerSheet { | |||||
| Text("Select Device", modifier = Modifier.padding(16.dp), style = MaterialTheme.typography.titleLarge) | |||||
| HorizontalDivider() | |||||
| devices.forEach { device -> | |||||
| val isSelected = selectedDeviceId == device.id | |||||
| val statusText = if (isSelected) { | |||||
| when (connectionState) { | |||||
| is LedWebSocketManager.ConnectionState.Connected -> "Connected" | |||||
| is LedWebSocketManager.ConnectionState.Connecting -> "Connecting..." | |||||
| is LedWebSocketManager.ConnectionState.Disconnected -> "Disconnected" | |||||
| is LedWebSocketManager.ConnectionState.Error -> "Connection failed" | |||||
| } | |||||
| } else "Inactive" | |||||
| val statusColor = if (isSelected) { | |||||
| when (connectionState) { | |||||
| is LedWebSocketManager.ConnectionState.Connected -> Color(0xFF4CAF50) | |||||
| is LedWebSocketManager.ConnectionState.Connecting -> Color(0xFFFFC107) | |||||
| is LedWebSocketManager.ConnectionState.Disconnected -> Color.Gray | |||||
| is LedWebSocketManager.ConnectionState.Error -> Color.Red | |||||
| } | |||||
| } else Color.Gray | |||||
| NavigationDrawerItem( | |||||
| label = { | |||||
| Column { | |||||
| Text(device.name) | |||||
| Text(statusText, style = MaterialTheme.typography.labelSmall, color = statusColor) | |||||
| } | |||||
| }, | |||||
| selected = isSelected, | |||||
| onClick = { | |||||
| viewModel.selectDevice(device.id) | |||||
| scope.launch { drawerState.close() } | |||||
| }, | |||||
| icon = { Icon(Icons.Default.Lightbulb, contentDescription = null) }, | |||||
| badge = { Text(device.ip, style = MaterialTheme.typography.labelSmall) }, | |||||
| modifier = Modifier.padding(PaddingValues(horizontal = 12.dp, vertical = 4.dp)) | |||||
| ) | |||||
| } | |||||
| Spacer(modifier = Modifier.weight(1f)) | |||||
| NavigationDrawerItem( | |||||
| label = { Text("Manage Devices") }, | |||||
| selected = selectedTab == 2, | |||||
| onClick = { | |||||
| selectedTab = 2 | |||||
| scope.launch { drawerState.close() } | |||||
| }, | |||||
| icon = { Icon(Icons.Default.Settings, contentDescription = null) }, | |||||
| modifier = Modifier.padding(PaddingValues(horizontal = 12.dp, vertical = 4.dp)) | |||||
| ) | |||||
| } | |||||
| } | |||||
| ) { | |||||
| Scaffold( | |||||
| topBar = { | |||||
| CenterAlignedTopAppBar( | |||||
| title = { | |||||
| val currentDevice = devices.find { it.id == selectedDeviceId } | |||||
| Text(currentDevice?.name ?: "LED Control") | |||||
| }, | |||||
| navigationIcon = { | |||||
| IconButton(onClick = { scope.launch { drawerState.open() } }) { | |||||
| Icon(Icons.Default.Menu, contentDescription = "Menu") | |||||
| } | |||||
| }, | |||||
| actions = { | |||||
| Box( | |||||
| modifier = Modifier | |||||
| .padding(end = 16.dp) | |||||
| .size(12.dp) | |||||
| .background( | |||||
| color = if (ledState.power) Color(0xFF4CAF50) else Color.Gray, | |||||
| shape = CircleShape | |||||
| ) | |||||
| ) | |||||
| } | |||||
| ) | |||||
| }, | |||||
| bottomBar = { | |||||
| NavigationBar { | |||||
| NavigationBarItem( | |||||
| selected = selectedTab == 0, | |||||
| onClick = { selectedTab = 0 }, | |||||
| icon = { Icon(Icons.Default.Tune, contentDescription = "Adjust") }, | |||||
| label = { Text("Adjust") } | |||||
| ) | |||||
| NavigationBarItem( | |||||
| selected = selectedTab == 1, | |||||
| onClick = { selectedTab = 1 }, | |||||
| icon = { Icon(Icons.Default.GridView, contentDescription = "Style") }, | |||||
| label = { Text("Style") } | |||||
| ) | |||||
| NavigationBarItem( | |||||
| selected = selectedTab == 2, | |||||
| onClick = { selectedTab = 2 }, | |||||
| icon = { Icon(Icons.Default.Router, contentDescription = "Devices") }, | |||||
| label = { Text("Devices") } | |||||
| ) | |||||
| NavigationBarItem( | |||||
| selected = selectedTab == 3, | |||||
| onClick = { selectedTab = 3 }, | |||||
| icon = { Icon(Icons.Default.Palette, contentDescription = "Appearance") }, | |||||
| label = { Text("Appearance") } | |||||
| ) | |||||
| } | |||||
| } | |||||
| ) { paddingValues -> | |||||
| Box( | |||||
| modifier = Modifier | |||||
| .padding(paddingValues) | |||||
| .imePadding() // Moves the content up when the keyboard appears | |||||
| .fillMaxSize() | |||||
| ) { | |||||
| when (selectedTab) { | |||||
| 0 -> { | |||||
| AdjustPane( | |||||
| 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) }, | |||||
| onBrightnessChanged = { b -> viewModel.updateBrightness(b) } | |||||
| ) | |||||
| } | |||||
| 1 -> { | |||||
| StylePane( | |||||
| ledState = ledState, | |||||
| onModeSelected = { viewModel.updateMode(it) }, | |||||
| onSpeedChanged = { viewModel.updateSpeed(it) } | |||||
| ) | |||||
| } | |||||
| 2 -> { | |||||
| val discoveredDevices by viewModel.discoveredDevices.collectAsState() | |||||
| val savedDevices by viewModel.devices.collectAsState() | |||||
| val scanStatus by viewModel.scanStatus.collectAsState() | |||||
| val isScanning by viewModel.isScanning.collectAsState() | |||||
| DevicesPane( | |||||
| savedDevices = savedDevices, | |||||
| discoveredDevices = discoveredDevices, | |||||
| scanStatus = scanStatus, | |||||
| isScanning = isScanning, | |||||
| onAddDevice = { name, ip -> viewModel.addDevice(name, ip) }, | |||||
| onRemoveDevice = { viewModel.removeDevice(it) }, | |||||
| onStartDiscovery = { viewModel.startDiscovery() }, | |||||
| onStopDiscovery = { viewModel.stopDiscovery() } | |||||
| ) | |||||
| } | |||||
| 3 -> { | |||||
| val themeMode by viewModel.themeMode.collectAsState() | |||||
| val dynamicColor by viewModel.dynamicColor.collectAsState() | |||||
| AppearancePane( | |||||
| themeMode = themeMode, | |||||
| dynamicColor = dynamicColor, | |||||
| onThemeChange = { viewModel.updateThemeMode(it) }, | |||||
| onDynamicColorChange = { viewModel.updateDynamicColor(it) } | |||||
| ) | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @Composable | |||||
| fun AdjustPane( | |||||
| ledState: LedState, | |||||
| onPowerToggle: () -> Unit, | |||||
| onColorChanged: (Int, Int, Int) -> Unit, | |||||
| onWhiteChanged: (Int) -> Unit, | |||||
| onBrightnessChanged: (Int) -> Unit | |||||
| ) { | |||||
| Column( | |||||
| modifier = Modifier | |||||
| .fillMaxSize() | |||||
| .verticalScroll(rememberScrollState()) | |||||
| .padding(16.dp), | |||||
| horizontalAlignment = Alignment.CenterHorizontally | |||||
| ) { | |||||
| Row( | |||||
| modifier = Modifier.fillMaxWidth(), | |||||
| horizontalArrangement = Arrangement.SpaceBetween, | |||||
| verticalAlignment = Alignment.CenterVertically | |||||
| ) { | |||||
| Text("Power", style = MaterialTheme.typography.titleLarge) | |||||
| Switch( | |||||
| checked = ledState.power, | |||||
| onCheckedChange = { onPowerToggle() } | |||||
| ) | |||||
| } | |||||
| Spacer(modifier = Modifier.height(16.dp)) | |||||
| ColorWheel( | |||||
| modifier = Modifier | |||||
| .size(320.dp) | |||||
| .padding(8.dp), | |||||
| onColorChanged = onColorChanged | |||||
| ) | |||||
| Spacer(modifier = Modifier.height(24.dp)) | |||||
| // Brightness Slider | |||||
| Text("Brightness", style = MaterialTheme.typography.titleMedium) | |||||
| Slider( | |||||
| value = ledState.brightness.toFloat(), | |||||
| onValueChange = { onBrightnessChanged(it.toInt()) }, | |||||
| valueRange = 0f..255f, | |||||
| modifier = Modifier.fillMaxWidth() | |||||
| ) | |||||
| Spacer(modifier = Modifier.height(8.dp)) | |||||
| // White Channel Slider | |||||
| Text("White Channel", style = MaterialTheme.typography.titleMedium) | |||||
| Slider( | |||||
| value = ledState.color.w.toFloat(), | |||||
| onValueChange = { onWhiteChanged(it.toInt()) }, | |||||
| valueRange = 0f..255f, | |||||
| modifier = Modifier.fillMaxWidth() | |||||
| ) | |||||
| Spacer(modifier = Modifier.height(16.dp)) | |||||
| PresetColorSection( | |||||
| title = "Preset", | |||||
| items = listOf( | |||||
| ColorItem(Color(0xFF4CAF50)), // Green | |||||
| ColorItem(Color(0xFFE91E63)), // Magenta | |||||
| ColorItem(Color(0xFF2196F3)), // Blue | |||||
| ColorItem(Color(0xFF9C27B0)), // Purple | |||||
| ColorItem(Color(0xFFFFEB3B), "CCT"), | |||||
| ColorItem(Color(0xFFBDBDBD), "W") | |||||
| ), | |||||
| currentColor = Color(ledState.color.r, ledState.color.g, ledState.color.b), | |||||
| onColorSelected = { color -> | |||||
| onColorChanged( | |||||
| (color.red * 255).toInt(), | |||||
| (color.green * 255).toInt(), | |||||
| (color.blue * 255).toInt() | |||||
| ) | |||||
| } | |||||
| ) | |||||
| Spacer(modifier = Modifier.height(16.dp)) | |||||
| PresetColorSection( | |||||
| title = "Classic", | |||||
| items = listOf( | |||||
| ColorItem(Color.Yellow), | |||||
| ColorItem(Color.White), | |||||
| ColorItem(Color.Cyan), | |||||
| ColorItem(Color.Red), | |||||
| ColorItem(Color.Green), | |||||
| ColorItem(Color.Blue) | |||||
| ), | |||||
| currentColor = Color(ledState.color.r, ledState.color.g, ledState.color.b), | |||||
| onColorSelected = { color -> | |||||
| onColorChanged( | |||||
| (color.red * 255).toInt(), | |||||
| (color.green * 255).toInt(), | |||||
| (color.blue * 255).toInt() | |||||
| ) | |||||
| } | |||||
| ) | |||||
| } | |||||
| } | |||||
| @Composable | |||||
| fun StylePane( | |||||
| ledState: LedState, | |||||
| onModeSelected: (LedModeType) -> Unit, | |||||
| onSpeedChanged: (Int) -> Unit | |||||
| ) { | |||||
| Column( | |||||
| modifier = Modifier | |||||
| .fillMaxSize() | |||||
| .padding(16.dp) | |||||
| .verticalScroll(rememberScrollState()), | |||||
| horizontalAlignment = Alignment.CenterHorizontally | |||||
| ) { | |||||
| Text("Animation Styles", style = MaterialTheme.typography.headlineMedium) | |||||
| Spacer(modifier = Modifier.height(24.dp)) | |||||
| LedModeType.entries.forEach { mode -> | |||||
| val displayName = mode.name.replace("_", " ").lowercase().replaceFirstChar { it.uppercase() } | |||||
| NavigationDrawerItem( | |||||
| label = { Text(displayName) }, | |||||
| selected = ledState.mode == mode, | |||||
| onClick = { onModeSelected(mode) }, | |||||
| modifier = Modifier.padding(vertical = 4.dp), | |||||
| colors = NavigationDrawerItemDefaults.colors( | |||||
| unselectedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) | |||||
| ) | |||||
| ) | |||||
| } | |||||
| Spacer(modifier = Modifier.height(32.dp)) | |||||
| Text("Animation Speed", style = MaterialTheme.typography.titleMedium) | |||||
| Slider( | |||||
| value = ledState.speed.toFloat(), | |||||
| onValueChange = { onSpeedChanged(it.toInt()) }, | |||||
| valueRange = 0f..255f, | |||||
| modifier = Modifier.fillMaxWidth() | |||||
| ) | |||||
| } | |||||
| } | |||||
| @Composable | |||||
| fun DevicesPane( | |||||
| savedDevices: List<LedDevice>, | |||||
| discoveredDevices: List<LedDevice>, | |||||
| scanStatus: String, | |||||
| isScanning: Boolean, | |||||
| onAddDevice: (String, String) -> Unit, | |||||
| onRemoveDevice: (String) -> Unit, | |||||
| onStartDiscovery: () -> Unit, | |||||
| onStopDiscovery: () -> Unit | |||||
| ) { | |||||
| var nameText by remember { mutableStateOf("") } | |||||
| var ipText by remember { mutableStateOf("") } | |||||
| DisposableEffect(Unit) { | |||||
| onDispose { | |||||
| if (isScanning) onStopDiscovery() | |||||
| } | |||||
| } | |||||
| Column( | |||||
| modifier = Modifier | |||||
| .fillMaxSize() | |||||
| .padding(16.dp) | |||||
| .verticalScroll(rememberScrollState()), | |||||
| horizontalAlignment = Alignment.CenterHorizontally | |||||
| ) { | |||||
| Text("Manage Devices", style = MaterialTheme.typography.headlineMedium) | |||||
| Text("Scanner Status: $scanStatus", style = MaterialTheme.typography.bodySmall, color = Color.Gray) | |||||
| Spacer(modifier = Modifier.height(16.dp)) | |||||
| if (isScanning) { | |||||
| CircularProgressIndicator(modifier = Modifier.size(32.dp)) | |||||
| Spacer(modifier = Modifier.height(8.dp)) | |||||
| Text("Searching for LED strips...", style = MaterialTheme.typography.bodySmall) | |||||
| } | |||||
| Spacer(modifier = Modifier.height(16.dp)) | |||||
| Button( | |||||
| onClick = { | |||||
| if (isScanning) onStopDiscovery() else onStartDiscovery() | |||||
| }, | |||||
| colors = ButtonDefaults.buttonColors( | |||||
| containerColor = if (isScanning) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.primaryContainer, | |||||
| contentColor = if (isScanning) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onPrimaryContainer | |||||
| ), | |||||
| modifier = Modifier.fillMaxWidth() | |||||
| ) { | |||||
| Icon(if (isScanning) Icons.Default.Stop else Icons.Default.Search, contentDescription = null) | |||||
| Spacer(modifier = Modifier.width(8.dp)) | |||||
| Text(if (isScanning) "Stop Scanning" else "Scan for Devices") | |||||
| } | |||||
| Spacer(modifier = Modifier.height(24.dp)) | |||||
| Card(modifier = Modifier.fillMaxWidth()) { | |||||
| Column(modifier = Modifier.padding(16.dp)) { | |||||
| Text("Add Manual Device", style = MaterialTheme.typography.titleMedium) | |||||
| Spacer(modifier = Modifier.height(8.dp)) | |||||
| OutlinedTextField( | |||||
| value = nameText, | |||||
| onValueChange = { nameText = it }, | |||||
| label = { Text("Name") }, | |||||
| modifier = Modifier.fillMaxWidth() | |||||
| ) | |||||
| OutlinedTextField( | |||||
| value = ipText, | |||||
| onValueChange = { ipText = it }, | |||||
| label = { Text("IP Address") }, | |||||
| modifier = Modifier.fillMaxWidth() | |||||
| ) | |||||
| Spacer(modifier = Modifier.height(8.dp)) | |||||
| Button( | |||||
| onClick = { | |||||
| if (nameText.isNotBlank() && ipText.isNotBlank()) { | |||||
| onAddDevice(nameText, ipText) | |||||
| nameText = "" | |||||
| ipText = "" | |||||
| } | |||||
| }, | |||||
| modifier = Modifier.align(Alignment.End) | |||||
| ) { | |||||
| Text("Add") | |||||
| } | |||||
| } | |||||
| } | |||||
| Spacer(modifier = Modifier.height(24.dp)) | |||||
| if (discoveredDevices.isNotEmpty()) { | |||||
| Text("Discovered Devices", style = MaterialTheme.typography.titleMedium) | |||||
| discoveredDevices.forEach { device -> | |||||
| var editedName by remember(device.id) { mutableStateOf(device.name) } | |||||
| Card( | |||||
| modifier = Modifier | |||||
| .padding(vertical = 4.dp) | |||||
| .fillMaxWidth(), | |||||
| colors = CardDefaults.cardColors( | |||||
| containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) | |||||
| ) | |||||
| ) { | |||||
| Row( | |||||
| modifier = Modifier.padding(12.dp), | |||||
| verticalAlignment = Alignment.CenterVertically | |||||
| ) { | |||||
| Column(modifier = Modifier.weight(1f)) { | |||||
| OutlinedTextField( | |||||
| value = editedName, | |||||
| onValueChange = { editedName = it }, | |||||
| label = { Text("Device Name") }, | |||||
| singleLine = true, | |||||
| modifier = Modifier.fillMaxWidth() | |||||
| ) | |||||
| Spacer(modifier = Modifier.height(4.dp)) | |||||
| Text( | |||||
| text = "IP: ${device.ip}", | |||||
| style = MaterialTheme.typography.labelSmall, | |||||
| color = Color.Gray | |||||
| ) | |||||
| } | |||||
| Spacer(modifier = Modifier.width(8.dp)) | |||||
| IconButton( | |||||
| onClick = { onAddDevice(editedName, device.ip) }, | |||||
| colors = IconButtonDefaults.iconButtonColors( | |||||
| containerColor = MaterialTheme.colorScheme.primaryContainer, | |||||
| contentColor = MaterialTheme.colorScheme.onPrimaryContainer | |||||
| ) | |||||
| ) { | |||||
| Icon(Icons.Default.Add, contentDescription = "Add") | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| Spacer(modifier = Modifier.height(24.dp)) | |||||
| } | |||||
| Text("Saved Devices", style = MaterialTheme.typography.titleMedium) | |||||
| savedDevices.forEach { device -> | |||||
| ListItem( | |||||
| headlineContent = { Text(device.name) }, | |||||
| supportingContent = { Text(device.ip) }, | |||||
| trailingContent = { | |||||
| IconButton(onClick = { onRemoveDevice(device.id) }) { | |||||
| Icon(Icons.Default.Delete, contentDescription = "Delete") | |||||
| } | |||||
| } | |||||
| ) | |||||
| } | |||||
| Spacer(modifier = Modifier.height(32.dp)) | |||||
| } | |||||
| } | |||||
| @Composable | |||||
| fun AppearancePane( | |||||
| themeMode: Int, | |||||
| dynamicColor: Boolean, | |||||
| onThemeChange: (Int) -> Unit, | |||||
| onDynamicColorChange: (Boolean) -> Unit | |||||
| ) { | |||||
| Column( | |||||
| modifier = Modifier | |||||
| .fillMaxSize() | |||||
| .padding(16.dp) | |||||
| .verticalScroll(rememberScrollState()), | |||||
| horizontalAlignment = Alignment.CenterHorizontally | |||||
| ) { | |||||
| Text("Appearance", style = MaterialTheme.typography.headlineMedium) | |||||
| Spacer(modifier = Modifier.height(24.dp)) | |||||
| Text("Theme Mode", style = MaterialTheme.typography.titleMedium) | |||||
| Row( | |||||
| modifier = Modifier.fillMaxWidth(), | |||||
| horizontalArrangement = Arrangement.SpaceBetween | |||||
| ) { | |||||
| listOf("System", "Light", "Dark").forEachIndexed { index, label -> | |||||
| FilterChip( | |||||
| selected = themeMode == index, | |||||
| onClick = { onThemeChange(index) }, | |||||
| label = { Text(label) } | |||||
| ) | |||||
| } | |||||
| } | |||||
| Spacer(modifier = Modifier.height(16.dp)) | |||||
| Row( | |||||
| modifier = Modifier.fillMaxWidth(), | |||||
| verticalAlignment = Alignment.CenterVertically, | |||||
| horizontalArrangement = Arrangement.SpaceBetween | |||||
| ) { | |||||
| Column { | |||||
| Text("Dynamic Color", style = MaterialTheme.typography.titleMedium) | |||||
| Text("Use wallpaper colors (Android 12+)", style = MaterialTheme.typography.bodySmall) | |||||
| } | |||||
| Switch( | |||||
| checked = dynamicColor, | |||||
| onCheckedChange = onDynamicColorChange | |||||
| ) | |||||
| } | |||||
| Spacer(modifier = Modifier.height(32.dp)) | |||||
| } | |||||
| } | |||||
| data class ColorItem(val color: Color, val label: String? = null) | |||||
| @Composable | |||||
| fun PresetColorSection( | |||||
| title: String, | |||||
| items: List<ColorItem>, | |||||
| currentColor: Color, | |||||
| onColorSelected: (Color) -> Unit | |||||
| ) { | |||||
| Column(modifier = Modifier.fillMaxWidth()) { | |||||
| Text(title, style = MaterialTheme.typography.titleMedium) | |||||
| Spacer(modifier = Modifier.height(8.dp)) | |||||
| Row( | |||||
| modifier = Modifier.fillMaxWidth(), | |||||
| horizontalArrangement = Arrangement.SpaceBetween | |||||
| ) { | |||||
| items.forEach { item -> | |||||
| val isSelected = currentColor == item.color | |||||
| Surface( | |||||
| onClick = { onColorSelected(item.color) }, | |||||
| modifier = Modifier.size(50.dp), | |||||
| shape = RoundedCornerShape(8.dp), | |||||
| color = item.color, | |||||
| border = if (item.color == Color.White) BorderStroke(1.dp, Color.LightGray) else null | |||||
| ) { | |||||
| Box(contentAlignment = Alignment.Center) { | |||||
| if (item.label != null) { | |||||
| Text( | |||||
| item.label, | |||||
| style = MaterialTheme.typography.labelSmall, | |||||
| color = if (item.color == Color.White) Color.Black else Color.White | |||||
| ) | |||||
| } | |||||
| if (isSelected) { | |||||
| Icon( | |||||
| Icons.Default.CheckCircle, | |||||
| contentDescription = "Selected", | |||||
| tint = Color.White.copy(alpha = 0.8f), | |||||
| modifier = Modifier | |||||
| .size(20.dp) | |||||
| .align(Alignment.BottomEnd) | |||||
| .padding(2.dp) | |||||
| ) | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @Preview(showBackground = true, widthDp = 400, heightDp = 1000) | |||||
| @Composable | |||||
| fun DevicesPanePreview() { | |||||
| LEDLampControlTheme { | |||||
| DevicesPane( | |||||
| savedDevices = listOf( | |||||
| LedDevice("1", "Living Room", "192.168.1.101"), | |||||
| LedDevice("2", "Bedroom", "192.168.1.102") | |||||
| ), | |||||
| discoveredDevices = listOf( | |||||
| LedDevice("3", "Kitchen", "192.168.1.103") | |||||
| ), | |||||
| scanStatus = "Scanning...", | |||||
| isScanning = true, | |||||
| onAddDevice = { _, _ -> }, | |||||
| onRemoveDevice = { _ -> }, | |||||
| onStartDiscovery = {}, | |||||
| onStopDiscovery = {} | |||||
| ) | |||||
| } | |||||
| } |
| package ch.spherIC.ledlampcontrol.ui.theme | |||||
| import androidx.compose.ui.graphics.Color | |||||
| val Purple80 = Color(0xFFD0BCFF) | |||||
| val PurpleGrey80 = Color(0xFFCCC2DC) | |||||
| val Pink80 = Color(0xFFEFB8C8) | |||||
| val Purple40 = Color(0xFF6650a4) | |||||
| val PurpleGrey40 = Color(0xFF625b71) | |||||
| val Pink40 = Color(0xFF7D5260) |
| package ch.spherIC.ledlampcontrol.ui.theme | |||||
| import android.app.Activity | |||||
| import android.os.Build | |||||
| import androidx.compose.foundation.isSystemInDarkTheme | |||||
| import androidx.compose.material3.MaterialTheme | |||||
| import androidx.compose.material3.darkColorScheme | |||||
| import androidx.compose.material3.dynamicDarkColorScheme | |||||
| import androidx.compose.material3.dynamicLightColorScheme | |||||
| import androidx.compose.material3.lightColorScheme | |||||
| import androidx.compose.runtime.Composable | |||||
| import androidx.compose.runtime.SideEffect | |||||
| import androidx.compose.ui.graphics.toArgb | |||||
| import androidx.compose.ui.platform.LocalContext | |||||
| import androidx.compose.ui.platform.LocalView | |||||
| import androidx.core.view.WindowCompat | |||||
| private val DarkColorScheme = darkColorScheme( | |||||
| primary = Purple80, | |||||
| secondary = PurpleGrey80, | |||||
| tertiary = Pink80 | |||||
| ) | |||||
| private val LightColorScheme = lightColorScheme( | |||||
| primary = Purple40, | |||||
| secondary = PurpleGrey40, | |||||
| tertiary = Pink40 | |||||
| ) | |||||
| @Composable | |||||
| fun LEDLampControlTheme( | |||||
| darkTheme: Boolean = isSystemInDarkTheme(), | |||||
| // Dynamic color is available on Android 12+ | |||||
| dynamicColor: Boolean = true, | |||||
| content: @Composable () -> Unit | |||||
| ) { | |||||
| val colorScheme = when { | |||||
| dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { | |||||
| val context = LocalContext.current | |||||
| if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) | |||||
| } | |||||
| darkTheme -> DarkColorScheme | |||||
| else -> LightColorScheme | |||||
| } | |||||
| val view = LocalView.current | |||||
| if (!view.isInEditMode) { | |||||
| SideEffect { | |||||
| val window = (view.context as Activity).window | |||||
| // InsetsController handles the icon colors | |||||
| WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme | |||||
| } | |||||
| } | |||||
| MaterialTheme( | |||||
| colorScheme = colorScheme, | |||||
| typography = Typography, | |||||
| content = content | |||||
| ) | |||||
| } |
| package ch.spherIC.ledlampcontrol.ui.theme | |||||
| import androidx.compose.material3.Typography | |||||
| import androidx.compose.ui.text.TextStyle | |||||
| import androidx.compose.ui.text.font.FontFamily | |||||
| import androidx.compose.ui.text.font.FontWeight | |||||
| import androidx.compose.ui.unit.sp | |||||
| // Set of Material typography styles to start with | |||||
| val Typography = Typography( | |||||
| bodyLarge = TextStyle( | |||||
| fontFamily = FontFamily.Default, | |||||
| fontWeight = FontWeight.Normal, | |||||
| fontSize = 16.sp, | |||||
| lineHeight = 24.sp, | |||||
| letterSpacing = 0.5.sp | |||||
| ) | |||||
| /* Other default text styles to override | |||||
| titleLarge = TextStyle( | |||||
| fontFamily = FontFamily.Default, | |||||
| fontWeight = FontWeight.Normal, | |||||
| fontSize = 22.sp, | |||||
| lineHeight = 28.sp, | |||||
| letterSpacing = 0.sp | |||||
| ), | |||||
| labelSmall = TextStyle( | |||||
| fontFamily = FontFamily.Default, | |||||
| fontWeight = FontWeight.Medium, | |||||
| fontSize = 11.sp, | |||||
| lineHeight = 16.sp, | |||||
| letterSpacing = 0.5.sp | |||||
| ) | |||||
| */ | |||||
| ) |
| 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.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.flow.* | |||||
| import kotlinx.coroutines.launch | |||||
| import java.util.UUID | |||||
| class LedViewModel( | |||||
| private val repository: LedRepository, | |||||
| private val settingsRepository: SettingsRepository, | |||||
| private val discoveryManager: LedDiscoveryManager | |||||
| ) : ViewModel() { | |||||
| private val _ignoredIps = MutableStateFlow<Set<String>>(emptySet()) | |||||
| val devices: StateFlow<List<LedDevice>> = settingsRepository.devicesFlow | |||||
| .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) | |||||
| val selectedDeviceId: StateFlow<String?> = settingsRepository.selectedDeviceIdFlow | |||||
| .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) | |||||
| val discoveredDevices: StateFlow<List<LedDevice>> = combine( | |||||
| discoveryManager.discoveredDevices, | |||||
| devices, | |||||
| _ignoredIps | |||||
| ) { discovered, saved, ignored -> | |||||
| discovered.filter { d -> | |||||
| saved.none { s -> s.ip == d.ip } && !ignored.contains(d.ip) | |||||
| } | |||||
| }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) | |||||
| val scanStatus: StateFlow<String> = discoveryManager.scanStatus | |||||
| val isScanning: StateFlow<Boolean> = discoveryManager.isScanning | |||||
| val themeMode: StateFlow<Int> = settingsRepository.themeModeFlow | |||||
| .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 2) | |||||
| val dynamicColor: StateFlow<Boolean> = settingsRepository.dynamicColorFlow | |||||
| .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) | |||||
| val connectionState: StateFlow<LedWebSocketManager.ConnectionState> = repository.connectionState | |||||
| val ledState: StateFlow<LedState> = repository.ledState | |||||
| init { | |||||
| viewModelScope.launch { | |||||
| combine(devices, selectedDeviceId) { deviceList, selectedId -> | |||||
| deviceList.find { it.id == selectedId } | |||||
| }.collect { device -> | |||||
| if (device != null) { | |||||
| repository.connect("ws://${device.ip}:${device.port}") | |||||
| } else { | |||||
| repository.disconnect() | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| fun addDevice(name: String, ip: String, port: Int = 81) { | |||||
| viewModelScope.launch { | |||||
| val newDevice = LedDevice(UUID.randomUUID().toString(), name, ip, port) | |||||
| val currentDevices = devices.value.toMutableList() | |||||
| currentDevices.add(newDevice) | |||||
| settingsRepository.saveDevices(currentDevices) | |||||
| _ignoredIps.update { it + ip } | |||||
| selectDevice(newDevice.id) | |||||
| } | |||||
| } | |||||
| fun removeDevice(deviceId: String) { | |||||
| viewModelScope.launch { | |||||
| val deviceToRemove = devices.value.find { it.id == deviceId } | |||||
| val currentDevices = devices.value.filter { it.id != deviceId } | |||||
| settingsRepository.saveDevices(currentDevices) | |||||
| deviceToRemove?.let { device -> | |||||
| _ignoredIps.update { it + device.ip } | |||||
| } | |||||
| if (selectedDeviceId.value == deviceId) { | |||||
| selectDevice(null) | |||||
| } | |||||
| } | |||||
| } | |||||
| 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}") | |||||
| } | |||||
| } | |||||
| settingsRepository.selectDevice(deviceId) | |||||
| } | |||||
| } | |||||
| fun startDiscovery() { | |||||
| _ignoredIps.value = emptySet() | |||||
| discoveryManager.startDiscovery() | |||||
| viewModelScope.launch { | |||||
| kotlinx.coroutines.delay(10000) | |||||
| if (isScanning.value) { | |||||
| stopDiscovery() | |||||
| } | |||||
| } | |||||
| } | |||||
| fun stopDiscovery() { | |||||
| discoveryManager.stopDiscovery() | |||||
| } | |||||
| fun updateThemeMode(mode: Int) { | |||||
| viewModelScope.launch { | |||||
| settingsRepository.saveThemeMode(mode) | |||||
| } | |||||
| } | |||||
| fun updateDynamicColor(enabled: Boolean) { | |||||
| viewModelScope.launch { | |||||
| settingsRepository.saveDynamicColor(enabled) | |||||
| } | |||||
| } | |||||
| fun togglePower() { | |||||
| val currentPower = ledState.value.power | |||||
| repository.setPower(!currentPower) | |||||
| } | |||||
| fun updateColor(r: Int, g: Int, b: Int, w: Int) { | |||||
| repository.setColor(LedColor(r, g, b, w)) | |||||
| } | |||||
| fun updateBrightness(brightness: Int) { | |||||
| repository.setBrightness(brightness) | |||||
| } | |||||
| fun updateMode(mode: LedModeType) { | |||||
| repository.setMode(mode) | |||||
| } | |||||
| fun updateSpeed(speed: Int) { | |||||
| repository.setSpeed(speed) | |||||
| } | |||||
| } |
| # Add project specific R8 rules here. | |||||
| # AGP will combine all keep rule files in src/main/keepRules to pass to R8 | |||||
| # | |||||
| # For more details, see | |||||
| # https://d.android.com/r/tools/r8/keep-rules | |||||
| # If your project uses WebView with JS, uncomment the following | |||||
| # and specify the fully qualified class name to the JavaScript interface | |||||
| # class: | |||||
| #-keepclassmembers class fqcn.of.javascript.interface.for.webview { | |||||
| # public *; | |||||
| #} |
| <?xml version="1.0" encoding="utf-8"?> | |||||
| <vector xmlns:android="http://schemas.android.com/apk/res/android" | |||||
| android:width="108dp" | |||||
| android:height="108dp" | |||||
| android:viewportHeight="108" | |||||
| android:viewportWidth="108"> | |||||
| <path | |||||
| android:fillColor="#3DDC84" | |||||
| android:pathData="M0,0h108v108h-108z" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M9,0L9,108" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M19,0L19,108" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M29,0L29,108" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M39,0L39,108" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M49,0L49,108" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M59,0L59,108" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M69,0L69,108" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M79,0L79,108" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M89,0L89,108" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M99,0L99,108" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M0,9L108,9" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M0,19L108,19" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M0,29L108,29" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M0,39L108,39" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M0,49L108,49" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M0,59L108,59" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M0,69L108,69" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M0,79L108,79" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M0,89L108,89" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M0,99L108,99" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M19,29L89,29" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M19,39L89,39" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M19,49L89,49" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M19,59L89,59" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M19,69L89,69" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M19,79L89,79" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M29,19L29,89" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M39,19L39,89" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M49,19L49,89" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M59,19L59,89" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M69,19L69,89" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| <path | |||||
| android:fillColor="#00000000" | |||||
| android:pathData="M79,19L79,89" | |||||
| android:strokeColor="#33FFFFFF" | |||||
| android:strokeWidth="0.8" /> | |||||
| </vector> |
| <vector xmlns:android="http://schemas.android.com/apk/res/android" | |||||
| xmlns:aapt="http://schemas.android.com/aapt" | |||||
| android:width="108dp" | |||||
| android:height="108dp" | |||||
| android:viewportHeight="108" | |||||
| android:viewportWidth="108"> | |||||
| <path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z"> | |||||
| <aapt:attr name="android:fillColor"> | |||||
| <gradient | |||||
| android:endX="85.84757" | |||||
| android:endY="92.4963" | |||||
| android:startX="42.9492" | |||||
| android:startY="49.59793" | |||||
| android:type="linear"> | |||||
| <item | |||||
| android:color="#44000000" | |||||
| android:offset="0.0" /> | |||||
| <item | |||||
| android:color="#00000000" | |||||
| android:offset="1.0" /> | |||||
| </gradient> | |||||
| </aapt:attr> | |||||
| </path> | |||||
| <path | |||||
| android:fillColor="#FFFFFF" | |||||
| android:fillType="nonZero" | |||||
| android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z" | |||||
| android:strokeColor="#00000000" | |||||
| android:strokeWidth="1" /> | |||||
| </vector> |
| <?xml version="1.0" encoding="utf-8"?> | |||||
| <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> | |||||
| <background android:drawable="@drawable/ic_launcher_background" /> | |||||
| <foreground android:drawable="@drawable/ic_launcher_foreground" /> | |||||
| <monochrome android:drawable="@drawable/ic_launcher_foreground" /> | |||||
| </adaptive-icon> |
| <?xml version="1.0" encoding="utf-8"?> | |||||
| <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> | |||||
| <background android:drawable="@drawable/ic_launcher_background" /> | |||||
| <foreground android:drawable="@drawable/ic_launcher_foreground" /> | |||||
| <monochrome android:drawable="@drawable/ic_launcher_foreground" /> | |||||
| </adaptive-icon> |
| <?xml version="1.0" encoding="utf-8"?> | |||||
| <resources> | |||||
| <color name="purple_200">#FFBB86FC</color> | |||||
| <color name="purple_500">#FF6200EE</color> | |||||
| <color name="purple_700">#FF3700B3</color> | |||||
| <color name="teal_200">#FF03DAC5</color> | |||||
| <color name="teal_700">#FF018786</color> | |||||
| <color name="black">#FF000000</color> | |||||
| <color name="white">#FFFFFFFF</color> | |||||
| </resources> |
| <resources> | |||||
| <string name="app_name">LEDLampControl</string> | |||||
| </resources> |
| <resources> | |||||
| <style name="Theme.LEDLampControl" parent="Theme.MaterialComponents.Light" /> | |||||
| </resources> |
| <?xml version="1.0" encoding="utf-8"?><!-- | |||||
| Sample backup rules file; uncomment and customize as necessary. | |||||
| See https://developer.android.com/guide/topics/data/autobackup | |||||
| for details. | |||||
| Note: This file is ignored for devices older than API 31 | |||||
| See https://developer.android.com/about/versions/12/backup-restore | |||||
| --> | |||||
| <full-backup-content> | |||||
| <!-- | |||||
| <include domain="sharedpref" path="."/> | |||||
| <exclude domain="sharedpref" path="device.xml"/> | |||||
| --> | |||||
| </full-backup-content> |
| <?xml version="1.0" encoding="utf-8"?><!-- | |||||
| Sample data extraction rules file; uncomment and customize as necessary. | |||||
| See https://developer.android.com/about/versions/12/backup-restore#xml-changes | |||||
| for details. | |||||
| --> | |||||
| <data-extraction-rules> | |||||
| <cloud-backup> | |||||
| <!-- TODO: Use <include> and <exclude> to control what is backed up. | |||||
| <include .../> | |||||
| <exclude .../> | |||||
| --> | |||||
| </cloud-backup> | |||||
| <!-- | |||||
| <device-transfer> | |||||
| <include .../> | |||||
| <exclude .../> | |||||
| </device-transfer> | |||||
| --> | |||||
| </data-extraction-rules> |
| package ch.spherIC.ledlampcontrol | |||||
| import org.junit.Test | |||||
| import org.junit.Assert.* | |||||
| /** | |||||
| * Example local unit test, which will execute on the development machine (host). | |||||
| * | |||||
| * See [testing documentation](http://d.android.com/tools/testing). | |||||
| */ | |||||
| class ExampleUnitTest { | |||||
| @Test | |||||
| fun addition_isCorrect() { | |||||
| assertEquals(4, 2 + 2) | |||||
| } | |||||
| } |
| package ch.spherIC.ledlampcontrol.data.model | |||||
| import kotlinx.serialization.encodeToString | |||||
| import kotlinx.serialization.json.Json | |||||
| import kotlinx.serialization.json.encodeToJsonElement | |||||
| import org.junit.Assert.assertEquals | |||||
| import org.junit.Test | |||||
| class LedModelsTest { | |||||
| private val json = Json { | |||||
| ignoreUnknownKeys = true | |||||
| encodeDefaults = true | |||||
| } | |||||
| @Test | |||||
| fun testLedStateSerialization() { | |||||
| val state = LedState( | |||||
| power = true, | |||||
| color = LedColor(255, 128, 64, 32), | |||||
| brightness = 200, | |||||
| mode = LedModeType.RAINBOW, | |||||
| speed = 50 | |||||
| ) | |||||
| val serialized = json.encodeToString(state) | |||||
| val deserialized = json.decodeFromString<LedState>(serialized) | |||||
| assertEquals(state, deserialized) | |||||
| } | |||||
| @Test | |||||
| fun testSetPowerCommandSerialization() { | |||||
| val command = LedCommand.SetPower(status = "on") | |||||
| val jsonElement = json.encodeToJsonElement(command) | |||||
| val expected = """{"cmd":"SWITCH_LED","val":"on"}""" | |||||
| assertEquals(expected, jsonElement.toString()) | |||||
| } | |||||
| @Test | |||||
| fun testLedStateParsingWithStrings() { | |||||
| val jsonString = """{"power":"on","brightness":128}""" | |||||
| val state = json.decodeFromString<LedState>(jsonString) | |||||
| assert(state.power) | |||||
| val jsonStringLedOn = """{"power":"LED ON","brightness":255}""" | |||||
| val stateLedOn = json.decodeFromString<LedState>(jsonStringLedOn) | |||||
| assert(stateLedOn.power) | |||||
| val jsonStringOff = """{"power":"off","brightness":0}""" | |||||
| val stateOff = json.decodeFromString<LedState>(jsonStringOff) | |||||
| assert(!stateOff.power) | |||||
| } | |||||
| @Test | |||||
| fun testLedStateParsingWithBooleans() { | |||||
| val jsonString = """{"power":true}""" | |||||
| val state = json.decodeFromString<LedState>(jsonString) | |||||
| assert(state.power) | |||||
| } | |||||
| } |
| // Top-level build file where you can add configuration options common to all sub-projects/modules. | |||||
| plugins { | |||||
| alias(libs.plugins.android.application) apply false | |||||
| alias(libs.plugins.kotlin.compose) apply false | |||||
| alias(libs.plugins.google.devtools.ksp) apply false | |||||
| alias(libs.plugins.jetbrains.kotlin.plugin.serialization) apply false | |||||
| } |
| # Project-wide Gradle settings. | |||||
| # IDE (e.g. Android Studio) users: | |||||
| # Gradle settings configured through the IDE *will override* | |||||
| # any settings specified in this file. | |||||
| # For more details on how to configure your build environment visit | |||||
| # http://www.gradle.org/docs/current/userguide/build_environment.html | |||||
| # Specifies the JVM arguments used for the daemon process. | |||||
| # The setting is particularly useful for tweaking memory settings. | |||||
| org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 | |||||
| # When configured, Gradle will run in incubating parallel mode. | |||||
| # This option should only be used with decoupled projects. For more details, visit | |||||
| # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects | |||||
| # org.gradle.parallel=true | |||||
| # When enabled, the Configuration Cache allows Gradle to skip the configuration | |||||
| # phase entirely if nothing that affects the build configuration (such as build scripts) | |||||
| # has changed. Additionally, Gradle applies performance optimizations to task execution. | |||||
| org.gradle.configuration-cache=true | |||||
| # Kotlin code style for this project: "official" or "obsolete": | |||||
| kotlin.code.style=official |
| #This file is generated by updateDaemonJvm | |||||
| toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect | |||||
| toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect | |||||
| toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect | |||||
| toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect | |||||
| toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect | |||||
| toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect | |||||
| toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect | |||||
| toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect | |||||
| toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect | |||||
| toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect | |||||
| toolchainVersion=21 |
| [versions] | |||||
| agp = "9.2.1" | |||||
| coreKtx = "1.18.0" | |||||
| junit = "4.13.2" | |||||
| junitVersion = "1.3.0" | |||||
| espressoCore = "3.7.0" | |||||
| lifecycleRuntimeKtx = "2.8.7" | |||||
| lifecycleViewmodelCompose = "2.8.7" | |||||
| lifecycleRuntimeCompose = "2.8.7" | |||||
| activityCompose = "1.10.1" | |||||
| kotlin = "2.2.10" | |||||
| composeBom = "2024.09.00" | |||||
| googleDevtoolsKsp = "2.3.5" | |||||
| jetbrainsKotlinPluginSerialization = "2.2.21" | |||||
| kotlinxSerializationCore = "1.9.0" | |||||
| datastorePreferences = "1.1.2" | |||||
| kotlinxSerializationJson = "1.9.0" | |||||
| navigation3Ui = "1.0.1" | |||||
| navigation3Runtime = "1.0.1" | |||||
| adaptive = "1.3.+" | |||||
| adaptiveLayout = "1.3.+" | |||||
| adaptiveNavigation3 = "1.3.+" | |||||
| lifecycleViewmodelNavigation3 = "2.11.0-beta02" | |||||
| roomRuntime = "2.7.0" | |||||
| roomKtx = "2.7.0" | |||||
| roomCompiler = "2.7.0" | |||||
| kotlinxCoroutinesTest = "1.10.2" | |||||
| core = "1.6.1" | |||||
| runner = "1.6.2" | |||||
| coilCompose = "2.7.0" | |||||
| retrofit = "2.12.0" | |||||
| converterMoshi = "2.12.0" | |||||
| kotlinxCoroutinesAndroid = "1.10.2" | |||||
| kotlinxCoroutinesCore = "1.10.2" | |||||
| accompanistPermissions = "0.37.3" | |||||
| playServicesLocation = "21.3.0" | |||||
| cameraCamera2 = "1.5.0" | |||||
| cameraLifecycle = "1.5.0" | |||||
| cameraView = "1.5.0" | |||||
| cameraCore = "1.5.0" | |||||
| loggingInterceptor = "4.10.0" | |||||
| okhttp = "4.10.0" | |||||
| moshiKotlin = "1.15.2" | |||||
| moshiKotlinCodegen = "1.15.2" | |||||
| material = "1.4.0" | |||||
| [libraries] | |||||
| androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } | |||||
| junit = { group = "junit", name = "junit", version.ref = "junit" } | |||||
| androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } | |||||
| androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } | |||||
| androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } | |||||
| androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } | |||||
| androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeCompose" } | |||||
| androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } | |||||
| androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } | |||||
| androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } | |||||
| androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } | |||||
| androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } | |||||
| androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } | |||||
| androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } | |||||
| androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } | |||||
| kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "kotlinxSerializationCore" } | |||||
| kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } | |||||
| androidx-navigation3-ui = { group = "androidx.navigation3", name = "navigation3-ui", version.ref = "navigation3Ui" } | |||||
| androidx-navigation3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime", version.ref = "navigation3Runtime" } | |||||
| androidx-compose-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive", version.ref = "adaptive" } | |||||
| androidx-compose-adaptive-layout = { group = "androidx.compose.material3.adaptive", name = "adaptive-layout", version.ref = "adaptiveLayout" } | |||||
| androidx-compose-adaptive-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3", version.ref = "adaptiveNavigation3" } | |||||
| androidx-lifecycle-viewmodel-navigation3 = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNavigation3" } | |||||
| androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "roomRuntime" } | |||||
| androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "roomKtx" } | |||||
| androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "roomCompiler" } | |||||
| kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } | |||||
| androidx-core = { group = "androidx.test", name = "core", version.ref = "core" } | |||||
| androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } | |||||
| androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } | |||||
| androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" } | |||||
| androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } | |||||
| coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coilCompose" } | |||||
| retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } | |||||
| converter-moshi = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "converterMoshi" } | |||||
| kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutinesAndroid" } | |||||
| kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } | |||||
| accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistPermissions" } | |||||
| play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" } | |||||
| androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "cameraCamera2" } | |||||
| androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraLifecycle" } | |||||
| androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "cameraView" } | |||||
| androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "cameraCore" } | |||||
| logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "loggingInterceptor" } | |||||
| okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } | |||||
| moshi-kotlin = { group = "com.squareup.moshi", name = "moshi-kotlin", version.ref = "moshiKotlin" } | |||||
| moshi-kotlin-codegen = { group = "com.squareup.moshi", name = "moshi-kotlin-codegen", version.ref = "moshiKotlinCodegen" } | |||||
| androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePreferences" } | |||||
| material = { group = "com.google.android.material", name = "material", version.ref = "material" } | |||||
| [plugins] | |||||
| android-application = { id = "com.android.application", version.ref = "agp" } | |||||
| kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } | |||||
| google-devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "googleDevtoolsKsp" } | |||||
| jetbrains-kotlin-plugin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "jetbrainsKotlinPluginSerialization" } |
| #Thu Jul 16 20:01:24 CEST 2026 | |||||
| distributionBase=GRADLE_USER_HOME | |||||
| distributionPath=wrapper/dists | |||||
| distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip | |||||
| zipStoreBase=GRADLE_USER_HOME | |||||
| zipStorePath=wrapper/dists |
| #!/bin/sh | |||||
| # | |||||
| # Copyright © 2015 the original authors. | |||||
| # | |||||
| # Licensed under the Apache License, Version 2.0 (the "License"); | |||||
| # you may not use this file except in compliance with the License. | |||||
| # You may obtain a copy of the License at | |||||
| # | |||||
| # https://www.apache.org/licenses/LICENSE-2.0 | |||||
| # | |||||
| # Unless required by applicable law or agreed to in writing, software | |||||
| # distributed under the License is distributed on an "AS IS" BASIS, | |||||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||||
| # See the License for the specific language governing permissions and | |||||
| # limitations under the License. | |||||
| # | |||||
| # SPDX-License-Identifier: Apache-2.0 | |||||
| # | |||||
| ############################################################################## | |||||
| # | |||||
| # Gradle start up script for POSIX generated by Gradle. | |||||
| # | |||||
| # Important for running: | |||||
| # | |||||
| # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is | |||||
| # noncompliant, but you have some other compliant shell such as ksh or | |||||
| # bash, then to run this script, type that shell name before the whole | |||||
| # command line, like: | |||||
| # | |||||
| # ksh Gradle | |||||
| # | |||||
| # Busybox and similar reduced shells will NOT work, because this script | |||||
| # requires all of these POSIX shell features: | |||||
| # * functions; | |||||
| # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», | |||||
| # «${var#prefix}», «${var%suffix}», and «$( cmd )»; | |||||
| # * compound commands having a testable exit status, especially «case»; | |||||
| # * various built-in commands including «command», «set», and «ulimit». | |||||
| # | |||||
| # Important for patching: | |||||
| # | |||||
| # (2) This script targets any POSIX shell, so it avoids extensions provided | |||||
| # by Bash, Ksh, etc; in particular arrays are avoided. | |||||
| # | |||||
| # The "traditional" practice of packing multiple parameters into a | |||||
| # space-separated string is a well documented source of bugs and security | |||||
| # problems, so this is (mostly) avoided, by progressively accumulating | |||||
| # options in "$@", and eventually passing that to Java. | |||||
| # | |||||
| # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, | |||||
| # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; | |||||
| # see the in-line comments for details. | |||||
| # | |||||
| # There are tweaks for specific operating systems such as AIX, CygWin, | |||||
| # Darwin, MinGW, and NonStop. | |||||
| # | |||||
| # (3) This script is generated from the Groovy template | |||||
| # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt | |||||
| # within the Gradle project. | |||||
| # | |||||
| # You can find Gradle at https://github.com/gradle/gradle/. | |||||
| # | |||||
| ############################################################################## | |||||
| # Attempt to set APP_HOME | |||||
| # Resolve links: $0 may be a link | |||||
| app_path=$0 | |||||
| # Need this for daisy-chained symlinks. | |||||
| while | |||||
| APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path | |||||
| [ -h "$app_path" ] | |||||
| do | |||||
| ls=$( ls -ld "$app_path" ) | |||||
| link=${ls#*' -> '} | |||||
| case $link in #( | |||||
| /*) app_path=$link ;; #( | |||||
| *) app_path=$APP_HOME$link ;; | |||||
| esac | |||||
| done | |||||
| # This is normally unused | |||||
| # shellcheck disable=SC2034 | |||||
| APP_BASE_NAME=${0##*/} | |||||
| # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) | |||||
| APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit | |||||
| # Use the maximum available, or set MAX_FD != -1 to use that value. | |||||
| MAX_FD=maximum | |||||
| warn () { | |||||
| echo "$*" | |||||
| } >&2 | |||||
| die () { | |||||
| echo | |||||
| echo "$*" | |||||
| echo | |||||
| exit 1 | |||||
| } >&2 | |||||
| # OS specific support (must be 'true' or 'false'). | |||||
| cygwin=false | |||||
| msys=false | |||||
| darwin=false | |||||
| nonstop=false | |||||
| case "$( uname )" in #( | |||||
| CYGWIN* ) cygwin=true ;; #( | |||||
| Darwin* ) darwin=true ;; #( | |||||
| MSYS* | MINGW* ) msys=true ;; #( | |||||
| NONSTOP* ) nonstop=true ;; | |||||
| esac | |||||
| CLASSPATH="\\\"\\\"" | |||||
| # Determine the Java command to use to start the JVM. | |||||
| if [ -n "$JAVA_HOME" ] ; then | |||||
| if [ -x "$JAVA_HOME/jre/sh/java" ] ; then | |||||
| # IBM's JDK on AIX uses strange locations for the executables | |||||
| JAVACMD=$JAVA_HOME/jre/sh/java | |||||
| else | |||||
| JAVACMD=$JAVA_HOME/bin/java | |||||
| fi | |||||
| if [ ! -x "$JAVACMD" ] ; then | |||||
| die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME | |||||
| Please set the JAVA_HOME variable in your environment to match the | |||||
| location of your Java installation." | |||||
| fi | |||||
| else | |||||
| JAVACMD=java | |||||
| if ! command -v java >/dev/null 2>&1 | |||||
| then | |||||
| die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. | |||||
| Please set the JAVA_HOME variable in your environment to match the | |||||
| location of your Java installation." | |||||
| fi | |||||
| fi | |||||
| # Increase the maximum file descriptors if we can. | |||||
| if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then | |||||
| case $MAX_FD in #( | |||||
| max*) | |||||
| # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. | |||||
| # shellcheck disable=SC2039,SC3045 | |||||
| MAX_FD=$( ulimit -H -n ) || | |||||
| warn "Could not query maximum file descriptor limit" | |||||
| esac | |||||
| case $MAX_FD in #( | |||||
| '' | soft) :;; #( | |||||
| *) | |||||
| # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. | |||||
| # shellcheck disable=SC2039,SC3045 | |||||
| ulimit -n "$MAX_FD" || | |||||
| warn "Could not set maximum file descriptor limit to $MAX_FD" | |||||
| esac | |||||
| fi | |||||
| # Collect all arguments for the java command, stacking in reverse order: | |||||
| # * args from the command line | |||||
| # * the main class name | |||||
| # * -classpath | |||||
| # * -D...appname settings | |||||
| # * --module-path (only if needed) | |||||
| # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. | |||||
| # For Cygwin or MSYS, switch paths to Windows format before running java | |||||
| if "$cygwin" || "$msys" ; then | |||||
| APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) | |||||
| CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) | |||||
| JAVACMD=$( cygpath --unix "$JAVACMD" ) | |||||
| # Now convert the arguments - kludge to limit ourselves to /bin/sh | |||||
| for arg do | |||||
| if | |||||
| case $arg in #( | |||||
| -*) false ;; # don't mess with options #( | |||||
| /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath | |||||
| [ -e "$t" ] ;; #( | |||||
| *) false ;; | |||||
| esac | |||||
| then | |||||
| arg=$( cygpath --path --ignore --mixed "$arg" ) | |||||
| fi | |||||
| # Roll the args list around exactly as many times as the number of | |||||
| # args, so each arg winds up back in the position where it started, but | |||||
| # possibly modified. | |||||
| # | |||||
| # NB: a `for` loop captures its iteration list before it begins, so | |||||
| # changing the positional parameters here affects neither the number of | |||||
| # iterations, nor the values presented in `arg`. | |||||
| shift # remove old arg | |||||
| set -- "$@" "$arg" # push replacement arg | |||||
| done | |||||
| fi | |||||
| # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. | |||||
| DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' | |||||
| # Collect all arguments for the java command: | |||||
| # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, | |||||
| # and any embedded shellness will be escaped. | |||||
| # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be | |||||
| # treated as '${Hostname}' itself on the command line. | |||||
| set -- \ | |||||
| "-Dorg.gradle.appname=$APP_BASE_NAME" \ | |||||
| -classpath "$CLASSPATH" \ | |||||
| -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ | |||||
| "$@" | |||||
| # Stop when "xargs" is not available. | |||||
| if ! command -v xargs >/dev/null 2>&1 | |||||
| then | |||||
| die "xargs is not available" | |||||
| fi | |||||
| # Use "xargs" to parse quoted args. | |||||
| # | |||||
| # With -n1 it outputs one arg per line, with the quotes and backslashes removed. | |||||
| # | |||||
| # In Bash we could simply go: | |||||
| # | |||||
| # readarray ARGS < <( xargs -n1 <<<"$var" ) && | |||||
| # set -- "${ARGS[@]}" "$@" | |||||
| # | |||||
| # but POSIX shell has neither arrays nor command substitution, so instead we | |||||
| # post-process each arg (as a line of input to sed) to backslash-escape any | |||||
| # character that might be a shell metacharacter, then use eval to reverse | |||||
| # that process (while maintaining the separation between arguments), and wrap | |||||
| # the whole thing up as a single "set" statement. | |||||
| # | |||||
| # This will of course break if any of these variables contains a newline or | |||||
| # an unmatched quote. | |||||
| # | |||||
| eval "set -- $( | |||||
| printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | | |||||
| xargs -n1 | | |||||
| sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | | |||||
| tr '\n' ' ' | |||||
| )" '"$@"' | |||||
| exec "$JAVACMD" "$@" |
| @rem | |||||
| @rem Copyright 2015 the original author or authors. | |||||
| @rem | |||||
| @rem Licensed under the Apache License, Version 2.0 (the "License"); | |||||
| @rem you may not use this file except in compliance with the License. | |||||
| @rem You may obtain a copy of the License at | |||||
| @rem | |||||
| @rem https://www.apache.org/licenses/LICENSE-2.0 | |||||
| @rem | |||||
| @rem Unless required by applicable law or agreed to in writing, software | |||||
| @rem distributed under the License is distributed on an "AS IS" BASIS, | |||||
| @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |||||
| @rem See the License for the specific language governing permissions and | |||||
| @rem limitations under the License. | |||||
| @rem | |||||
| @rem SPDX-License-Identifier: Apache-2.0 | |||||
| @rem | |||||
| @if "%DEBUG%"=="" @echo off | |||||
| @rem ########################################################################## | |||||
| @rem | |||||
| @rem Gradle startup script for Windows | |||||
| @rem | |||||
| @rem ########################################################################## | |||||
| @rem Set local scope for the variables with windows NT shell | |||||
| if "%OS%"=="Windows_NT" setlocal | |||||
| set DIRNAME=%~dp0 | |||||
| if "%DIRNAME%"=="" set DIRNAME=. | |||||
| @rem This is normally unused | |||||
| set APP_BASE_NAME=%~n0 | |||||
| set APP_HOME=%DIRNAME% | |||||
| @rem Resolve any "." and ".." in APP_HOME to make it shorter. | |||||
| for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi | |||||
| @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. | |||||
| set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" | |||||
| @rem Find java.exe | |||||
| if defined JAVA_HOME goto findJavaFromJavaHome | |||||
| set JAVA_EXE=java.exe | |||||
| %JAVA_EXE% -version >NUL 2>&1 | |||||
| if %ERRORLEVEL% equ 0 goto execute | |||||
| echo. 1>&2 | |||||
| echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 | |||||
| echo. 1>&2 | |||||
| echo Please set the JAVA_HOME variable in your environment to match the 1>&2 | |||||
| echo location of your Java installation. 1>&2 | |||||
| goto fail | |||||
| :findJavaFromJavaHome | |||||
| set JAVA_HOME=%JAVA_HOME:"=% | |||||
| set JAVA_EXE=%JAVA_HOME%/bin/java.exe | |||||
| if exist "%JAVA_EXE%" goto execute | |||||
| echo. 1>&2 | |||||
| echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 | |||||
| echo. 1>&2 | |||||
| echo Please set the JAVA_HOME variable in your environment to match the 1>&2 | |||||
| echo location of your Java installation. 1>&2 | |||||
| goto fail | |||||
| :execute | |||||
| @rem Setup the command line | |||||
| set CLASSPATH= | |||||
| @rem Execute Gradle | |||||
| "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* | |||||
| :end | |||||
| @rem End local scope for the variables with windows NT shell | |||||
| if %ERRORLEVEL% equ 0 goto mainEnd | |||||
| :fail | |||||
| rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of | |||||
| rem the _cmd.exe /c_ return code! | |||||
| set EXIT_CODE=%ERRORLEVEL% | |||||
| if %EXIT_CODE% equ 0 set EXIT_CODE=1 | |||||
| if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% | |||||
| exit /b %EXIT_CODE% | |||||
| :mainEnd | |||||
| if "%OS%"=="Windows_NT" endlocal | |||||
| :omega |
| pluginManagement { | |||||
| repositories { | |||||
| google { | |||||
| content { | |||||
| includeGroupByRegex("com\\.android.*") | |||||
| includeGroupByRegex("com\\.google.*") | |||||
| includeGroupByRegex("androidx.*") | |||||
| } | |||||
| } | |||||
| mavenCentral() | |||||
| gradlePluginPortal() | |||||
| } | |||||
| } | |||||
| plugins { | |||||
| id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" | |||||
| } | |||||
| dependencyResolutionManagement { | |||||
| repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) | |||||
| repositories { | |||||
| google() | |||||
| mavenCentral() | |||||
| } | |||||
| } | |||||
| rootProject.name = "LEDLampControl" | |||||
| include(":app") | |||||