diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7ff173e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+*.iml
+.gradle/
+/local.properties
+/keystore.properties
+*.keystore
+*.jks
+/.idea/
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+app/build/
+build/
+.kotlin/
diff --git a/README.md b/README.md
index e34d10e..9d58ce3 100644
--- a/README.md
+++ b/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.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..62c6845
--- /dev/null
+++ b/app/build.gradle.kts
@@ -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)
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..e69de29
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..6a94bc4
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/MainActivity.kt b/app/src/main/java/kr/tkrmagid/easyappliance/MainActivity.kt
new file mode 100644
index 0000000..d9c9a4c
--- /dev/null
+++ b/app/src/main/java/kr/tkrmagid/easyappliance/MainActivity.kt
@@ -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()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/data/DeviceImages.kt b/app/src/main/java/kr/tkrmagid/easyappliance/data/DeviceImages.kt
new file mode 100644
index 0000000..fc9e60a
--- /dev/null
+++ b/app/src/main/java/kr/tkrmagid/easyappliance/data/DeviceImages.kt
@@ -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:" 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 = 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:" 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")
+ }
+}
diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/data/DeviceRepository.kt b/app/src/main/java/kr/tkrmagid/easyappliance/data/DeviceRepository.kt
new file mode 100644
index 0000000..8515666
--- /dev/null
+++ b/app/src/main/java/kr/tkrmagid/easyappliance/data/DeviceRepository.kt
@@ -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
+ 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)
+}
+
+/**
+ * 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 = 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) {
+ // Sample mode: steps are a no-op; the device simply starts.
+ powerState[deviceId] = true
+ }
+}
diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/data/ImageStorage.kt b/app/src/main/java/kr/tkrmagid/easyappliance/data/ImageStorage.kt
new file mode 100644
index 0000000..453f34e
--- /dev/null
+++ b/app/src/main/java/kr/tkrmagid/easyappliance/data/ImageStorage.kt
@@ -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()
+}
diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/data/Macro.kt b/app/src/main/java/kr/tkrmagid/easyappliance/data/Macro.kt
new file mode 100644
index 0000000..11c91af
--- /dev/null
+++ b/app/src/main/java/kr/tkrmagid/easyappliance/data/Macro.kt
@@ -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 = 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 = emptyList(),
+)
diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/data/Models.kt b/app/src/main/java/kr/tkrmagid/easyappliance/data/Models.kt
new file mode 100644
index 0000000..88cf8c0
--- /dev/null
+++ b/app/src/main/java/kr/tkrmagid/easyappliance/data/Models.kt
@@ -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,
+)
diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/data/SettingsRepository.kt b/app/src/main/java/kr/tkrmagid/easyappliance/data/SettingsRepository.kt
new file mode 100644
index 0000000..e8a2761
--- /dev/null
+++ b/app/src/main/java/kr/tkrmagid/easyappliance/data/SettingsRepository.kt
@@ -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 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 = emptyMap(),
+ /** deviceId -> image reference ("builtin:" or a content:// Uri). */
+ val imageOverrides: Map = emptyMap(),
+ /** deviceId -> start macro. */
+ val deviceMacros: Map = emptyMap(),
+)
+
+/** Persists [AppSettings] via Jetpack DataStore. */
+class SettingsRepository(private val context: Context) {
+
+ val settings: Flow = 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 =
+ if (raw.isNullOrBlank()) emptyMap()
+ else runCatching { Json.decodeFromString