commit af503c04f8a59738c6becca87a92c6c4f53b1731 Author: C0ffeeCode Date: Mon Sep 2 11:08:23 2024 +0200 test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..99029d8 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +Home Native \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..4bec4ea --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,117 @@ + + + + + + \ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..a55e7a1 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b589d56 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..b268ef3 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..0897082 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..44ca2d9 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,41 @@ + + + + \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml new file mode 100644 index 0000000..0a8358a --- /dev/null +++ b/.idea/kotlinc.xml @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..8978d23 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..2e77c5d --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,24 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "android", + "request": "launch", + "name": "Android launch", + "appSrcRoot": "${workspaceRoot}/app/src/main", + "apkFile": "${workspaceRoot}/app/build/outputs/apk/debug/app-debug.apk", + "adbPort": 5037 + }, + { + "type": "android", + "request": "attach", + "name": "Android attach", + "appSrcRoot": "${workspaceRoot}/app/src/main", + "adbPort": 5037, + "processId": "${command:PickAndroidProcess}" + } + ] +} \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..2c223a6 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,86 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.jetbrains.kotlin.android) + alias(libs.plugins.compose.compiler) + kotlin("plugin.serialization") +} + +android { + namespace = "dev.coffeeco.homenative" + compileSdk = 35 + + defaultConfig { + applicationId = "dev.coffeeco.homenative" + minSdk = 33 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + debug { + kotlinOptions { + freeCompilerArgs = listOf("-Xdebug") + } + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.15" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.navigation.compose) + implementation(libs.ktor.client.websockets) + implementation(libs.ktor.client.serialization) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.kotlinx.serialization.json) + implementation(libs.androidx.datastore.preferences) + implementation(libs.ktor.client.okhttp) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# 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 *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/dev/coffeeco/homenative/ExampleInstrumentedTest.kt b/app/src/androidTest/java/dev/coffeeco/homenative/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..d2dc89f --- /dev/null +++ b/app/src/androidTest/java/dev/coffeeco/homenative/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package dev.coffeeco.homenative + +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("dev.coffeeco.homenative", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6e46188 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/dev/coffeeco/homenative/MainActivity.kt b/app/src/main/java/dev/coffeeco/homenative/MainActivity.kt new file mode 100644 index 0000000..8ec0d05 --- /dev/null +++ b/app/src/main/java/dev/coffeeco/homenative/MainActivity.kt @@ -0,0 +1,140 @@ +package dev.coffeeco.homenative + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import dev.coffeeco.homenative.data.HomeClient +import dev.coffeeco.homenative.data.obtainClient +import dev.coffeeco.homenative.data.setInstance +import dev.coffeeco.homenative.ui.ControlContainer +import dev.coffeeco.homenative.ui.SignInUI +import dev.coffeeco.homenative.ui.theme.HomeNativeTheme +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking + +class MainActivity : ComponentActivity() { + private var homeClient by mutableStateOf(null) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + setContent { + HomeNativeTheme { + AppContainer() + } + } + } + + @Composable + private fun AppContainer() { + val ctx = LocalContext.current + if (homeClient == null) { + val cf = obtainClient(ctx) + runBlocking { + homeClient = cf.first() + } + homeClient!!.connect(rememberCoroutineScope()) + } + + val onLoginSubmit: suspend (String, String) -> Unit = { url: String, token: String -> + setInstance(ctx, url, token, refreshToken = null) + homeClient = HomeClient(url, token) + } + + if (homeClient == null) { + SignInUI(onSubmit = onLoginSubmit) + } else { + AppNavigation() + } + } + + @Composable + @Preview(showBackground = true) + @OptIn(ExperimentalMaterial3Api::class) + private fun AppNavigation() { + val scrollBehavior = + TopAppBarDefaults.enterAlwaysScrollBehavior(snapAnimationSpec = spring(stiffness = Spring.StiffnessLow)) + + Scaffold( + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { + AppTopBar(scrollBehavior) + }, + bottomBar = { + AppBottomNav() + }, + ) { innerPadding -> + ControlContainer( + homeClient = homeClient!!, modifier = Modifier.padding(innerPadding) + ) + } + } +} + +@Composable +private fun AppBottomNav() { + BottomAppBar { + NavigationBar { + NavigationBarItem( + icon = { + Icon( + imageVector = Icons.Filled.Menu, contentDescription = "Open Menu" + ) + }, + label = { + Text("Text") + }, + onClick = { /**/ }, + selected = true, + ) + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun AppTopBar(scrollBehavior: TopAppBarScrollBehavior) { + LargeTopAppBar(title = { + Text("Top app bar") + }, navigationIcon = { + IconButton(onClick = { /* do something */ }) { + Icon( + imageVector = Icons.Filled.Menu, contentDescription = "Open Menu" + ) + } + }, actions = { + IconButton(onClick = { /* do something */ }) { + Icon( + imageVector = Icons.Filled.Menu, contentDescription = "Localized description" + ) + } + }, scrollBehavior = scrollBehavior + ) +} diff --git a/app/src/main/java/dev/coffeeco/homenative/data/Authentication.kt b/app/src/main/java/dev/coffeeco/homenative/data/Authentication.kt new file mode 100644 index 0000000..5f06dfa --- /dev/null +++ b/app/src/main/java/dev/coffeeco/homenative/data/Authentication.kt @@ -0,0 +1,35 @@ +package dev.coffeeco.homenative.data + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.dataStore: DataStore by preferencesDataStore("settings") +private val SERVER_URL = stringPreferencesKey("server_url") +private val ACCESS_TOKEN = stringPreferencesKey("access_token") +private val REFRESH_TOKEN = stringPreferencesKey("refresh_token") + +fun obtainClient(context: Context): Flow { + return context.dataStore.data.map { pref -> + val url = pref[SERVER_URL] + val token = pref[ACCESS_TOKEN] + if (url == null || token == null) null + else HomeClient(url, token) + } +} + +/** + * @param refreshToken is null if [accessToken] is long-living + */ +suspend fun setInstance(context: Context, url: String, accessToken: String, refreshToken: String?) { + context.dataStore.edit { preferences -> + preferences[SERVER_URL] = url + preferences[ACCESS_TOKEN] = accessToken + preferences[REFRESH_TOKEN] = refreshToken ?: "" + } +} diff --git a/app/src/main/java/dev/coffeeco/homenative/data/HomeClient.kt b/app/src/main/java/dev/coffeeco/homenative/data/HomeClient.kt new file mode 100644 index 0000000..ebb040e --- /dev/null +++ b/app/src/main/java/dev/coffeeco/homenative/data/HomeClient.kt @@ -0,0 +1,102 @@ +package dev.coffeeco.homenative.data + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.DefaultRequest +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.plugins.websocket.receiveDeserialized +import io.ktor.client.plugins.websocket.sendSerialized +import io.ktor.client.plugins.websocket.webSocket +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.KotlinxWebsocketSerializationConverter +import io.ktor.serialization.kotlinx.json.json +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json + +class HomeClient(private val baseUrl: String, private val accessToken: String) { + private val client: HttpClient = HttpClient(OkHttp) { + install(WebSockets) { + contentConverter = KotlinxWebsocketSerializationConverter(Json) + } + install(ContentNegotiation) { + json(Json { // isLenient = true + // useArrayPolymorphism = true + allowStructuredMapKeys = true + ignoreUnknownKeys = true // explicitNulls = false + // namingStrategy = JsonNamingStrategy.SnakeCase + }) + } + + install(DefaultRequest) { + header(HttpHeaders.Authorization, "Bearer $accessToken") + } + } + + suspend fun apiStatus(): HttpResponse { + val res = client.get("$baseUrl/api/") + return res + } + + private suspend fun getState(): List { + val res = client.get("$baseUrl/api/states") + val body = res.body>() + + return body + } + + suspend fun getUsefulState(): List { + val all = getState() + val filtered = all.filter { i -> i.entityId.startsWith("switch.") } + return filtered + } + + private suspend fun updateService( + entityId: String, domain: String, service: String + ): HttpResponse { + return client.post("$baseUrl/api/services/$domain/$service") { + contentType(ContentType.Application.Json) + setBody(PostServiceBody(entityId)) + } + } + + suspend fun updateSwitch(entityId: String, service: String) { + if (service !in switchServices) { + throw Exception("Illegal/unknown service was specified") + } + val res = updateService(entityId, "switch", service) + } + + fun connect(coroutineScope: CoroutineScope) { + GlobalScope.launch { + client.webSocket( + "${baseUrl.replace("http", "ws")}/api/websocket" + ) { + var iteratingNumber = 0 + var isReady = false + while (true) { + val data = receiveDeserialized() + if (data.type == "auth_required") sendSerialized(WSAuth("auth", accessToken)) + if (data.type == "auth_ok") { + isReady = true + sendSerialized(WSSubscribe(iteratingNumber, "state_changed")) + } + println(data) + iteratingNumber++ + } + } + } + } +} + +val switchServices = listOf("turn_off", "turn_on", "toggle") diff --git a/app/src/main/java/dev/coffeeco/homenative/data/Requests.kt b/app/src/main/java/dev/coffeeco/homenative/data/Requests.kt new file mode 100644 index 0000000..8a6dbfa --- /dev/null +++ b/app/src/main/java/dev/coffeeco/homenative/data/Requests.kt @@ -0,0 +1,40 @@ +package dev.coffeeco.homenative.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PostServiceBody( + @SerialName("entity_id") val entityId: String +) + +@Serializable +/** + * Special since `id` field must be omitted in contrast to [WebSocketMessage.id] + */ +data class WSAuth( + @Serializable + @SerialName("type") + val type: String, + @SerialName("access_token") + val accessToken: String, +) + +@Serializable +abstract class WebSocketMessage { + @SerialName("type") + abstract val type: String + + @SerialName("id") + abstract val id: Int +} + +@Serializable +data class WSSubscribe ( + override val id: Int, + @SerialName("event_type") + val eventType: String? +): WebSocketMessage() { + override val type: String + get() = "subscribe_events" +} diff --git a/app/src/main/java/dev/coffeeco/homenative/data/Responses.kt b/app/src/main/java/dev/coffeeco/homenative/data/Responses.kt new file mode 100644 index 0000000..8134f07 --- /dev/null +++ b/app/src/main/java/dev/coffeeco/homenative/data/Responses.kt @@ -0,0 +1,94 @@ +package dev.coffeeco.homenative.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +@Serializable +data class AuthRes( + @SerialName("access_token") val accessToken: String, + @SerialName("expires_in") val expiresIn: Int, /// Not present when using the refresh token + @SerialName("refresh_token") val refreshToken: String? = null, /// This should always be "Bearer" + @SerialName("token_type") val tokenType: String +) + +@Serializable +data class StatesResItem( + val attributes: StatesResItemAttributes, + val context: StatesResItemContext? = null, + @SerialName("entity_id") val entityId: String, + @SerialName("last_changed") val lastChanged: String, + @SerialName("last_reported") val lastReported: String? = null, + @SerialName("last_updated") val lastUpdated: String? = null, + val state: String +) { + fun isOn(): Boolean? { + if (state == "on") return true + if (state == "off") return false + + return null + } +} + +@Serializable /// NOTE: this property is affected by polymorphism +data class StatesResItemAttributes( + @SerialName("device_trackers") val deviceTrackers: List? = null, + val editable: Boolean? = null, + @SerialName("friendly_name") val friendlyName: String = "unnamed state", + val id: String? = null, + @SerialName("user_id") val userId: String? = null +) + +@Serializable +data class StatesResItemContext( + val id: String, + @SerialName("parent_id") val parentId: String?, + @SerialName("user_id") val userId: String? +) + +@Serializable +sealed class WebSocketIncoming { + @SerialName("type") + abstract val type: String +} + +@Serializable +@SerialName("auth_required") +data class WebSocketAuthRequired( + @SerialName("ha_version") + val homeAssistantVersion: String, +) : WebSocketIncoming() { + override val type: String + get() = "auth_required" +} + +@Serializable +@SerialName("auth_ok") +data class WebSocketAuthOk( + @SerialName("ha_version") + val homeAssistantVersion: String +) : WebSocketIncoming() { + override val type: String + get() = "auth_ok" +} + +@Serializable +@SerialName("auth_invalid") +data class WebSocketAuthInvalid( + val message: String +) : WebSocketIncoming() { + override val type: String + get() = "auth_invalid" +} + +@Serializable +@SerialName("result") +data class WebSocketResult( + val id: Int, + val success: Boolean, + val result: JsonObject? = null, + val error: JsonObject? = null +) : WebSocketIncoming() { + override val type: String + get() = "result" +} diff --git a/app/src/main/java/dev/coffeeco/homenative/ui/ControlContainer.kt b/app/src/main/java/dev/coffeeco/homenative/ui/ControlContainer.kt new file mode 100644 index 0000000..050de4a --- /dev/null +++ b/app/src/main/java/dev/coffeeco/homenative/ui/ControlContainer.kt @@ -0,0 +1,52 @@ +package dev.coffeeco.homenative.ui + +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.coffeeco.homenative.data.HomeClient +import dev.coffeeco.homenative.data.StatesResItem + +@Composable +fun ControlContainer(homeClient: HomeClient, modifier: Modifier = Modifier) { + val sm = modifier + .scrollable( + orientation = Orientation.Vertical, state = rememberScrollState() + ) + .padding(horizontal = 8.dp) + + var itemListState by remember { mutableStateOf>(emptyList()) } + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(coroutineScope) { + itemListState = homeClient.getUsefulState() + } + + if (itemListState.isEmpty()) { + // Never visible? + repeat(100) { Column { Text("aaa") } } + } else { + LazyVerticalGrid( + modifier = sm, + columns = GridCells.Adaptive(minSize = 150.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + itemListState.forEach { i -> item(key = i.entityId) { ControllingCard(homeClient, i) } } + } + } +} diff --git a/app/src/main/java/dev/coffeeco/homenative/ui/ControllingCard.kt b/app/src/main/java/dev/coffeeco/homenative/ui/ControllingCard.kt new file mode 100644 index 0000000..032fd2a --- /dev/null +++ b/app/src/main/java/dev/coffeeco/homenative/ui/ControllingCard.kt @@ -0,0 +1,54 @@ +package dev.coffeeco.homenative.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.coffeeco.homenative.data.HomeClient +import dev.coffeeco.homenative.data.StatesResItem +import kotlinx.coroutines.launch + +@Composable +fun ControllingCard(homeClient: HomeClient, state: StatesResItem) { + val coroutineScope = rememberCoroutineScope() + val color = if (state.isOn() == true) MaterialTheme.colorScheme.primaryContainer + else if (state.isOn() == false) MaterialTheme.colorScheme.secondaryContainer + else MaterialTheme.colorScheme.surfaceContainerHigh + + Card( + modifier = Modifier + .height(150.dp) + .clickable { + println("hi") + coroutineScope.launch { + homeClient.updateSwitch(state.entityId, "toggle") + } + }, + colors = CardDefaults.cardColors( + containerColor = color + ), + ) { + Spacer(Modifier.weight(1f)) + Column(modifier = Modifier.padding(10.dp)) { + Text(text = state.state) + } + Column(modifier = Modifier.padding(10.dp)) { + Text( + style = TextStyle(fontWeight = FontWeight.Black, fontSize = 15.sp), + text = state.attributes.friendlyName + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/coffeeco/homenative/ui/Screens.kt b/app/src/main/java/dev/coffeeco/homenative/ui/Screens.kt new file mode 100644 index 0000000..f488ae7 --- /dev/null +++ b/app/src/main/java/dev/coffeeco/homenative/ui/Screens.kt @@ -0,0 +1,20 @@ +package dev.coffeeco.homenative.ui + +import androidx.annotation.StringRes +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.ui.graphics.vector.ImageVector +import dev.coffeeco.homenative.R + +sealed class Screen( + val route: String, + @StringRes val resourceId: Int, + val navIcon: ImageVector?, + val navIconSelected: ImageVector?, +) { + data object AppList : + Screen("app_list", R.string.app_name, Icons.Outlined.Settings, Icons.Rounded.Settings) + + data object Settings : Screen("settings", R.string.app_name, null, Icons.Rounded.Settings) +} \ No newline at end of file diff --git a/app/src/main/java/dev/coffeeco/homenative/ui/SignInUI.kt b/app/src/main/java/dev/coffeeco/homenative/ui/SignInUI.kt new file mode 100644 index 0000000..1864d9c --- /dev/null +++ b/app/src/main/java/dev/coffeeco/homenative/ui/SignInUI.kt @@ -0,0 +1,50 @@ +package dev.coffeeco.homenative.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import dev.coffeeco.homenative.R +import kotlinx.coroutines.runBlocking + +@Composable +fun SignInUI(onSubmit: suspend (a: String, b: String) -> Unit) { + var urlState by remember { mutableStateOf("") } + var tokenState by remember { mutableStateOf("") } + + val modifier = Modifier + .padding(15.dp) + .fillMaxWidth() + + Scaffold { innerPadding -> + Column(modifier = Modifier.padding(innerPadding)) { + Spacer(modifier = Modifier.height(100.dp)) + Text(modifier = modifier, text = stringResource(R.string.log_in_request)) + Spacer(modifier = Modifier.height(50.dp)) + OutlinedTextField(value = urlState, + modifier = modifier, + onValueChange = { urlState = it }, + label = { Text("Instance URL") }) + OutlinedTextField(value = tokenState, + modifier = modifier, + onValueChange = { tokenState = it }, + label = { Text("Long-Lived Access Token") }) + Button(onClick = { + runBlocking { onSubmit(urlState, tokenState) } + }) { Text("Log-In") } + } + } +} diff --git a/app/src/main/java/dev/coffeeco/homenative/ui/theme/Color.kt b/app/src/main/java/dev/coffeeco/homenative/ui/theme/Color.kt new file mode 100644 index 0000000..af3a443 --- /dev/null +++ b/app/src/main/java/dev/coffeeco/homenative/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package dev.coffeeco.homenative.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) \ No newline at end of file diff --git a/app/src/main/java/dev/coffeeco/homenative/ui/theme/Theme.kt b/app/src/main/java/dev/coffeeco/homenative/ui/theme/Theme.kt new file mode 100644 index 0000000..50a87ae --- /dev/null +++ b/app/src/main/java/dev/coffeeco/homenative/ui/theme/Theme.kt @@ -0,0 +1,56 @@ +package dev.coffeeco.homenative.ui.theme + +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.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun HomeNativeTheme( + 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 + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/dev/coffeeco/homenative/ui/theme/Type.kt b/app/src/main/java/dev/coffeeco/homenative/ui/theme/Type.kt new file mode 100644 index 0000000..478004b --- /dev/null +++ b/app/src/main/java/dev/coffeeco/homenative/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package dev.coffeeco.homenative.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 + ) + */ +) \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..1896cb5 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + Home Native + Log-In to Your Home Assistant Instance + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..9d9fb90 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +