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>(raw) }.getOrDefault(emptyMap()) + + private fun decodeMacros(raw: String?): Map = + if (raw.isNullOrBlank()) emptyMap() + else runCatching { Json.decodeFromString>(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") + } +} diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/data/smartthings/SmartThingsApi.kt b/app/src/main/java/kr/tkrmagid/easyappliance/data/smartthings/SmartThingsApi.kt new file mode 100644 index 0000000..934ac91 --- /dev/null +++ b/app/src/main/java/kr/tkrmagid/easyappliance/data/smartthings/SmartThingsApi.kt @@ -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 = emptyList()) + +@Serializable +data class StDevice( + val deviceId: String, + val label: String? = null, + val name: String? = null, + val components: List = emptyList(), +) + +@Serializable +data class StComponent(val id: String = "main", val capabilities: List = 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 = emptyList(), +) + +@Serializable +data class StCommandsRequest(val commands: List) + +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) + } +} diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/data/smartthings/SmartThingsDeviceRepository.kt b/app/src/main/java/kr/tkrmagid/easyappliance/data/smartthings/SmartThingsDeviceRepository.kt new file mode 100644 index 0000000..95282f7 --- /dev/null +++ b/app/src/main/java/kr/tkrmagid/easyappliance/data/smartthings/SmartThingsDeviceRepository.kt @@ -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() + + override suspend fun listDevices(): List { + 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) { + 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, 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 + } + } +} diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/data/smartthings/SmartThingsStatusMapper.kt b/app/src/main/java/kr/tkrmagid/easyappliance/data/smartthings/SmartThingsStatusMapper.kt new file mode 100644 index 0000000..fb29191 --- /dev/null +++ b/app/src/main/java/kr/tkrmagid/easyappliance/data/smartthings/SmartThingsStatusMapper.kt @@ -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 +} diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/ui/AdminGesture.kt b/app/src/main/java/kr/tkrmagid/easyappliance/ui/AdminGesture.kt new file mode 100644 index 0000000..3a2a2dc --- /dev/null +++ b/app/src/main/java/kr/tkrmagid/easyappliance/ui/AdminGesture.kt @@ -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()) + } + } + } +} diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/ui/AdminScreen.kt b/app/src/main/java/kr/tkrmagid/easyappliance/ui/AdminScreen.kt new file mode 100644 index 0000000..28357bd --- /dev/null +++ b/app/src/main/java/kr/tkrmagid/easyappliance/ui/AdminScreen.kt @@ -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) + } +} diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/ui/AppRoot.kt b/app/src/main/java/kr/tkrmagid/easyappliance/ui/AppRoot.kt new file mode 100644 index 0000000..75348c7 --- /dev/null +++ b/app/src/main/java/kr/tkrmagid/easyappliance/ui/AppRoot.kt @@ -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, + ) + } + } + } +} diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/ui/ConnectionWarning.kt b/app/src/main/java/kr/tkrmagid/easyappliance/ui/ConnectionWarning.kt new file mode 100644 index 0000000..85038f1 --- /dev/null +++ b/app/src/main/java/kr/tkrmagid/easyappliance/ui/ConnectionWarning.kt @@ -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) + } + } + } + } + } +} diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/ui/DeviceImage.kt b/app/src/main/java/kr/tkrmagid/easyappliance/ui/DeviceImage.kt new file mode 100644 index 0000000..04a60e6 --- /dev/null +++ b/app/src/main/java/kr/tkrmagid/easyappliance/ui/DeviceImage.kt @@ -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:") 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, + ) + } +} diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/ui/HomeScreen.kt b/app/src/main/java/kr/tkrmagid/easyappliance/ui/HomeScreen.kt new file mode 100644 index 0000000..99ea525 --- /dev/null +++ b/app/src/main/java/kr/tkrmagid/easyappliance/ui/HomeScreen.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/ui/MacroEditor.kt b/app/src/main/java/kr/tkrmagid/easyappliance/ui/MacroEditor.kt new file mode 100644 index 0000000..93d8cb2 --- /dev/null +++ b/app/src/main/java/kr/tkrmagid/easyappliance/ui/MacroEditor.kt @@ -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 }) diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/ui/theme/Theme.kt b/app/src/main/java/kr/tkrmagid/easyappliance/ui/theme/Theme.kt new file mode 100644 index 0000000..10b6cd2 --- /dev/null +++ b/app/src/main/java/kr/tkrmagid/easyappliance/ui/theme/Theme.kt @@ -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, + ) +} diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/ui/theme/Type.kt b/app/src/main/java/kr/tkrmagid/easyappliance/ui/theme/Type.kt new file mode 100644 index 0000000..8f1f528 --- /dev/null +++ b/app/src/main/java/kr/tkrmagid/easyappliance/ui/theme/Type.kt @@ -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), +) diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/vm/AppViewModel.kt b/app/src/main/java/kr/tkrmagid/easyappliance/vm/AppViewModel.kt new file mode 100644 index 0000000..769c5c8 --- /dev/null +++ b/app/src/main/java/kr/tkrmagid/easyappliance/vm/AppViewModel.kt @@ -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 = 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 = _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] diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..017b7af --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/img_aircon.xml b/app/src/main/res/drawable/img_aircon.xml new file mode 100644 index 0000000..36c72de --- /dev/null +++ b/app/src/main/res/drawable/img_aircon.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/src/main/res/drawable/img_device.xml b/app/src/main/res/drawable/img_device.xml new file mode 100644 index 0000000..78d7ac8 --- /dev/null +++ b/app/src/main/res/drawable/img_device.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/img_fridge.xml b/app/src/main/res/drawable/img_fridge.xml new file mode 100644 index 0000000..0e8cddd --- /dev/null +++ b/app/src/main/res/drawable/img_fridge.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/img_tv.xml b/app/src/main/res/drawable/img_tv.xml new file mode 100644 index 0000000..358e42d --- /dev/null +++ b/app/src/main/res/drawable/img_tv.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/img_washer.xml b/app/src/main/res/drawable/img_washer.xml new file mode 100644 index 0000000..5961f82 --- /dev/null +++ b/app/src/main/res/drawable/img_washer.xml @@ -0,0 +1,10 @@ + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..928ecd6 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #1565C0 + #FFFFFF + #1565C0 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..f9fb2d6 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + 쉬운 가전 리모컨 + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..6ead39e --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/test/java/kr/tkrmagid/easyappliance/MockDeviceRepositoryTest.kt b/app/src/test/java/kr/tkrmagid/easyappliance/MockDeviceRepositoryTest.kt new file mode 100644 index 0000000..1bd20f1 --- /dev/null +++ b/app/src/test/java/kr/tkrmagid/easyappliance/MockDeviceRepositoryTest.kt @@ -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) + } +} diff --git a/app/src/test/java/kr/tkrmagid/easyappliance/SmartThingsStatusMapperTest.kt b/app/src/test/java/kr/tkrmagid/easyappliance/SmartThingsStatusMapperTest.kt new file mode 100644 index 0000000..a41da8b --- /dev/null +++ b/app/src/test/java/kr/tkrmagid/easyappliance/SmartThingsStatusMapperTest.kt @@ -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) + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..132ad8d --- /dev/null +++ b/build.gradle.kts @@ -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 +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..71e8721 --- /dev/null +++ b/gradle.properties @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..5c69408 --- /dev/null +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e2847c8 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..aa77249 --- /dev/null +++ b/settings.gradle.kts @@ -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")