| @@ -0,0 +1 @@ | |||
| plan.md | |||
| @@ -0,0 +1,80 @@ | |||
| # 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 | |||
| @@ -0,0 +1,15 @@ | |||
| *.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 | |||
| @@ -0,0 +1,3 @@ | |||
| # Default ignored files | |||
| /shelf/ | |||
| /workspace.xml | |||
| @@ -0,0 +1,6 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <project version="4"> | |||
| <component name="AndroidProjectSystem"> | |||
| <option name="providerId" value="com.android.tools.idea.GradleProjectSystem" /> | |||
| </component> | |||
| </project> | |||
| @@ -0,0 +1,6 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <project version="4"> | |||
| <component name="CompilerConfiguration"> | |||
| <bytecodeTargetLevel target="21" /> | |||
| </component> | |||
| </project> | |||
| @@ -0,0 +1,37 @@ | |||
| <?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> | |||
| @@ -0,0 +1,13 @@ | |||
| <?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> | |||
| @@ -0,0 +1,18 @@ | |||
| <?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> | |||
| @@ -0,0 +1,61 @@ | |||
| <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> | |||
| @@ -0,0 +1,8 @@ | |||
| <?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> | |||
| @@ -0,0 +1,10 @@ | |||
| <?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> | |||
| @@ -0,0 +1,17 @@ | |||
| <?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> | |||
| @@ -0,0 +1 @@ | |||
| /build | |||
| @@ -0,0 +1,91 @@ | |||
| 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) | |||
| } | |||
| @@ -0,0 +1,24 @@ | |||
| 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) | |||
| } | |||
| } | |||
| @@ -0,0 +1,38 @@ | |||
| <?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> | |||
| @@ -0,0 +1,85 @@ | |||
| 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() | |||
| } | |||
| } | |||
| @@ -0,0 +1,96 @@ | |||
| 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() | |||
| } | |||
| @@ -0,0 +1,124 @@ | |||
| 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() | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,107 @@ | |||
| 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() | |||
| } | |||
| } | |||
| @@ -0,0 +1,47 @@ | |||
| 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) | |||
| } | |||
| } | |||
| @@ -0,0 +1,78 @@ | |||
| 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 | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,125 @@ | |||
| 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 | |||
| } | |||
| @@ -0,0 +1,631 @@ | |||
| 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 = {} | |||
| ) | |||
| } | |||
| } | |||
| @@ -0,0 +1,11 @@ | |||
| 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) | |||
| @@ -0,0 +1,61 @@ | |||
| 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 | |||
| ) | |||
| } | |||
| @@ -0,0 +1,34 @@ | |||
| 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 | |||
| ) | |||
| */ | |||
| ) | |||
| @@ -0,0 +1,152 @@ | |||
| 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) | |||
| } | |||
| } | |||
| @@ -0,0 +1,12 @@ | |||
| # 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 *; | |||
| #} | |||
| @@ -0,0 +1,170 @@ | |||
| <?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> | |||
| @@ -0,0 +1,30 @@ | |||
| <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> | |||
| @@ -0,0 +1,6 @@ | |||
| <?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> | |||
| @@ -0,0 +1,6 @@ | |||
| <?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> | |||
| @@ -0,0 +1,10 @@ | |||
| <?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> | |||
| @@ -0,0 +1,3 @@ | |||
| <resources> | |||
| <string name="app_name">LEDLampControl</string> | |||
| </resources> | |||
| @@ -0,0 +1,4 @@ | |||
| <resources> | |||
| <style name="Theme.LEDLampControl" parent="Theme.MaterialComponents.Light" /> | |||
| </resources> | |||
| @@ -0,0 +1,13 @@ | |||
| <?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> | |||
| @@ -0,0 +1,19 @@ | |||
| <?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> | |||
| @@ -0,0 +1,17 @@ | |||
| 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) | |||
| } | |||
| } | |||
| @@ -0,0 +1,60 @@ | |||
| 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) | |||
| } | |||
| } | |||
| @@ -0,0 +1,7 @@ | |||
| // 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 | |||
| } | |||
| @@ -0,0 +1,19 @@ | |||
| # 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 | |||
| @@ -0,0 +1,12 @@ | |||
| #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 | |||
| @@ -0,0 +1,102 @@ | |||
| [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" } | |||
| @@ -0,0 +1,6 @@ | |||
| #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 | |||
| @@ -0,0 +1,251 @@ | |||
| #!/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" "$@" | |||
| @@ -0,0 +1,94 @@ | |||
| @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 | |||
| @@ -0,0 +1,27 @@ | |||
| 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") | |||