feat: EasyAppliance app v0.3.0 (SmartThings elderly-friendly remote)

This commit is contained in:
tkrmagid
2026-08-05 14:37:31 +09:00
parent a8eccd2c68
commit 39a4543640
46 changed files with 2719 additions and 2 deletions

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
*.iml
.gradle/
/local.properties
/keystore.properties
*.keystore
*.jks
/.idea/
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
app/build/
build/
.kotlin/

View File

@@ -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
View 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
View File

View 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>

View 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()
}
}
}
}

View File

@@ -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")
}
}

View File

@@ -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
}
}

View File

@@ -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()
}

View 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(),
)

View 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,
)

View File

@@ -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")
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}
}

View File

@@ -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
}

View File

@@ -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())
}
}
}
}

View 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)
}
}

View 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,
)
}
}
}
}

View File

@@ -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)
}
}
}
}
}
}

View File

@@ -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,
)
}
}

View 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,
)
}
}

View 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 })

View File

@@ -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,
)
}

View 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),
)

View 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]

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">쉬운 가전 리모컨</string>
</resources>

View 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>

View File

@@ -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)
}
}

View File

@@ -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
View 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
View 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
View 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

Binary file not shown.

View 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
View 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
View 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
View 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")