feat: EasyAppliance app v0.3.0 (SmartThings elderly-friendly remote)
This commit is contained in:
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle/
|
||||||
|
/local.properties
|
||||||
|
/keystore.properties
|
||||||
|
*.keystore
|
||||||
|
*.jks
|
||||||
|
/.idea/
|
||||||
|
.DS_Store
|
||||||
|
/build
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
app/build/
|
||||||
|
build/
|
||||||
|
.kotlin/
|
||||||
51
README.md
51
README.md
@@ -1,4 +1,51 @@
|
|||||||
# washing_machine_app
|
# 쉬운 가전 리모컨 (EasyAppliance)
|
||||||
|
|
||||||
세탁기 앱 저장소입니다. (edit test: 2026-08-05, 저장된 Gitea 토큰으로 푸시 검증)
|
SmartThings 가전(세탁기·에어컨 등)을 어르신도 쉽게 쓸 수 있도록 만든 안드로이드 앱.
|
||||||
|
큰 글씨·고대비·큰 버튼의 직관적 UX로, 등록된 기기 중 한 대를 골라 상태 확인과
|
||||||
|
켜기/끄기, 시작 매크로를 제공합니다.
|
||||||
|
|
||||||
|
## 주요 기능
|
||||||
|
|
||||||
|
- 한 대 선택 조작: 관리자 화면에서 조작할 기기 1대를 선택
|
||||||
|
- 숨김 관리자 진입: 화면 위쪽 양쪽 모서리를 동시에 약 1.5초 길게 누르면 진입
|
||||||
|
(어르신이 실수로 들어가지 않도록)
|
||||||
|
- 큰 상태 표시: 운전 중/꺼짐, 남은 시간, 에어컨 온도 등
|
||||||
|
- 관리자에서 표시 정보·기능 on/off, 기기 이름·사진 변경(기본 이미지 + 직접 추가)
|
||||||
|
- 시작 매크로: "시작"을 누르면 물 온도·수위 등 설정을 적용한 뒤 자동 시작
|
||||||
|
- 연결 끊김 시 화면 중앙 큰 경고창 + 다시 연결하기(백그라운드 재시도, 복구 시 자동 해제)
|
||||||
|
- 설정은 저장되어 앱을 껐다 켜도 유지, 앱 복귀/주기적 폴링으로 최신화
|
||||||
|
- 폴드/플립 대응 반응형 레이아웃, 삼성 뒤로가기 지원
|
||||||
|
|
||||||
|
## SmartThings 연결
|
||||||
|
|
||||||
|
관리자 화면에서 SmartThings Personal Access Token을 입력하면 실제 기기 목록/상태/제어에
|
||||||
|
연결됩니다. 토큰이 없으면 샘플(목업) 데이터로 동작합니다. 토큰은
|
||||||
|
https://account.smartthings.com/tokens 에서 발급합니다.
|
||||||
|
|
||||||
|
## 빌드
|
||||||
|
|
||||||
|
Android SDK와 JDK 17+ 필요.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :app:assembleDebug # 디버그 APK
|
||||||
|
./gradlew :app:testDebugUnitTest # 단위 테스트
|
||||||
|
```
|
||||||
|
|
||||||
|
릴리즈 서명 빌드는 프로젝트 루트에 `keystore.properties`(gitignore됨)가 있을 때만
|
||||||
|
서명됩니다:
|
||||||
|
|
||||||
|
```properties
|
||||||
|
storeFile=/절대/경로/키스토어.keystore
|
||||||
|
storePassword=...
|
||||||
|
keyAlias=...
|
||||||
|
keyPassword=...
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :app:assembleRelease
|
||||||
|
```
|
||||||
|
|
||||||
|
## 기술 스택
|
||||||
|
|
||||||
|
Kotlin, Jetpack Compose(Material3), DataStore, Retrofit + kotlinx.serialization, Coil.
|
||||||
|
minSdk 24 / targetSdk 35.
|
||||||
|
|||||||
91
app/build.gradle.kts
Normal file
91
app/build.gradle.kts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import java.io.FileInputStream
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional release signing: only active when keystore.properties is present
|
||||||
|
// (kept out of version control). Without it, release builds are unsigned.
|
||||||
|
val keystorePropsFile = rootProject.file("keystore.properties")
|
||||||
|
val keystoreProps = Properties().apply {
|
||||||
|
if (keystorePropsFile.exists()) FileInputStream(keystorePropsFile).use { load(it) }
|
||||||
|
}
|
||||||
|
val hasReleaseSigning = keystorePropsFile.exists()
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "kr.tkrmagid.easyappliance"
|
||||||
|
compileSdk = 35
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "kr.tkrmagid.easyappliance"
|
||||||
|
minSdk = 24
|
||||||
|
targetSdk = 35
|
||||||
|
versionCode = 3
|
||||||
|
versionName = "0.3.0"
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
if (hasReleaseSigning) {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file(keystoreProps["storeFile"] as String)
|
||||||
|
storePassword = keystoreProps["storePassword"] as String
|
||||||
|
keyAlias = keystoreProps["keyAlias"] as String
|
||||||
|
keyPassword = keystoreProps["keyPassword"] as String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
if (hasReleaseSigning) {
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
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.material.icons.extended)
|
||||||
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
implementation(libs.retrofit)
|
||||||
|
implementation(libs.okhttp.logging)
|
||||||
|
implementation(libs.retrofit.kotlinx.serialization)
|
||||||
|
implementation(libs.coil.compose)
|
||||||
|
|
||||||
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
|
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
}
|
||||||
0
app/proguard-rules.pro
vendored
Normal file
0
app/proguard-rules.pro
vendored
Normal file
28
app/src/main/AndroidManifest.xml
Normal file
28
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.EasyAppliance">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|density"
|
||||||
|
android:resizeableActivity="true"
|
||||||
|
android:theme="@style/Theme.EasyAppliance">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
20
app/src/main/java/kr/tkrmagid/easyappliance/MainActivity.kt
Normal file
20
app/src/main/java/kr/tkrmagid/easyappliance/MainActivity.kt
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package kr.tkrmagid.easyappliance
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import kr.tkrmagid.easyappliance.ui.AppRoot
|
||||||
|
import kr.tkrmagid.easyappliance.ui.theme.EasyApplianceTheme
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContent {
|
||||||
|
EasyApplianceTheme {
|
||||||
|
AppRoot()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package kr.tkrmagid.easyappliance.data
|
||||||
|
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import kr.tkrmagid.easyappliance.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in appliance pictures shown as defaults in the admin image picker.
|
||||||
|
* An image override is stored as either "builtin:<key>" or a content:// Uri string.
|
||||||
|
*/
|
||||||
|
object DeviceImages {
|
||||||
|
|
||||||
|
const val BUILTIN_PREFIX = "builtin:"
|
||||||
|
|
||||||
|
data class Builtin(val key: String, val label: String, @DrawableRes val res: Int)
|
||||||
|
|
||||||
|
val builtins: List<Builtin> = listOf(
|
||||||
|
Builtin("washer", "세탁기", R.drawable.img_washer),
|
||||||
|
Builtin("aircon", "에어컨", R.drawable.img_aircon),
|
||||||
|
Builtin("fridge", "냉장고", R.drawable.img_fridge),
|
||||||
|
Builtin("tv", "TV", R.drawable.img_tv),
|
||||||
|
Builtin("device", "기타 기기", R.drawable.img_device),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Drawable resource for a "builtin:<key>" reference, or null if not a builtin. */
|
||||||
|
@DrawableRes
|
||||||
|
fun resForRef(ref: String?): Int? {
|
||||||
|
if (ref == null || !ref.startsWith(BUILTIN_PREFIX)) return null
|
||||||
|
val key = ref.removePrefix(BUILTIN_PREFIX)
|
||||||
|
return builtins.firstOrNull { it.key == key }?.res
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refForKey(key: String): String = "$BUILTIN_PREFIX$key"
|
||||||
|
|
||||||
|
/** Default built-in reference for a device type. */
|
||||||
|
fun defaultRefFor(type: DeviceType): String = when (type) {
|
||||||
|
DeviceType.WASHER -> refForKey("washer")
|
||||||
|
DeviceType.AIRCONDITIONER -> refForKey("aircon")
|
||||||
|
DeviceType.REFRIGERATOR -> refForKey("fridge")
|
||||||
|
DeviceType.TV -> refForKey("tv")
|
||||||
|
DeviceType.OTHER -> refForKey("device")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package kr.tkrmagid.easyappliance.data
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Source of devices and their status. A real SmartThings-backed implementation
|
||||||
|
* will replace [MockDeviceRepository] once an API token is configured; the UI
|
||||||
|
* only depends on this interface.
|
||||||
|
*/
|
||||||
|
interface DeviceRepository {
|
||||||
|
suspend fun listDevices(): List<Device>
|
||||||
|
suspend fun getStatus(deviceId: String): DeviceStatus
|
||||||
|
suspend fun setPower(deviceId: String, on: Boolean)
|
||||||
|
|
||||||
|
/** Apply each macro step, then start the device. */
|
||||||
|
suspend fun runMacro(deviceId: String, steps: List<MacroStep>)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory sample data so the whole UX can be built and demoed before a real
|
||||||
|
* SmartThings token exists. Power toggles are reflected back on next status read.
|
||||||
|
*/
|
||||||
|
class MockDeviceRepository : DeviceRepository {
|
||||||
|
|
||||||
|
private val powerState = mutableMapOf(
|
||||||
|
"washer-1" to false,
|
||||||
|
"aircon-1" to true,
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun listDevices(): List<Device> = listOf(
|
||||||
|
Device("washer-1", "세탁기", DeviceType.WASHER),
|
||||||
|
Device("aircon-1", "거실 에어컨", DeviceType.AIRCONDITIONER),
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun getStatus(deviceId: String): DeviceStatus {
|
||||||
|
val on = powerState[deviceId] ?: false
|
||||||
|
return when (deviceId) {
|
||||||
|
"washer-1" -> DeviceStatus(
|
||||||
|
powerOn = on,
|
||||||
|
operatingState = if (on) "운전 중" else "꺼짐",
|
||||||
|
remainingMinutes = if (on) 32 else null,
|
||||||
|
reservation = null,
|
||||||
|
)
|
||||||
|
"aircon-1" -> DeviceStatus(
|
||||||
|
powerOn = on,
|
||||||
|
operatingState = if (on) "냉방 중" else "꺼짐",
|
||||||
|
currentTemperature = 27,
|
||||||
|
targetTemperature = if (on) 24 else null,
|
||||||
|
)
|
||||||
|
else -> DeviceStatus(powerOn = on)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setPower(deviceId: String, on: Boolean) {
|
||||||
|
powerState[deviceId] = on
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun runMacro(deviceId: String, steps: List<MacroStep>) {
|
||||||
|
// Sample mode: steps are a no-op; the device simply starts.
|
||||||
|
powerState[deviceId] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package kr.tkrmagid.easyappliance.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies a picked image into app-internal storage so it survives restarts
|
||||||
|
* (Photo Picker URIs are not durably readable). Returns a file:// reference.
|
||||||
|
*/
|
||||||
|
object ImageStorage {
|
||||||
|
fun saveDeviceImage(context: Context, source: Uri, deviceId: String): String? = runCatching {
|
||||||
|
val dir = File(context.filesDir, "device_images").apply { mkdirs() }
|
||||||
|
val file = File(dir, "img_${deviceId.hashCode()}_${System.nanoTime()}.jpg")
|
||||||
|
context.contentResolver.openInputStream(source)?.use { input ->
|
||||||
|
file.outputStream().use { output -> input.copyTo(output) }
|
||||||
|
} ?: return null
|
||||||
|
"file://${file.absolutePath}"
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
27
app/src/main/java/kr/tkrmagid/easyappliance/data/Macro.kt
Normal file
27
app/src/main/java/kr/tkrmagid/easyappliance/data/Macro.kt
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package kr.tkrmagid.easyappliance.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One SmartThings command applied as part of a macro, e.g. set water
|
||||||
|
* temperature. [args] are stored as strings and converted to JSON at send time
|
||||||
|
* (numeric strings become numbers).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class MacroStep(
|
||||||
|
val capability: String,
|
||||||
|
val command: String,
|
||||||
|
val args: List<String> = emptyList(),
|
||||||
|
/** Human-readable description shown in admin, e.g. "물 온도 40도". */
|
||||||
|
val label: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-device macro: when enabled, the home screen shows a single "시작" button
|
||||||
|
* that applies every step and then starts the device.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class DeviceMacro(
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
val steps: List<MacroStep> = emptyList(),
|
||||||
|
)
|
||||||
39
app/src/main/java/kr/tkrmagid/easyappliance/data/Models.kt
Normal file
39
app/src/main/java/kr/tkrmagid/easyappliance/data/Models.kt
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package kr.tkrmagid.easyappliance.data
|
||||||
|
|
||||||
|
/** Broad appliance category, used to pick icons and default labels. */
|
||||||
|
enum class DeviceType { WASHER, AIRCONDITIONER, REFRIGERATOR, TV, OTHER }
|
||||||
|
|
||||||
|
/** Korean category name shown in warnings and defaults, e.g. "세탁기". */
|
||||||
|
fun DeviceType.koreanLabel(): String = when (this) {
|
||||||
|
DeviceType.WASHER -> "세탁기"
|
||||||
|
DeviceType.AIRCONDITIONER -> "에어컨"
|
||||||
|
DeviceType.REFRIGERATOR -> "냉장고"
|
||||||
|
DeviceType.TV -> "TV"
|
||||||
|
DeviceType.OTHER -> "기기"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A SmartThings device the user can control. */
|
||||||
|
data class Device(
|
||||||
|
val id: String,
|
||||||
|
val label: String,
|
||||||
|
val type: DeviceType,
|
||||||
|
val online: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalized status shown on the home screen. Not every field applies to every
|
||||||
|
* device; null means "not reported / not applicable".
|
||||||
|
*/
|
||||||
|
data class DeviceStatus(
|
||||||
|
val powerOn: Boolean = false,
|
||||||
|
/** Human-readable run state, e.g. "운전 중", "정지", "일시정지". */
|
||||||
|
val operatingState: String? = null,
|
||||||
|
/** Remaining minutes for a running cycle, if reported. */
|
||||||
|
val remainingMinutes: Int? = null,
|
||||||
|
/** Reservation / scheduled-start description, if set. */
|
||||||
|
val reservation: String? = null,
|
||||||
|
/** Current temperature (aircon), if reported. */
|
||||||
|
val currentTemperature: Int? = null,
|
||||||
|
/** Target temperature (aircon), if reported. */
|
||||||
|
val targetTemperature: Int? = null,
|
||||||
|
)
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package kr.tkrmagid.easyappliance.data
|
||||||
|
|
||||||
|
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.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||||
|
|
||||||
|
/** User/admin choices that must survive app restarts. */
|
||||||
|
data class AppSettings(
|
||||||
|
val selectedDeviceId: String? = null,
|
||||||
|
val showRemainingTime: Boolean = true,
|
||||||
|
val showReservation: Boolean = true,
|
||||||
|
val showPowerControls: Boolean = true,
|
||||||
|
/** SmartThings Personal Access Token; null/blank means use sample data. */
|
||||||
|
val smartThingsToken: String? = null,
|
||||||
|
/** deviceId -> custom display name. */
|
||||||
|
val labelOverrides: Map<String, String> = emptyMap(),
|
||||||
|
/** deviceId -> image reference ("builtin:<key>" or a content:// Uri). */
|
||||||
|
val imageOverrides: Map<String, String> = emptyMap(),
|
||||||
|
/** deviceId -> start macro. */
|
||||||
|
val deviceMacros: Map<String, DeviceMacro> = emptyMap(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Persists [AppSettings] via Jetpack DataStore. */
|
||||||
|
class SettingsRepository(private val context: Context) {
|
||||||
|
|
||||||
|
val settings: Flow<AppSettings> = context.dataStore.data.map { p ->
|
||||||
|
AppSettings(
|
||||||
|
selectedDeviceId = p[KEY_SELECTED_DEVICE],
|
||||||
|
showRemainingTime = p[KEY_SHOW_REMAINING] ?: true,
|
||||||
|
showReservation = p[KEY_SHOW_RESERVATION] ?: true,
|
||||||
|
showPowerControls = p[KEY_SHOW_POWER] ?: true,
|
||||||
|
smartThingsToken = p[KEY_TOKEN]?.takeIf { it.isNotBlank() },
|
||||||
|
labelOverrides = decodeMap(p[KEY_LABEL_OVERRIDES]),
|
||||||
|
imageOverrides = decodeMap(p[KEY_IMAGE_OVERRIDES]),
|
||||||
|
deviceMacros = decodeMacros(p[KEY_MACROS]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSelectedDevice(deviceId: String) =
|
||||||
|
edit { it[KEY_SELECTED_DEVICE] = deviceId }
|
||||||
|
|
||||||
|
suspend fun setShowRemainingTime(value: Boolean) =
|
||||||
|
edit { it[KEY_SHOW_REMAINING] = value }
|
||||||
|
|
||||||
|
suspend fun setShowReservation(value: Boolean) =
|
||||||
|
edit { it[KEY_SHOW_RESERVATION] = value }
|
||||||
|
|
||||||
|
suspend fun setShowPowerControls(value: Boolean) =
|
||||||
|
edit { it[KEY_SHOW_POWER] = value }
|
||||||
|
|
||||||
|
suspend fun setToken(token: String) =
|
||||||
|
edit { it[KEY_TOKEN] = token.trim() }
|
||||||
|
|
||||||
|
suspend fun setDeviceLabel(deviceId: String, label: String) = edit { prefs ->
|
||||||
|
val map = decodeMap(prefs[KEY_LABEL_OVERRIDES]).toMutableMap()
|
||||||
|
if (label.isBlank()) map.remove(deviceId) else map[deviceId] = label.trim()
|
||||||
|
prefs[KEY_LABEL_OVERRIDES] = Json.encodeToString(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDeviceImage(deviceId: String, imageRef: String) = edit { prefs ->
|
||||||
|
val map = decodeMap(prefs[KEY_IMAGE_OVERRIDES]).toMutableMap()
|
||||||
|
map[deviceId] = imageRef
|
||||||
|
prefs[KEY_IMAGE_OVERRIDES] = Json.encodeToString(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setDeviceMacro(deviceId: String, macro: DeviceMacro) = edit { prefs ->
|
||||||
|
val map = decodeMacros(prefs[KEY_MACROS]).toMutableMap()
|
||||||
|
map[deviceId] = macro
|
||||||
|
prefs[KEY_MACROS] = Json.encodeToString(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun edit(block: (androidx.datastore.preferences.core.MutablePreferences) -> Unit) {
|
||||||
|
context.dataStore.edit(block)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeMap(raw: String?): Map<String, String> =
|
||||||
|
if (raw.isNullOrBlank()) emptyMap()
|
||||||
|
else runCatching { Json.decodeFromString<Map<String, String>>(raw) }.getOrDefault(emptyMap())
|
||||||
|
|
||||||
|
private fun decodeMacros(raw: String?): Map<String, DeviceMacro> =
|
||||||
|
if (raw.isNullOrBlank()) emptyMap()
|
||||||
|
else runCatching { Json.decodeFromString<Map<String, DeviceMacro>>(raw) }.getOrDefault(emptyMap())
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val KEY_SELECTED_DEVICE = stringPreferencesKey("selected_device_id")
|
||||||
|
val KEY_SHOW_REMAINING = booleanPreferencesKey("show_remaining_time")
|
||||||
|
val KEY_SHOW_RESERVATION = booleanPreferencesKey("show_reservation")
|
||||||
|
val KEY_SHOW_POWER = booleanPreferencesKey("show_power_controls")
|
||||||
|
val KEY_TOKEN = stringPreferencesKey("smartthings_token")
|
||||||
|
val KEY_LABEL_OVERRIDES = stringPreferencesKey("label_overrides_json")
|
||||||
|
val KEY_IMAGE_OVERRIDES = stringPreferencesKey("image_overrides_json")
|
||||||
|
val KEY_MACROS = stringPreferencesKey("device_macros_json")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package kr.tkrmagid.easyappliance.data.smartthings
|
||||||
|
|
||||||
|
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Path
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DevicesResponse(val items: List<StDevice> = emptyList())
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class StDevice(
|
||||||
|
val deviceId: String,
|
||||||
|
val label: String? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val components: List<StComponent> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class StComponent(val id: String = "main", val capabilities: List<StCapability> = emptyList())
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class StCapability(val id: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class StCommand(
|
||||||
|
val component: String = "main",
|
||||||
|
val capability: String,
|
||||||
|
val command: String,
|
||||||
|
val arguments: List<JsonElement> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class StCommandsRequest(val commands: List<StCommand>)
|
||||||
|
|
||||||
|
interface SmartThingsApi {
|
||||||
|
@GET("v1/devices")
|
||||||
|
suspend fun listDevices(): DevicesResponse
|
||||||
|
|
||||||
|
@GET("v1/devices/{id}/status")
|
||||||
|
suspend fun deviceStatus(@Path("id") deviceId: String): JsonObject
|
||||||
|
|
||||||
|
@POST("v1/devices/{id}/commands")
|
||||||
|
suspend fun sendCommands(@Path("id") deviceId: String, @Body body: StCommandsRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a [SmartThingsApi] bound to a Personal Access Token. */
|
||||||
|
object SmartThingsClient {
|
||||||
|
private const val BASE_URL = "https://api.smartthings.com/"
|
||||||
|
|
||||||
|
val json = Json { ignoreUnknownKeys = true; coerceInputValues = true }
|
||||||
|
|
||||||
|
fun create(token: String): SmartThingsApi {
|
||||||
|
val auth = Interceptor { chain ->
|
||||||
|
val req = chain.request().newBuilder()
|
||||||
|
.addHeader("Authorization", "Bearer $token")
|
||||||
|
.addHeader("Accept", "application/json")
|
||||||
|
.build()
|
||||||
|
chain.proceed(req)
|
||||||
|
}
|
||||||
|
val client = OkHttpClient.Builder()
|
||||||
|
.addInterceptor(auth)
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
return Retrofit.Builder()
|
||||||
|
.baseUrl(BASE_URL)
|
||||||
|
.client(client)
|
||||||
|
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||||
|
.build()
|
||||||
|
.create(SmartThingsApi::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package kr.tkrmagid.easyappliance.data.smartthings
|
||||||
|
|
||||||
|
import kr.tkrmagid.easyappliance.data.Device
|
||||||
|
import kr.tkrmagid.easyappliance.data.DeviceRepository
|
||||||
|
import kr.tkrmagid.easyappliance.data.DeviceStatus
|
||||||
|
import kr.tkrmagid.easyappliance.data.DeviceType
|
||||||
|
import kr.tkrmagid.easyappliance.data.MacroStep
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
|
/** [DeviceRepository] backed by the real SmartThings Cloud API. */
|
||||||
|
class SmartThingsDeviceRepository(
|
||||||
|
private val api: SmartThingsApi,
|
||||||
|
) : DeviceRepository {
|
||||||
|
|
||||||
|
private val typeCache = mutableMapOf<String, DeviceType>()
|
||||||
|
|
||||||
|
override suspend fun listDevices(): List<Device> {
|
||||||
|
return api.listDevices().items.map { d ->
|
||||||
|
val caps = d.components.flatMap { c -> c.capabilities.map { it.id } }.toSet()
|
||||||
|
val type = inferType(caps, d.label, d.name)
|
||||||
|
typeCache[d.deviceId] = type
|
||||||
|
Device(
|
||||||
|
id = d.deviceId,
|
||||||
|
label = d.label ?: d.name ?: "기기",
|
||||||
|
type = type,
|
||||||
|
online = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getStatus(deviceId: String): DeviceStatus {
|
||||||
|
val json = api.deviceStatus(deviceId)
|
||||||
|
val type = typeCache[deviceId] ?: DeviceType.OTHER
|
||||||
|
return SmartThingsStatusMapper.map(json, type)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun setPower(deviceId: String, on: Boolean) {
|
||||||
|
api.sendCommands(
|
||||||
|
deviceId,
|
||||||
|
StCommandsRequest(
|
||||||
|
listOf(StCommand(capability = "switch", command = if (on) "on" else "off")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun runMacro(deviceId: String, steps: List<MacroStep>) {
|
||||||
|
val commands = steps.map { step ->
|
||||||
|
StCommand(
|
||||||
|
capability = step.capability,
|
||||||
|
command = step.command,
|
||||||
|
arguments = step.args.map { it.toJsonElement() },
|
||||||
|
)
|
||||||
|
} + StCommand(capability = "switch", command = "on")
|
||||||
|
api.sendCommands(deviceId, StCommandsRequest(commands))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toJsonElement(): JsonElement =
|
||||||
|
toIntOrNull()?.let { JsonPrimitive(it) }
|
||||||
|
?: toDoubleOrNull()?.let { JsonPrimitive(it) }
|
||||||
|
?: JsonPrimitive(this)
|
||||||
|
|
||||||
|
private fun inferType(caps: Set<String>, label: String?, name: String?): DeviceType {
|
||||||
|
val text = "${label.orEmpty()} ${name.orEmpty()}"
|
||||||
|
return when {
|
||||||
|
caps.any { it.contains("washerOperatingState", ignoreCase = true) } -> DeviceType.WASHER
|
||||||
|
caps.any { it.contains("airConditionerMode", ignoreCase = true) } -> DeviceType.AIRCONDITIONER
|
||||||
|
caps.any { it.contains("refrigeration", ignoreCase = true) } -> DeviceType.REFRIGERATOR
|
||||||
|
caps.contains("tvChannel") || caps.contains("mediaPlayback") -> DeviceType.TV
|
||||||
|
text.contains("세탁") || text.contains("washer", true) -> DeviceType.WASHER
|
||||||
|
text.contains("에어컨") || text.contains("aircon", true) || text.contains("air conditioner", true) -> DeviceType.AIRCONDITIONER
|
||||||
|
text.contains("냉장") || text.contains("fridge", true) -> DeviceType.REFRIGERATOR
|
||||||
|
text.contains("tv", true) -> DeviceType.TV
|
||||||
|
else -> DeviceType.OTHER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package kr.tkrmagid.easyappliance.data.smartthings
|
||||||
|
|
||||||
|
import kr.tkrmagid.easyappliance.data.DeviceStatus
|
||||||
|
import kr.tkrmagid.easyappliance.data.DeviceType
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort mapping from a SmartThings `/status` payload to our [DeviceStatus].
|
||||||
|
* SmartThings status is deeply nested with capability-specific keys; this reads
|
||||||
|
* the common capabilities defensively and returns nulls for anything missing.
|
||||||
|
* Refine per real device once a token is available.
|
||||||
|
*/
|
||||||
|
object SmartThingsStatusMapper {
|
||||||
|
|
||||||
|
fun map(status: JsonObject, type: DeviceType): DeviceStatus {
|
||||||
|
val main = status["components"]?.jsonObject?.get("main")?.jsonObject
|
||||||
|
?: return DeviceStatus(powerOn = false)
|
||||||
|
|
||||||
|
val powerOn = attr(main, "switch", "switch")?.contentOrNull() == "on"
|
||||||
|
|
||||||
|
return when (type) {
|
||||||
|
DeviceType.WASHER -> mapWasher(main, powerOn)
|
||||||
|
DeviceType.AIRCONDITIONER -> mapAircon(main, powerOn)
|
||||||
|
else -> DeviceStatus(powerOn = powerOn, operatingState = if (powerOn) "켜짐" else "꺼짐")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapWasher(main: JsonObject, powerOn: Boolean): DeviceStatus {
|
||||||
|
val machine = attr(main, "washerOperatingState", "machineState")?.contentOrNull()
|
||||||
|
?: attr(main, "samsungce.washerOperatingState", "operatingState")?.contentOrNull()
|
||||||
|
val state = when (machine) {
|
||||||
|
"run" -> "운전 중"
|
||||||
|
"pause" -> "일시정지"
|
||||||
|
"stop" -> "정지"
|
||||||
|
else -> if (powerOn) "켜짐" else "꺼짐"
|
||||||
|
}
|
||||||
|
val remaining = numberAttr(main, "samsungce.washerOperatingState", "remainingTime")
|
||||||
|
?.let { secondsToMinutes(it) }
|
||||||
|
return DeviceStatus(
|
||||||
|
powerOn = powerOn,
|
||||||
|
operatingState = state,
|
||||||
|
remainingMinutes = remaining,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapAircon(main: JsonObject, powerOn: Boolean): DeviceStatus {
|
||||||
|
val current = numberAttr(main, "temperatureMeasurement", "temperature")
|
||||||
|
val target = numberAttr(main, "thermostatCoolingSetpoint", "coolingSetpoint")
|
||||||
|
?: numberAttr(main, "custom.thermostatSetpointControl", "coolingSetpoint")
|
||||||
|
return DeviceStatus(
|
||||||
|
powerOn = powerOn,
|
||||||
|
operatingState = if (powerOn) "냉방 중" else "꺼짐",
|
||||||
|
currentTemperature = current,
|
||||||
|
targetTemperature = if (powerOn) target else null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun attr(main: JsonObject, capability: String, attribute: String): JsonPrimitive? =
|
||||||
|
runCatching {
|
||||||
|
main[capability]?.jsonObject?.get(attribute)?.jsonObject?.get("value") as? JsonPrimitive
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private fun numberAttr(main: JsonObject, capability: String, attribute: String): Int? =
|
||||||
|
attr(main, capability, attribute)?.let { it.contentOrNull()?.toDoubleOrNull()?.toInt() }
|
||||||
|
|
||||||
|
private fun JsonPrimitive.contentOrNull(): String? =
|
||||||
|
runCatching { jsonPrimitive.content }.getOrNull()?.takeIf { it != "null" }
|
||||||
|
|
||||||
|
private fun secondsToMinutes(seconds: Int): Int =
|
||||||
|
if (seconds > 300) (seconds + 59) / 60 else seconds
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package kr.tkrmagid.easyappliance.ui
|
||||||
|
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEvent
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hidden admin unlock: the user must press BOTH top corners at the same time and
|
||||||
|
* hold for ~1.5s. This is intentionally hard to trigger by accident so older
|
||||||
|
* users don't stumble into the admin screen, while a caretaker can still open it.
|
||||||
|
*/
|
||||||
|
fun Modifier.adminUnlockGesture(
|
||||||
|
holdMillis: Long = 1500L,
|
||||||
|
cornerFraction: Float = 0.30f,
|
||||||
|
onUnlock: () -> Unit,
|
||||||
|
): Modifier = this.pointerInput(Unit) {
|
||||||
|
val zoneW = size.width * cornerFraction
|
||||||
|
val zoneH = size.height * cornerFraction
|
||||||
|
|
||||||
|
fun PointerEvent.bothTopCornersHeld(): Boolean {
|
||||||
|
val pressed = changes.filter { it.pressed }.map { it.position }
|
||||||
|
val topLeft = pressed.any { it.x < zoneW && it.y < zoneH }
|
||||||
|
val topRight = pressed.any { it.x > size.width - zoneW && it.y < zoneH }
|
||||||
|
return topLeft && topRight
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
// 1) Wait until both top corners are pressed simultaneously.
|
||||||
|
awaitPointerEventScope {
|
||||||
|
do { } while (!awaitPointerEvent().bothTopCornersHeld())
|
||||||
|
}
|
||||||
|
// 2) They are held now. If they stay held for the full duration, the inner
|
||||||
|
// wait never returns and withTimeoutOrNull yields null -> unlock. If a
|
||||||
|
// finger lifts, an event fires, the loop returns, and we abort.
|
||||||
|
val releasedEarly = withTimeoutOrNull(holdMillis) {
|
||||||
|
awaitPointerEventScope {
|
||||||
|
do { } while (awaitPointerEvent().bothTopCornersHeld())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (releasedEarly == null) {
|
||||||
|
onUnlock()
|
||||||
|
// Wait for fingers to lift so we don't immediately re-trigger.
|
||||||
|
awaitPointerEventScope {
|
||||||
|
do { } while (awaitPointerEvent().bothTopCornersHeld())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
328
app/src/main/java/kr/tkrmagid/easyappliance/ui/AdminScreen.kt
Normal file
328
app/src/main/java/kr/tkrmagid/easyappliance/ui/AdminScreen.kt
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
package kr.tkrmagid.easyappliance.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.PickVisualMediaRequest
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
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.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import kr.tkrmagid.easyappliance.data.Device
|
||||||
|
import kr.tkrmagid.easyappliance.data.DeviceImages
|
||||||
|
import kr.tkrmagid.easyappliance.data.DeviceMacro
|
||||||
|
import kr.tkrmagid.easyappliance.data.ImageStorage
|
||||||
|
import kr.tkrmagid.easyappliance.vm.HomeUiState
|
||||||
|
import kr.tkrmagid.easyappliance.vm.displayLabelFor
|
||||||
|
import kr.tkrmagid.easyappliance.vm.imageRefFor
|
||||||
|
import kr.tkrmagid.easyappliance.vm.macroFor
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AdminScreen(
|
||||||
|
state: HomeUiState,
|
||||||
|
onSelectDevice: (String) -> Unit,
|
||||||
|
onToggleRemaining: (Boolean) -> Unit,
|
||||||
|
onToggleReservation: (Boolean) -> Unit,
|
||||||
|
onTogglePowerControls: (Boolean) -> Unit,
|
||||||
|
onSetLabel: (String, String) -> Unit,
|
||||||
|
onSetImageRef: (String, String) -> Unit,
|
||||||
|
onSetToken: (String) -> Unit,
|
||||||
|
onSetMacro: (String, DeviceMacro) -> Unit,
|
||||||
|
onPreviewWarning: () -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val selected = state.selected
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(20.dp),
|
||||||
|
) {
|
||||||
|
Text("관리자 설정", style = MaterialTheme.typography.headlineLarge)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
SectionTitle("조작할 기기 선택")
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||||
|
state.devices.forEach { device ->
|
||||||
|
DeviceRow(
|
||||||
|
device = device,
|
||||||
|
subtitle = state.displayLabelFor(device),
|
||||||
|
selected = device.id == selected?.id,
|
||||||
|
onClick = { onSelectDevice(device.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (state.devices.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"표시할 기기가 없습니다.",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected != null) {
|
||||||
|
Spacer(Modifier.height(28.dp))
|
||||||
|
SectionTitle("이름 바꾸기")
|
||||||
|
NameEditor(
|
||||||
|
current = state.displayLabelFor(selected),
|
||||||
|
onSave = { onSetLabel(selected.id, it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(28.dp))
|
||||||
|
SectionTitle("사진 바꾸기")
|
||||||
|
ImagePicker(
|
||||||
|
currentRef = state.imageRefFor(selected),
|
||||||
|
onPickBuiltin = { onSetImageRef(selected.id, it) },
|
||||||
|
onPickCustom = { onSetImageRef(selected.id, it) },
|
||||||
|
deviceId = selected.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(28.dp))
|
||||||
|
SectionTitle("매크로 (시작 자동화)")
|
||||||
|
MacroEditor(
|
||||||
|
device = selected,
|
||||||
|
macro = state.macroFor(selected) ?: DeviceMacro(),
|
||||||
|
onChange = { onSetMacro(selected.id, it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(28.dp))
|
||||||
|
SectionTitle("표시할 정보 / 기능")
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(8.dp)) {
|
||||||
|
ToggleRow("남은 시간 표시", state.settings.showRemainingTime, onToggleRemaining)
|
||||||
|
HorizontalDivider()
|
||||||
|
ToggleRow("예약 정보 표시", state.settings.showReservation, onToggleReservation)
|
||||||
|
HorizontalDivider()
|
||||||
|
ToggleRow("켜기 / 끄기 버튼 표시", state.settings.showPowerControls, onTogglePowerControls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(28.dp))
|
||||||
|
SectionTitle("SmartThings 연결")
|
||||||
|
TokenEditor(
|
||||||
|
usingRealApi = state.usingRealApi,
|
||||||
|
onSave = onSetToken,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { onPreviewWarning(); onBack() },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(64.dp),
|
||||||
|
) {
|
||||||
|
Text("연결 경고창 미리보기 (테스트)", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(28.dp))
|
||||||
|
Button(
|
||||||
|
onClick = onBack,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(76.dp),
|
||||||
|
) {
|
||||||
|
Text("저장하고 닫기", style = MaterialTheme.typography.labelLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NameEditor(current: String, onSave: (String) -> Unit) {
|
||||||
|
var text by remember(current) { mutableStateOf(current) }
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = text,
|
||||||
|
onValueChange = { text = it },
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(12.dp))
|
||||||
|
Button(onClick = { onSave(text) }, modifier = Modifier.height(56.dp)) {
|
||||||
|
Text("저장")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ImagePicker(
|
||||||
|
currentRef: String,
|
||||||
|
onPickBuiltin: (String) -> Unit,
|
||||||
|
onPickCustom: (String) -> Unit,
|
||||||
|
deviceId: String,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val launcher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.PickVisualMedia(),
|
||||||
|
) { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
ImageStorage.saveDeviceImage(context, uri, deviceId)?.let(onPickCustom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxWidth()) {
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
DeviceImage(imageRef = currentRef, modifier = Modifier.size(72.dp))
|
||||||
|
Spacer(Modifier.size(16.dp))
|
||||||
|
Text("현재 사진", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text("기본 사진 중 선택", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
DeviceImages.builtins.forEach { b ->
|
||||||
|
val ref = DeviceImages.refForKey(b.key)
|
||||||
|
val isSel = ref == currentRef
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.border(
|
||||||
|
width = if (isSel) 3.dp else 1.dp,
|
||||||
|
color = if (isSel) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
)
|
||||||
|
.clickable { onPickBuiltin(ref) }
|
||||||
|
.padding(6.dp),
|
||||||
|
) {
|
||||||
|
DeviceImage(imageRef = ref, modifier = Modifier.size(48.dp))
|
||||||
|
Text(b.label, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
launcher.launch(
|
||||||
|
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().height(60.dp),
|
||||||
|
) {
|
||||||
|
Text("갤러리에서 사진 추가", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TokenEditor(usingRealApi: Boolean, onSave: (String) -> Unit) {
|
||||||
|
var token by remember { mutableStateOf("") }
|
||||||
|
var reveal by remember { mutableStateOf(false) }
|
||||||
|
Column(Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
if (usingRealApi) "상태: 실기기 연결 (토큰 설정됨)" else "상태: 샘플 모드 (토큰 없음)",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = if (usingRealApi) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = token,
|
||||||
|
onValueChange = { token = it },
|
||||||
|
singleLine = true,
|
||||||
|
label = { Text("SmartThings 토큰 입력") },
|
||||||
|
visualTransformation = if (reveal) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Switch(checked = reveal, onCheckedChange = { reveal = it })
|
||||||
|
Text(" 토큰 보기", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = { if (token.isNotBlank()) onSave(token) },
|
||||||
|
modifier = Modifier.height(56.dp),
|
||||||
|
) {
|
||||||
|
Text("토큰 저장")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionTitle(text: String) {
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.padding(bottom = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeviceRow(device: Device, subtitle: String, selected: Boolean, onClick: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.selectable(selected = selected, onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
RadioButton(selected = selected, onClick = onClick)
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text(subtitle, style = MaterialTheme.typography.titleLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ToggleRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
|
||||||
|
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
89
app/src/main/java/kr/tkrmagid/easyappliance/ui/AppRoot.kt
Normal file
89
app/src/main/java/kr/tkrmagid/easyappliance/ui/AppRoot.kt
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package kr.tkrmagid.easyappliance.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import kr.tkrmagid.easyappliance.data.koreanLabel
|
||||||
|
import kr.tkrmagid.easyappliance.vm.AppViewModel
|
||||||
|
import kr.tkrmagid.easyappliance.vm.displayLabelFor
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
private enum class Screen { HOME, ADMIN }
|
||||||
|
|
||||||
|
private const val POLL_OK_MS = 15_000L
|
||||||
|
private const val POLL_OFFLINE_MS = 5_000L
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AppRoot() {
|
||||||
|
val vm: AppViewModel = viewModel()
|
||||||
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
|
var screen by rememberSaveable { mutableStateOf(Screen.HOME) }
|
||||||
|
|
||||||
|
// Refresh when returning to the foreground...
|
||||||
|
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { vm.refresh() }
|
||||||
|
// ...and poll while composed; retry faster while disconnected so the warning
|
||||||
|
// clears itself as soon as the device/API is reachable again.
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
|
val connected = vm.state.value.connected
|
||||||
|
delay(if (connected) POLL_OK_MS else POLL_OFFLINE_MS)
|
||||||
|
if (vm.state.value.connected) vm.refresh() else vm.retryConnection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// System / Samsung back: admin -> home; block back while the warning is up.
|
||||||
|
BackHandler(enabled = screen == Screen.ADMIN) { screen = Screen.HOME }
|
||||||
|
BackHandler(enabled = state.showConnectionWarning) { /* must use the buttons */ }
|
||||||
|
|
||||||
|
Scaffold(modifier = Modifier.fillMaxSize()) { padding ->
|
||||||
|
Box(Modifier.fillMaxSize().padding(padding)) {
|
||||||
|
when (screen) {
|
||||||
|
Screen.HOME -> HomeScreen(
|
||||||
|
state = state,
|
||||||
|
onTogglePower = vm::togglePower,
|
||||||
|
onRunMacro = vm::runMacro,
|
||||||
|
onRefresh = vm::refresh,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.adminUnlockGesture { screen = Screen.ADMIN },
|
||||||
|
)
|
||||||
|
Screen.ADMIN -> AdminScreen(
|
||||||
|
state = state,
|
||||||
|
onSelectDevice = vm::selectDevice,
|
||||||
|
onToggleRemaining = vm::setShowRemainingTime,
|
||||||
|
onToggleReservation = vm::setShowReservation,
|
||||||
|
onTogglePowerControls = vm::setShowPowerControls,
|
||||||
|
onSetLabel = vm::setDeviceLabel,
|
||||||
|
onSetImageRef = vm::setDeviceImage,
|
||||||
|
onSetToken = vm::setToken,
|
||||||
|
onSetMacro = vm::setDeviceMacro,
|
||||||
|
onPreviewWarning = vm::previewConnectionWarning,
|
||||||
|
onBack = { screen = Screen.HOME },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.showConnectionWarning) {
|
||||||
|
val selected = state.selected
|
||||||
|
ConnectionWarning(
|
||||||
|
deviceTypeLabel = selected?.type?.koreanLabel() ?: "기기",
|
||||||
|
deviceName = selected?.let { state.displayLabelFor(it) } ?: "기기",
|
||||||
|
onRetry = vm::retryConnection,
|
||||||
|
onDismiss = vm::dismissWarning,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package kr.tkrmagid.easyappliance.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.WifiOff
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen modal shown when the selected device / API is unreachable.
|
||||||
|
* Big headline for older users, a mandatory "다시 연결하기" button, and a small
|
||||||
|
* "x" in the corner for users who know to dismiss it manually.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ConnectionWarning(
|
||||||
|
deviceTypeLabel: String,
|
||||||
|
deviceName: String,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val noRipple = remember { MutableInteractionSource() }
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color(0xCC000000))
|
||||||
|
// Swallow taps so the screen behind cannot be operated while offline.
|
||||||
|
.clickable(interactionSource = noRipple, indication = null) {},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 520.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(24.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||||
|
) {
|
||||||
|
Box(Modifier.fillMaxWidth()) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier.align(Alignment.TopEnd).padding(4.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Close, contentDescription = "닫기")
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp, vertical = 32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.WifiOff,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
"${deviceTypeLabel}와 연결할 수 없습니다",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
"API 또는 '${deviceName}' 과(와) 연결 실패",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(28.dp))
|
||||||
|
Button(
|
||||||
|
onClick = onRetry,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(80.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(28.dp))
|
||||||
|
Text(" 다시 연결하기", style = MaterialTheme.typography.labelLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package kr.tkrmagid.easyappliance.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import coil.compose.AsyncImage
|
||||||
|
import kr.tkrmagid.easyappliance.data.DeviceImages
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a device image from an image reference: a built-in drawable
|
||||||
|
* ("builtin:<key>") or a file/content Uri string (custom photo).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun DeviceImage(
|
||||||
|
imageRef: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
contentScale: ContentScale = ContentScale.Fit,
|
||||||
|
) {
|
||||||
|
val builtinRes = DeviceImages.resForRef(imageRef)
|
||||||
|
if (builtinRes != null) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(builtinRes),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = modifier,
|
||||||
|
contentScale = contentScale,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
AsyncImage(
|
||||||
|
model = imageRef,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = modifier,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
268
app/src/main/java/kr/tkrmagid/easyappliance/ui/HomeScreen.kt
Normal file
268
app/src/main/java/kr/tkrmagid/easyappliance/ui/HomeScreen.kt
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
package kr.tkrmagid.easyappliance.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.PowerSettingsNew
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kr.tkrmagid.easyappliance.data.DeviceType
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import kr.tkrmagid.easyappliance.vm.HomeUiState
|
||||||
|
import kr.tkrmagid.easyappliance.vm.displayLabelFor
|
||||||
|
import kr.tkrmagid.easyappliance.vm.imageRefFor
|
||||||
|
import kr.tkrmagid.easyappliance.vm.macroFor
|
||||||
|
|
||||||
|
private val BigButtonHeight = 84.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HomeScreen(
|
||||||
|
state: HomeUiState,
|
||||||
|
onTogglePower: (Boolean) -> Unit,
|
||||||
|
onRunMacro: () -> Unit,
|
||||||
|
onRefresh: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||||
|
// Responsive: cap content width on large / unfolded screens, stack the
|
||||||
|
// power buttons vertically on very narrow screens (flip closed cover).
|
||||||
|
val narrow = maxWidth < 340.dp
|
||||||
|
val contentMaxWidth = if (maxWidth < 600.dp) maxWidth else 600.dp
|
||||||
|
|
||||||
|
val device = state.selected
|
||||||
|
if (device == null) {
|
||||||
|
EmptyState()
|
||||||
|
return@BoxWithConstraints
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(20.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.widthIn(max = contentMaxWidth).fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
DeviceImage(
|
||||||
|
imageRef = state.imageRefFor(device),
|
||||||
|
modifier = Modifier.size(if (narrow) 96.dp else 128.dp),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = state.displayLabelFor(device),
|
||||||
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
|
StatusCard(state)
|
||||||
|
|
||||||
|
if (state.settings.showPowerControls) {
|
||||||
|
Spacer(Modifier.height(28.dp))
|
||||||
|
val macroOn = state.macroFor(device)?.enabled == true
|
||||||
|
if (macroOn) {
|
||||||
|
MacroControls(
|
||||||
|
isOn = state.status?.powerOn == true,
|
||||||
|
onStart = onRunMacro,
|
||||||
|
onOff = { onTogglePower(false) },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
PowerControls(
|
||||||
|
isOn = state.status?.powerOn == true,
|
||||||
|
stacked = narrow,
|
||||||
|
onTogglePower = onTogglePower,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onRefresh,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(BigButtonHeight),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(30.dp))
|
||||||
|
Text(" 새로고침", style = MaterialTheme.typography.labelLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StatusCard(state: HomeUiState) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
if (state.loading && state.status == null) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text("불러오는 중...", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
return@Column
|
||||||
|
}
|
||||||
|
val status = state.status
|
||||||
|
val on = status?.powerOn == true
|
||||||
|
Text(
|
||||||
|
text = status?.operatingState ?: if (on) "켜짐" else "꺼짐",
|
||||||
|
style = MaterialTheme.typography.displayLarge,
|
||||||
|
color = if (on) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (state.settings.showRemainingTime && status?.remainingMinutes != null) {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
"남은 시간 약 ${status.remainingMinutes}분",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.selected?.type == DeviceType.AIRCONDITIONER) {
|
||||||
|
status?.currentTemperature?.let {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
val target = status.targetTemperature?.let { t -> " · 설정 ${t}도" } ?: ""
|
||||||
|
Text(
|
||||||
|
"현재 ${it}도$target",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.settings.showReservation) {
|
||||||
|
status?.reservation?.let {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text("예약: $it", style = MaterialTheme.typography.titleLarge, textAlign = TextAlign.Center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PowerControls(isOn: Boolean, stacked: Boolean, onTogglePower: (Boolean) -> Unit) {
|
||||||
|
val onButton: @Composable (Modifier) -> Unit = { m ->
|
||||||
|
Button(
|
||||||
|
onClick = { onTogglePower(true) },
|
||||||
|
enabled = !isOn,
|
||||||
|
modifier = m.height(BigButtonHeight),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondary,
|
||||||
|
contentColor = Color.White,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, modifier = Modifier.size(30.dp))
|
||||||
|
Text(" 켜기", style = MaterialTheme.typography.labelLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val offButton: @Composable (Modifier) -> Unit = { m ->
|
||||||
|
Button(
|
||||||
|
onClick = { onTogglePower(false) },
|
||||||
|
enabled = isOn,
|
||||||
|
modifier = m.height(BigButtonHeight),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
|
contentColor = Color.White,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, modifier = Modifier.size(30.dp))
|
||||||
|
Text(" 끄기", style = MaterialTheme.typography.labelLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stacked) {
|
||||||
|
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
onButton(Modifier.fillMaxWidth())
|
||||||
|
offButton(Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
onButton(Modifier.weight(1f))
|
||||||
|
offButton(Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MacroControls(isOn: Boolean, onStart: () -> Unit, onOff: () -> Unit) {
|
||||||
|
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = onStart,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(96.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondary,
|
||||||
|
contentColor = Color.White,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.PlayArrow, contentDescription = null, modifier = Modifier.size(36.dp))
|
||||||
|
Text(" 시작", style = MaterialTheme.typography.headlineMedium)
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = onOff,
|
||||||
|
enabled = isOn,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(BigButtonHeight),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
|
contentColor = Color.White,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, modifier = Modifier.size(30.dp))
|
||||||
|
Text(" 끄기", style = MaterialTheme.typography.labelLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyState() {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"등록된 기기가 없습니다",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
"관리자 화면에서 기기를 선택해 주세요.\n(화면 위쪽 양쪽 모서리를 동시에 길게 누르세요)",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
197
app/src/main/java/kr/tkrmagid/easyappliance/ui/MacroEditor.kt
Normal file
197
app/src/main/java/kr/tkrmagid/easyappliance/ui/MacroEditor.kt
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
package kr.tkrmagid.easyappliance.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
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.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kr.tkrmagid.easyappliance.data.Device
|
||||||
|
import kr.tkrmagid.easyappliance.data.DeviceMacro
|
||||||
|
import kr.tkrmagid.easyappliance.data.DeviceType
|
||||||
|
import kr.tkrmagid.easyappliance.data.MacroStep
|
||||||
|
|
||||||
|
private const val TEMP_CAP = "custom.washerWaterTemperature"
|
||||||
|
private const val TEMP_CMD = "setWasherWaterTemperature"
|
||||||
|
private const val LEVEL_CAP = "custom.washerWaterLevel"
|
||||||
|
private const val LEVEL_CMD = "setWasherWaterLevel"
|
||||||
|
|
||||||
|
private data class Preset(val value: String, val label: String)
|
||||||
|
|
||||||
|
private val TEMP_PRESETS = listOf(
|
||||||
|
Preset("cold", "냉수"),
|
||||||
|
Preset("30", "30도"),
|
||||||
|
Preset("40", "40도"),
|
||||||
|
Preset("60", "60도"),
|
||||||
|
)
|
||||||
|
private val LEVEL_PRESETS = listOf(
|
||||||
|
Preset("low", "낮음"),
|
||||||
|
Preset("medium", "중간"),
|
||||||
|
Preset("high", "높음"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
fun MacroEditor(device: Device, macro: DeviceMacro, onChange: (DeviceMacro) -> Unit) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
) {
|
||||||
|
Column(Modifier.fillMaxWidth().padding(12.dp)) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"매크로 사용 (시작 시 자동 설정)",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Switch(
|
||||||
|
checked = macro.enabled,
|
||||||
|
onCheckedChange = { onChange(macro.copy(enabled = it)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!macro.enabled) return@Column
|
||||||
|
|
||||||
|
if (device.type == DeviceType.WASHER) {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text("물 온도", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
TEMP_PRESETS.forEach { p ->
|
||||||
|
val selected = macro.steps.any { it.capability == TEMP_CAP && it.args == listOf(p.value) }
|
||||||
|
FilterChip(
|
||||||
|
selected = selected,
|
||||||
|
onClick = {
|
||||||
|
onChange(
|
||||||
|
macro.upsert(
|
||||||
|
MacroStep(TEMP_CAP, TEMP_CMD, listOf(p.value), "물 온도 ${p.label}"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = { Text(p.label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text("수위(물 높이)", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
LEVEL_PRESETS.forEach { p ->
|
||||||
|
val selected = macro.steps.any { it.capability == LEVEL_CAP && it.args == listOf(p.value) }
|
||||||
|
FilterChip(
|
||||||
|
selected = selected,
|
||||||
|
onClick = {
|
||||||
|
onChange(
|
||||||
|
macro.upsert(
|
||||||
|
MacroStep(LEVEL_CAP, LEVEL_CMD, listOf(p.value), "수위 ${p.label}"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = { Text(p.label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
"※ 삼성 세탁기 기준 예시 명령입니다. 실제 기기에서 동작하지 않으면 아래 '직접 추가'로 조정하세요.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Text("현재 매크로 동작", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
if (macro.steps.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
"설정된 동작이 없습니다. 시작 시 전원만 켜집니다.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
macro.steps.forEach { step ->
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
step.label.ifBlank { "${step.capability} · ${step.command}" },
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
IconButton(onClick = { onChange(macro.removeStep(step)) }) {
|
||||||
|
Icon(Icons.Filled.Delete, contentDescription = "삭제")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
AdvancedStepAdder(onAdd = { onChange(macro.upsert(it)) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AdvancedStepAdder(onAdd: (MacroStep) -> Unit) {
|
||||||
|
var cap by remember { mutableStateOf("") }
|
||||||
|
var cmd by remember { mutableStateOf("") }
|
||||||
|
var arg by remember { mutableStateOf("") }
|
||||||
|
Column(Modifier.fillMaxWidth()) {
|
||||||
|
Text("직접 추가 (고급)", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = cap, onValueChange = { cap = it }, singleLine = true,
|
||||||
|
label = { Text("capability") }, modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = cmd, onValueChange = { cmd = it }, singleLine = true,
|
||||||
|
label = { Text("command") }, modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = arg, onValueChange = { arg = it }, singleLine = true,
|
||||||
|
label = { Text("argument (선택)") }, modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (cap.isNotBlank() && cmd.isNotBlank()) {
|
||||||
|
val args = if (arg.isBlank()) emptyList() else listOf(arg.trim())
|
||||||
|
onAdd(MacroStep(cap.trim(), cmd.trim(), args, "$cap · $cmd"))
|
||||||
|
cap = ""; cmd = ""; arg = ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.height(52.dp),
|
||||||
|
) { Text("명령 추가") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DeviceMacro.upsert(step: MacroStep): DeviceMacro =
|
||||||
|
copy(steps = steps.filterNot { it.capability == step.capability } + step)
|
||||||
|
|
||||||
|
private fun DeviceMacro.removeStep(step: MacroStep): DeviceMacro =
|
||||||
|
copy(steps = steps.filterNot { it.capability == step.capability && it.command == step.command && it.args == step.args })
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package kr.tkrmagid.easyappliance.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
// High-contrast, calm palette tuned for older users.
|
||||||
|
private val BrandPrimary = Color(0xFF1565C0)
|
||||||
|
private val BrandPrimaryDark = Color(0xFF90CAF9)
|
||||||
|
|
||||||
|
private val LightColors = lightColorScheme(
|
||||||
|
primary = BrandPrimary,
|
||||||
|
onPrimary = Color.White,
|
||||||
|
primaryContainer = Color(0xFFD6E4FF),
|
||||||
|
onPrimaryContainer = Color(0xFF001A41),
|
||||||
|
secondary = Color(0xFF2E7D32),
|
||||||
|
onSecondary = Color.White,
|
||||||
|
error = Color(0xFFC62828),
|
||||||
|
onError = Color.White,
|
||||||
|
background = Color(0xFFFDFDFD),
|
||||||
|
onBackground = Color(0xFF1A1A1A),
|
||||||
|
surface = Color(0xFFFFFFFF),
|
||||||
|
onSurface = Color(0xFF1A1A1A),
|
||||||
|
surfaceVariant = Color(0xFFEEF2F7),
|
||||||
|
onSurfaceVariant = Color(0xFF33383E),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val DarkColors = darkColorScheme(
|
||||||
|
primary = BrandPrimaryDark,
|
||||||
|
onPrimary = Color(0xFF00305F),
|
||||||
|
background = Color(0xFF121212),
|
||||||
|
onBackground = Color(0xFFF2F2F2),
|
||||||
|
surface = Color(0xFF1E1E1E),
|
||||||
|
onSurface = Color(0xFFF2F2F2),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun EasyApplianceTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = if (darkTheme) DarkColors else LightColors,
|
||||||
|
typography = ElderlyTypography,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
17
app/src/main/java/kr/tkrmagid/easyappliance/ui/theme/Type.kt
Normal file
17
app/src/main/java/kr/tkrmagid/easyappliance/ui/theme/Type.kt
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package kr.tkrmagid.easyappliance.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
// Larger-than-default type scale so text stays readable for older users.
|
||||||
|
val ElderlyTypography = Typography(
|
||||||
|
displayLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 48.sp, lineHeight = 56.sp),
|
||||||
|
headlineLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 40.sp, lineHeight = 48.sp),
|
||||||
|
headlineMedium = TextStyle(fontWeight = FontWeight.Bold, fontSize = 32.sp, lineHeight = 40.sp),
|
||||||
|
titleLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 34.sp),
|
||||||
|
bodyLarge = TextStyle(fontWeight = FontWeight.Normal, fontSize = 22.sp, lineHeight = 30.sp),
|
||||||
|
bodyMedium = TextStyle(fontWeight = FontWeight.Normal, fontSize = 20.sp, lineHeight = 28.sp),
|
||||||
|
labelLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 24.sp, lineHeight = 30.sp),
|
||||||
|
)
|
||||||
190
app/src/main/java/kr/tkrmagid/easyappliance/vm/AppViewModel.kt
Normal file
190
app/src/main/java/kr/tkrmagid/easyappliance/vm/AppViewModel.kt
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
package kr.tkrmagid.easyappliance.vm
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kr.tkrmagid.easyappliance.data.AppSettings
|
||||||
|
import kr.tkrmagid.easyappliance.data.Device
|
||||||
|
import kr.tkrmagid.easyappliance.data.DeviceRepository
|
||||||
|
import kr.tkrmagid.easyappliance.data.DeviceStatus
|
||||||
|
import kr.tkrmagid.easyappliance.data.MockDeviceRepository
|
||||||
|
import kr.tkrmagid.easyappliance.data.DeviceImages
|
||||||
|
import kr.tkrmagid.easyappliance.data.DeviceMacro
|
||||||
|
import kr.tkrmagid.easyappliance.data.SettingsRepository
|
||||||
|
import kr.tkrmagid.easyappliance.data.smartthings.SmartThingsClient
|
||||||
|
import kr.tkrmagid.easyappliance.data.smartthings.SmartThingsDeviceRepository
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class HomeUiState(
|
||||||
|
val loading: Boolean = true,
|
||||||
|
val devices: List<Device> = emptyList(),
|
||||||
|
val selected: Device? = null,
|
||||||
|
val status: DeviceStatus? = null,
|
||||||
|
val settings: AppSettings = AppSettings(),
|
||||||
|
val usingRealApi: Boolean = false,
|
||||||
|
/** False when the selected device / API cannot be reached. */
|
||||||
|
val connected: Boolean = true,
|
||||||
|
/** True after the user closes the warning with the small "x". */
|
||||||
|
val warningDismissed: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
|
) {
|
||||||
|
val showConnectionWarning: Boolean get() = !connected && !warningDismissed
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
|
||||||
|
private val settingsRepo = SettingsRepository(app)
|
||||||
|
private var deviceRepo: DeviceRepository = MockDeviceRepository()
|
||||||
|
private var activeToken: String? = null
|
||||||
|
private var lastSelectedId: String? = null
|
||||||
|
/** Preview flag so the admin can see the warning UI without a real outage. */
|
||||||
|
private var forcedWarning = false
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(HomeUiState())
|
||||||
|
val state: StateFlow<HomeUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
settingsRepo.settings.collect { settings -> applySettings(settings) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun applySettings(settings: AppSettings) {
|
||||||
|
val tokenChanged = settings.smartThingsToken != activeToken
|
||||||
|
if (tokenChanged) {
|
||||||
|
activeToken = settings.smartThingsToken
|
||||||
|
deviceRepo = if (settings.smartThingsToken.isNullOrBlank()) {
|
||||||
|
MockDeviceRepository()
|
||||||
|
} else {
|
||||||
|
SmartThingsDeviceRepository(SmartThingsClient.create(settings.smartThingsToken))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_state.update {
|
||||||
|
it.copy(settings = settings, usingRealApi = !settings.smartThingsToken.isNullOrBlank())
|
||||||
|
}
|
||||||
|
if (tokenChanged || _state.value.devices.isEmpty()) {
|
||||||
|
loadDevices()
|
||||||
|
}
|
||||||
|
resolveSelectionAndStatus(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadDevices() {
|
||||||
|
runCatching { deviceRepo.listDevices() }
|
||||||
|
.onSuccess { devices ->
|
||||||
|
_state.update { it.copy(devices = devices, connected = true) }
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
_state.update { it.copy(loading = false, connected = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun resolveSelectionAndStatus(settings: AppSettings) {
|
||||||
|
val devices = _state.value.devices
|
||||||
|
val selected = devices.firstOrNull { it.id == settings.selectedDeviceId }
|
||||||
|
?: devices.firstOrNull()
|
||||||
|
_state.update { it.copy(selected = selected) }
|
||||||
|
if (selected == null) {
|
||||||
|
_state.update { it.copy(loading = false) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selected.id != lastSelectedId) {
|
||||||
|
lastSelectedId = selected.id
|
||||||
|
loadStatus(selected.id)
|
||||||
|
} else {
|
||||||
|
_state.update { it.copy(loading = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadStatus(deviceId: String) {
|
||||||
|
runCatching { deviceRepo.getStatus(deviceId) }
|
||||||
|
.onSuccess { status ->
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
loading = false,
|
||||||
|
status = status,
|
||||||
|
connected = !forcedWarning,
|
||||||
|
warningDismissed = if (!forcedWarning) false else it.warningDismissed,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onFailure {
|
||||||
|
_state.update { it.copy(loading = false, connected = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Refresh selected device status; also used by the periodic poll. */
|
||||||
|
fun refresh() {
|
||||||
|
val id = _state.value.selected?.id ?: return
|
||||||
|
viewModelScope.launch { loadStatus(id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "다시 연결하기" — clear preview flag and re-attempt list + status. */
|
||||||
|
fun retryConnection() {
|
||||||
|
forcedWarning = false
|
||||||
|
viewModelScope.launch {
|
||||||
|
loadDevices()
|
||||||
|
resolveSelectionAndStatus(_state.value.settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissWarning() {
|
||||||
|
_state.update { it.copy(warningDismissed = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Admin preview of the disconnection warning (testing aid). */
|
||||||
|
fun previewConnectionWarning() {
|
||||||
|
forcedWarning = true
|
||||||
|
_state.update { it.copy(connected = false, warningDismissed = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectDevice(deviceId: String) {
|
||||||
|
viewModelScope.launch { settingsRepo.setSelectedDevice(deviceId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun togglePower(on: Boolean) {
|
||||||
|
val id = _state.value.selected?.id ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { deviceRepo.setPower(id, on) }
|
||||||
|
.onFailure { _state.update { s -> s.copy(connected = false) } }
|
||||||
|
loadStatus(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run the selected device's start macro (apply presets, then start). */
|
||||||
|
fun runMacro() {
|
||||||
|
val id = _state.value.selected?.id ?: return
|
||||||
|
val macro = _state.value.settings.deviceMacros[id] ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { deviceRepo.runMacro(id, macro.steps) }
|
||||||
|
.onFailure { _state.update { s -> s.copy(connected = false) } }
|
||||||
|
loadStatus(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDeviceMacro(deviceId: String, macro: DeviceMacro) {
|
||||||
|
viewModelScope.launch { settingsRepo.setDeviceMacro(deviceId, macro) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setShowRemainingTime(value: Boolean) { viewModelScope.launch { settingsRepo.setShowRemainingTime(value) } }
|
||||||
|
fun setShowReservation(value: Boolean) { viewModelScope.launch { settingsRepo.setShowReservation(value) } }
|
||||||
|
fun setShowPowerControls(value: Boolean) { viewModelScope.launch { settingsRepo.setShowPowerControls(value) } }
|
||||||
|
fun setToken(token: String) { viewModelScope.launch { settingsRepo.setToken(token) } }
|
||||||
|
fun setDeviceLabel(deviceId: String, label: String) { viewModelScope.launch { settingsRepo.setDeviceLabel(deviceId, label) } }
|
||||||
|
fun setDeviceImage(deviceId: String, imageRef: String) { viewModelScope.launch { settingsRepo.setDeviceImage(deviceId, imageRef) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display name for a device, applying the admin's custom-name override. */
|
||||||
|
fun HomeUiState.displayLabelFor(device: Device): String =
|
||||||
|
settings.labelOverrides[device.id]?.takeIf { it.isNotBlank() } ?: device.label
|
||||||
|
|
||||||
|
/** Image reference for a device: custom override, else the type default. */
|
||||||
|
fun HomeUiState.imageRefFor(device: Device): String =
|
||||||
|
settings.imageOverrides[device.id] ?: DeviceImages.defaultRefFor(device.type)
|
||||||
|
|
||||||
|
/** The device's macro, or null if none configured. */
|
||||||
|
fun HomeUiState.macroFor(device: Device): DeviceMacro? = settings.deviceMacros[device.id]
|
||||||
27
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
27
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<!-- washing machine body -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M36,30 h36 a4,4 0 0 1 4,4 v40 a4,4 0 0 1 -4,4 h-36 a4,4 0 0 1 -4,-4 v-40 a4,4 0 0 1 4,-4 z" />
|
||||||
|
<!-- top control bar -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#1565C0"
|
||||||
|
android:pathData="M36,36 h36 v6 h-36 z" />
|
||||||
|
<!-- knob -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#1565C0"
|
||||||
|
android:pathData="M67,38 m-2,0 a2,2 0 1,0 4,0 a2,2 0 1,0 -4,0" />
|
||||||
|
<!-- door outer -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#1565C0"
|
||||||
|
android:pathData="M54,62 m-14,0 a14,14 0 1,0 28,0 a14,14 0 1,0 -28,0" />
|
||||||
|
<!-- door inner -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M54,62 m-9,0 a9,9 0 1,0 18,0 a9,9 0 1,0 -18,0" />
|
||||||
|
</vector>
|
||||||
11
app/src/main/res/drawable/img_aircon.xml
Normal file
11
app/src/main/res/drawable/img_aircon.xml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="96dp" android:height="96dp"
|
||||||
|
android:viewportWidth="48" android:viewportHeight="48">
|
||||||
|
<path android:fillColor="#1565C0"
|
||||||
|
android:pathData="M6,14 h36 a3,3 0 0 1 3,3 v10 a3,3 0 0 1 -3,3 h-36 a3,3 0 0 1 -3,-3 v-10 a3,3 0 0 1 3,-3 z" />
|
||||||
|
<path android:fillColor="#FFFFFF" android:pathData="M7,25 h34 v2.5 h-34 z" />
|
||||||
|
<path android:fillColor="#90CAF9" android:pathData="M8,19 h20 v2 h-20 z" />
|
||||||
|
<path android:fillColor="#42A5F5"
|
||||||
|
android:strokeColor="#42A5F5" android:strokeWidth="1.4"
|
||||||
|
android:pathData="M12,33 q2,3 4,0 M22,33 q2,3 4,0 M32,33 q2,3 4,0" />
|
||||||
|
</vector>
|
||||||
8
app/src/main/res/drawable/img_device.xml
Normal file
8
app/src/main/res/drawable/img_device.xml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="96dp" android:height="96dp"
|
||||||
|
android:viewportWidth="48" android:viewportHeight="48">
|
||||||
|
<path android:fillColor="#1565C0"
|
||||||
|
android:pathData="M10,10 h28 a4,4 0 0 1 4,4 v20 a4,4 0 0 1 -4,4 h-28 a4,4 0 0 1 -4,-4 v-20 a4,4 0 0 1 4,-4 z" />
|
||||||
|
<path android:fillColor="#FFFFFF" android:pathData="M24,24 m-6,0 a6,6 0 1,0 12,0 a6,6 0 1,0 -12,0" />
|
||||||
|
<path android:fillColor="#1565C0" android:pathData="M24,24 m-2.5,0 a2.5,2.5 0 1,0 5,0 a2.5,2.5 0 1,0 -5,0" />
|
||||||
|
</vector>
|
||||||
9
app/src/main/res/drawable/img_fridge.xml
Normal file
9
app/src/main/res/drawable/img_fridge.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="96dp" android:height="96dp"
|
||||||
|
android:viewportWidth="48" android:viewportHeight="48">
|
||||||
|
<path android:fillColor="#1565C0"
|
||||||
|
android:pathData="M14,4 h20 a3,3 0 0 1 3,3 v34 a3,3 0 0 1 -3,3 h-20 a3,3 0 0 1 -3,-3 v-34 a3,3 0 0 1 3,-3 z" />
|
||||||
|
<path android:fillColor="#FFFFFF" android:pathData="M11,18 h26 v2 h-26 z" />
|
||||||
|
<path android:fillColor="#90CAF9" android:pathData="M15,9 h3 v6 h-3 z" />
|
||||||
|
<path android:fillColor="#90CAF9" android:pathData="M15,23 h3 v7 h-3 z" />
|
||||||
|
</vector>
|
||||||
9
app/src/main/res/drawable/img_tv.xml
Normal file
9
app/src/main/res/drawable/img_tv.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="96dp" android:height="96dp"
|
||||||
|
android:viewportWidth="48" android:viewportHeight="48">
|
||||||
|
<path android:fillColor="#1565C0"
|
||||||
|
android:pathData="M6,10 h36 a3,3 0 0 1 3,3 v18 a3,3 0 0 1 -3,3 h-36 a3,3 0 0 1 -3,-3 v-18 a3,3 0 0 1 3,-3 z" />
|
||||||
|
<path android:fillColor="#BBDEFB" android:pathData="M7,14 h34 v16 h-34 z" />
|
||||||
|
<path android:fillColor="#1565C0" android:pathData="M20,38 h8 v2 h-8 z" />
|
||||||
|
<path android:fillColor="#1565C0" android:pathData="M16,40 h16 v2 h-16 z" />
|
||||||
|
</vector>
|
||||||
10
app/src/main/res/drawable/img_washer.xml
Normal file
10
app/src/main/res/drawable/img_washer.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="96dp" android:height="96dp"
|
||||||
|
android:viewportWidth="48" android:viewportHeight="48">
|
||||||
|
<path android:fillColor="#1565C0"
|
||||||
|
android:pathData="M12,6 h24 a3,3 0 0 1 3,3 v30 a3,3 0 0 1 -3,3 h-24 a3,3 0 0 1 -3,-3 v-30 a3,3 0 0 1 3,-3 z" />
|
||||||
|
<path android:fillColor="#FFFFFF" android:pathData="M12,10 h24 v4 h-24 z" />
|
||||||
|
<path android:fillColor="#90CAF9" android:pathData="M31,12 m-1.6,0 a1.6,1.6 0 1,0 3.2,0 a1.6,1.6 0 1,0 -3.2,0" />
|
||||||
|
<path android:fillColor="#FFFFFF" android:pathData="M24,27 m-11,0 a11,11 0 1,0 22,0 a11,11 0 1,0 -22,0" />
|
||||||
|
<path android:fillColor="#42A5F5" android:pathData="M24,27 m-7,0 a7,7 0 1,0 14,0 a7,7 0 1,0 -14,0" />
|
||||||
|
</vector>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
6
app/src/main/res/values/colors.xml
Normal file
6
app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="brand_primary">#1565C0</color>
|
||||||
|
<color name="brand_background">#FFFFFF</color>
|
||||||
|
<color name="ic_launcher_background">#1565C0</color>
|
||||||
|
</resources>
|
||||||
3
app/src/main/res/values/strings.xml
Normal file
3
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">쉬운 가전 리모컨</string>
|
||||||
|
</resources>
|
||||||
7
app/src/main/res/values/themes.xml
Normal file
7
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.EasyAppliance" parent="android:Theme.Material.Light.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">@color/brand_primary</item>
|
||||||
|
<item name="android:windowLightStatusBar">false</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package kr.tkrmagid.easyappliance
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kr.tkrmagid.easyappliance.data.MockDeviceRepository
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class MockDeviceRepositoryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun listsBothSampleDevices() = runTest {
|
||||||
|
val repo = MockDeviceRepository()
|
||||||
|
val ids = repo.listDevices().map { it.id }
|
||||||
|
assertEquals(listOf("washer-1", "aircon-1"), ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun powerToggleIsReflectedInStatus() = runTest {
|
||||||
|
val repo = MockDeviceRepository()
|
||||||
|
assertFalse(repo.getStatus("washer-1").powerOn)
|
||||||
|
|
||||||
|
repo.setPower("washer-1", true)
|
||||||
|
val on = repo.getStatus("washer-1")
|
||||||
|
assertTrue(on.powerOn)
|
||||||
|
assertEquals("운전 중", on.operatingState)
|
||||||
|
assertEquals(32, on.remainingMinutes)
|
||||||
|
|
||||||
|
repo.setPower("washer-1", false)
|
||||||
|
assertFalse(repo.getStatus("washer-1").powerOn)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun runMacroStartsDevice() = runTest {
|
||||||
|
val repo = MockDeviceRepository()
|
||||||
|
assertFalse(repo.getStatus("washer-1").powerOn)
|
||||||
|
repo.runMacro("washer-1", emptyList())
|
||||||
|
assertTrue(repo.getStatus("washer-1").powerOn)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun airconReportsTemperatureWhenOn() = runTest {
|
||||||
|
val repo = MockDeviceRepository()
|
||||||
|
val status = repo.getStatus("aircon-1")
|
||||||
|
assertTrue(status.powerOn)
|
||||||
|
assertEquals(27, status.currentTemperature)
|
||||||
|
assertEquals(24, status.targetTemperature)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package kr.tkrmagid.easyappliance
|
||||||
|
|
||||||
|
import kr.tkrmagid.easyappliance.data.DeviceType
|
||||||
|
import kr.tkrmagid.easyappliance.data.smartthings.SmartThingsStatusMapper
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class SmartThingsStatusMapperTest {
|
||||||
|
|
||||||
|
private fun obj(json: String) = Json.parseToJsonElement(json).jsonObject
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mapsRunningWasher() {
|
||||||
|
val status = obj(
|
||||||
|
"""
|
||||||
|
{"components":{"main":{
|
||||||
|
"switch":{"switch":{"value":"on"}},
|
||||||
|
"washerOperatingState":{"machineState":{"value":"run"}},
|
||||||
|
"samsungce.washerOperatingState":{"remainingTime":{"value":1980}}
|
||||||
|
}}}
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
val result = SmartThingsStatusMapper.map(status, DeviceType.WASHER)
|
||||||
|
assertTrue(result.powerOn)
|
||||||
|
assertEquals("운전 중", result.operatingState)
|
||||||
|
assertEquals(33, result.remainingMinutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mapsAircon() {
|
||||||
|
val status = obj(
|
||||||
|
"""
|
||||||
|
{"components":{"main":{
|
||||||
|
"switch":{"switch":{"value":"on"}},
|
||||||
|
"temperatureMeasurement":{"temperature":{"value":27}},
|
||||||
|
"thermostatCoolingSetpoint":{"coolingSetpoint":{"value":24}}
|
||||||
|
}}}
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
val result = SmartThingsStatusMapper.map(status, DeviceType.AIRCONDITIONER)
|
||||||
|
assertTrue(result.powerOn)
|
||||||
|
assertEquals("냉방 중", result.operatingState)
|
||||||
|
assertEquals(27, result.currentTemperature)
|
||||||
|
assertEquals(24, result.targetTemperature)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun offDeviceHasNoTarget() {
|
||||||
|
val status = obj(
|
||||||
|
"""
|
||||||
|
{"components":{"main":{
|
||||||
|
"switch":{"switch":{"value":"off"}},
|
||||||
|
"temperatureMeasurement":{"temperature":{"value":28}},
|
||||||
|
"thermostatCoolingSetpoint":{"coolingSetpoint":{"value":22}}
|
||||||
|
}}}
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
val result = SmartThingsStatusMapper.map(status, DeviceType.AIRCONDITIONER)
|
||||||
|
assertTrue(!result.powerOn)
|
||||||
|
assertEquals("꺼짐", result.operatingState)
|
||||||
|
assertEquals(28, result.currentTemperature)
|
||||||
|
assertNull(result.targetTemperature)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun missingComponentsIsSafe() {
|
||||||
|
val result = SmartThingsStatusMapper.map(obj("""{}"""), DeviceType.WASHER)
|
||||||
|
assertTrue(!result.powerOn)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
build.gradle.kts
Normal file
6
build.gradle.kts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
|
}
|
||||||
5
gradle.properties
Normal file
5
gradle.properties
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.caching=true
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
|
kotlin.code.style=official
|
||||||
45
gradle/libs.versions.toml
Normal file
45
gradle/libs.versions.toml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "8.7.3"
|
||||||
|
kotlin = "2.0.21"
|
||||||
|
coreKtx = "1.15.0"
|
||||||
|
lifecycle = "2.8.7"
|
||||||
|
activityCompose = "1.9.3"
|
||||||
|
composeBom = "2024.12.01"
|
||||||
|
navigationCompose = "2.8.5"
|
||||||
|
datastore = "1.1.1"
|
||||||
|
serialization = "1.7.3"
|
||||||
|
retrofit = "2.11.0"
|
||||||
|
okhttp = "4.12.0"
|
||||||
|
retrofitSerialization = "1.0.0"
|
||||||
|
coil = "2.7.0"
|
||||||
|
junit = "4.13.2"
|
||||||
|
coroutinesTest = "1.9.0"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||||
|
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||||
|
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-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||||
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||||
|
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||||
|
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
|
||||||
|
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
|
||||||
|
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
|
||||||
|
retrofit-kotlinx-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitSerialization" }
|
||||||
|
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
|
||||||
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
252
gradlew
vendored
Executable file
252
gradlew
vendored
Executable file
@@ -0,0 +1,252 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 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
|
||||||
|
' "$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=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
# 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, 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" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# 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" "$@"
|
||||||
94
gradlew.bat
vendored
Normal file
94
gradlew.bat
vendored
Normal file
@@ -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=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
: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
|
||||||
23
settings.gradle.kts
Normal file
23
settings.gradle.kts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google {
|
||||||
|
content {
|
||||||
|
includeGroupByRegex("com\\.android.*")
|
||||||
|
includeGroupByRegex("com\\.google.*")
|
||||||
|
includeGroupByRegex("androidx.*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "EasyAppliance"
|
||||||
|
include(":app")
|
||||||
Reference in New Issue
Block a user