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

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

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher"
android:supportsRtl="true"
android:theme="@style/Theme.EasyAppliance">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|density"
android:resizeableActivity="true"
android:theme="@style/Theme.EasyAppliance">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,20 @@
package kr.tkrmagid.easyappliance
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import kr.tkrmagid.easyappliance.ui.AppRoot
import kr.tkrmagid.easyappliance.ui.theme.EasyApplianceTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
EasyApplianceTheme {
AppRoot()
}
}
}
}

View File

@@ -0,0 +1,42 @@
package kr.tkrmagid.easyappliance.data
import androidx.annotation.DrawableRes
import kr.tkrmagid.easyappliance.R
/**
* Built-in appliance pictures shown as defaults in the admin image picker.
* An image override is stored as either "builtin:<key>" or a content:// Uri string.
*/
object DeviceImages {
const val BUILTIN_PREFIX = "builtin:"
data class Builtin(val key: String, val label: String, @DrawableRes val res: Int)
val builtins: List<Builtin> = listOf(
Builtin("washer", "세탁기", R.drawable.img_washer),
Builtin("aircon", "에어컨", R.drawable.img_aircon),
Builtin("fridge", "냉장고", R.drawable.img_fridge),
Builtin("tv", "TV", R.drawable.img_tv),
Builtin("device", "기타 기기", R.drawable.img_device),
)
/** Drawable resource for a "builtin:<key>" reference, or null if not a builtin. */
@DrawableRes
fun resForRef(ref: String?): Int? {
if (ref == null || !ref.startsWith(BUILTIN_PREFIX)) return null
val key = ref.removePrefix(BUILTIN_PREFIX)
return builtins.firstOrNull { it.key == key }?.res
}
fun refForKey(key: String): String = "$BUILTIN_PREFIX$key"
/** Default built-in reference for a device type. */
fun defaultRefFor(type: DeviceType): String = when (type) {
DeviceType.WASHER -> refForKey("washer")
DeviceType.AIRCONDITIONER -> refForKey("aircon")
DeviceType.REFRIGERATOR -> refForKey("fridge")
DeviceType.TV -> refForKey("tv")
DeviceType.OTHER -> refForKey("device")
}
}

View File

@@ -0,0 +1,60 @@
package kr.tkrmagid.easyappliance.data
/**
* Source of devices and their status. A real SmartThings-backed implementation
* will replace [MockDeviceRepository] once an API token is configured; the UI
* only depends on this interface.
*/
interface DeviceRepository {
suspend fun listDevices(): List<Device>
suspend fun getStatus(deviceId: String): DeviceStatus
suspend fun setPower(deviceId: String, on: Boolean)
/** Apply each macro step, then start the device. */
suspend fun runMacro(deviceId: String, steps: List<MacroStep>)
}
/**
* In-memory sample data so the whole UX can be built and demoed before a real
* SmartThings token exists. Power toggles are reflected back on next status read.
*/
class MockDeviceRepository : DeviceRepository {
private val powerState = mutableMapOf(
"washer-1" to false,
"aircon-1" to true,
)
override suspend fun listDevices(): List<Device> = listOf(
Device("washer-1", "세탁기", DeviceType.WASHER),
Device("aircon-1", "거실 에어컨", DeviceType.AIRCONDITIONER),
)
override suspend fun getStatus(deviceId: String): DeviceStatus {
val on = powerState[deviceId] ?: false
return when (deviceId) {
"washer-1" -> DeviceStatus(
powerOn = on,
operatingState = if (on) "운전 중" else "꺼짐",
remainingMinutes = if (on) 32 else null,
reservation = null,
)
"aircon-1" -> DeviceStatus(
powerOn = on,
operatingState = if (on) "냉방 중" else "꺼짐",
currentTemperature = 27,
targetTemperature = if (on) 24 else null,
)
else -> DeviceStatus(powerOn = on)
}
}
override suspend fun setPower(deviceId: String, on: Boolean) {
powerState[deviceId] = on
}
override suspend fun runMacro(deviceId: String, steps: List<MacroStep>) {
// Sample mode: steps are a no-op; the device simply starts.
powerState[deviceId] = true
}
}

View File

@@ -0,0 +1,20 @@
package kr.tkrmagid.easyappliance.data
import android.content.Context
import android.net.Uri
import java.io.File
/**
* Copies a picked image into app-internal storage so it survives restarts
* (Photo Picker URIs are not durably readable). Returns a file:// reference.
*/
object ImageStorage {
fun saveDeviceImage(context: Context, source: Uri, deviceId: String): String? = runCatching {
val dir = File(context.filesDir, "device_images").apply { mkdirs() }
val file = File(dir, "img_${deviceId.hashCode()}_${System.nanoTime()}.jpg")
context.contentResolver.openInputStream(source)?.use { input ->
file.outputStream().use { output -> input.copyTo(output) }
} ?: return null
"file://${file.absolutePath}"
}.getOrNull()
}

View File

@@ -0,0 +1,27 @@
package kr.tkrmagid.easyappliance.data
import kotlinx.serialization.Serializable
/**
* One SmartThings command applied as part of a macro, e.g. set water
* temperature. [args] are stored as strings and converted to JSON at send time
* (numeric strings become numbers).
*/
@Serializable
data class MacroStep(
val capability: String,
val command: String,
val args: List<String> = emptyList(),
/** Human-readable description shown in admin, e.g. "물 온도 40도". */
val label: String = "",
)
/**
* Per-device macro: when enabled, the home screen shows a single "시작" button
* that applies every step and then starts the device.
*/
@Serializable
data class DeviceMacro(
val enabled: Boolean = false,
val steps: List<MacroStep> = emptyList(),
)

View File

@@ -0,0 +1,39 @@
package kr.tkrmagid.easyappliance.data
/** Broad appliance category, used to pick icons and default labels. */
enum class DeviceType { WASHER, AIRCONDITIONER, REFRIGERATOR, TV, OTHER }
/** Korean category name shown in warnings and defaults, e.g. "세탁기". */
fun DeviceType.koreanLabel(): String = when (this) {
DeviceType.WASHER -> "세탁기"
DeviceType.AIRCONDITIONER -> "에어컨"
DeviceType.REFRIGERATOR -> "냉장고"
DeviceType.TV -> "TV"
DeviceType.OTHER -> "기기"
}
/** A SmartThings device the user can control. */
data class Device(
val id: String,
val label: String,
val type: DeviceType,
val online: Boolean = true,
)
/**
* Normalized status shown on the home screen. Not every field applies to every
* device; null means "not reported / not applicable".
*/
data class DeviceStatus(
val powerOn: Boolean = false,
/** Human-readable run state, e.g. "운전 중", "정지", "일시정지". */
val operatingState: String? = null,
/** Remaining minutes for a running cycle, if reported. */
val remainingMinutes: Int? = null,
/** Reservation / scheduled-start description, if set. */
val reservation: String? = null,
/** Current temperature (aircon), if reported. */
val currentTemperature: Int? = null,
/** Target temperature (aircon), if reported. */
val targetTemperature: Int? = null,
)

View File

@@ -0,0 +1,105 @@
package kr.tkrmagid.easyappliance.data
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
/** User/admin choices that must survive app restarts. */
data class AppSettings(
val selectedDeviceId: String? = null,
val showRemainingTime: Boolean = true,
val showReservation: Boolean = true,
val showPowerControls: Boolean = true,
/** SmartThings Personal Access Token; null/blank means use sample data. */
val smartThingsToken: String? = null,
/** deviceId -> custom display name. */
val labelOverrides: Map<String, String> = emptyMap(),
/** deviceId -> image reference ("builtin:<key>" or a content:// Uri). */
val imageOverrides: Map<String, String> = emptyMap(),
/** deviceId -> start macro. */
val deviceMacros: Map<String, DeviceMacro> = emptyMap(),
)
/** Persists [AppSettings] via Jetpack DataStore. */
class SettingsRepository(private val context: Context) {
val settings: Flow<AppSettings> = context.dataStore.data.map { p ->
AppSettings(
selectedDeviceId = p[KEY_SELECTED_DEVICE],
showRemainingTime = p[KEY_SHOW_REMAINING] ?: true,
showReservation = p[KEY_SHOW_RESERVATION] ?: true,
showPowerControls = p[KEY_SHOW_POWER] ?: true,
smartThingsToken = p[KEY_TOKEN]?.takeIf { it.isNotBlank() },
labelOverrides = decodeMap(p[KEY_LABEL_OVERRIDES]),
imageOverrides = decodeMap(p[KEY_IMAGE_OVERRIDES]),
deviceMacros = decodeMacros(p[KEY_MACROS]),
)
}
suspend fun setSelectedDevice(deviceId: String) =
edit { it[KEY_SELECTED_DEVICE] = deviceId }
suspend fun setShowRemainingTime(value: Boolean) =
edit { it[KEY_SHOW_REMAINING] = value }
suspend fun setShowReservation(value: Boolean) =
edit { it[KEY_SHOW_RESERVATION] = value }
suspend fun setShowPowerControls(value: Boolean) =
edit { it[KEY_SHOW_POWER] = value }
suspend fun setToken(token: String) =
edit { it[KEY_TOKEN] = token.trim() }
suspend fun setDeviceLabel(deviceId: String, label: String) = edit { prefs ->
val map = decodeMap(prefs[KEY_LABEL_OVERRIDES]).toMutableMap()
if (label.isBlank()) map.remove(deviceId) else map[deviceId] = label.trim()
prefs[KEY_LABEL_OVERRIDES] = Json.encodeToString(map)
}
suspend fun setDeviceImage(deviceId: String, imageRef: String) = edit { prefs ->
val map = decodeMap(prefs[KEY_IMAGE_OVERRIDES]).toMutableMap()
map[deviceId] = imageRef
prefs[KEY_IMAGE_OVERRIDES] = Json.encodeToString(map)
}
suspend fun setDeviceMacro(deviceId: String, macro: DeviceMacro) = edit { prefs ->
val map = decodeMacros(prefs[KEY_MACROS]).toMutableMap()
map[deviceId] = macro
prefs[KEY_MACROS] = Json.encodeToString(map)
}
private suspend fun edit(block: (androidx.datastore.preferences.core.MutablePreferences) -> Unit) {
context.dataStore.edit(block)
}
private fun decodeMap(raw: String?): Map<String, String> =
if (raw.isNullOrBlank()) emptyMap()
else runCatching { Json.decodeFromString<Map<String, String>>(raw) }.getOrDefault(emptyMap())
private fun decodeMacros(raw: String?): Map<String, DeviceMacro> =
if (raw.isNullOrBlank()) emptyMap()
else runCatching { Json.decodeFromString<Map<String, DeviceMacro>>(raw) }.getOrDefault(emptyMap())
private companion object {
val KEY_SELECTED_DEVICE = stringPreferencesKey("selected_device_id")
val KEY_SHOW_REMAINING = booleanPreferencesKey("show_remaining_time")
val KEY_SHOW_RESERVATION = booleanPreferencesKey("show_reservation")
val KEY_SHOW_POWER = booleanPreferencesKey("show_power_controls")
val KEY_TOKEN = stringPreferencesKey("smartthings_token")
val KEY_LABEL_OVERRIDES = stringPreferencesKey("label_overrides_json")
val KEY_IMAGE_OVERRIDES = stringPreferencesKey("image_overrides_json")
val KEY_MACROS = stringPreferencesKey("device_macros_json")
}
}

View File

@@ -0,0 +1,83 @@
package kr.tkrmagid.easyappliance.data.smartthings
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import java.util.concurrent.TimeUnit
@Serializable
data class DevicesResponse(val items: List<StDevice> = emptyList())
@Serializable
data class StDevice(
val deviceId: String,
val label: String? = null,
val name: String? = null,
val components: List<StComponent> = emptyList(),
)
@Serializable
data class StComponent(val id: String = "main", val capabilities: List<StCapability> = emptyList())
@Serializable
data class StCapability(val id: String)
@Serializable
data class StCommand(
val component: String = "main",
val capability: String,
val command: String,
val arguments: List<JsonElement> = emptyList(),
)
@Serializable
data class StCommandsRequest(val commands: List<StCommand>)
interface SmartThingsApi {
@GET("v1/devices")
suspend fun listDevices(): DevicesResponse
@GET("v1/devices/{id}/status")
suspend fun deviceStatus(@Path("id") deviceId: String): JsonObject
@POST("v1/devices/{id}/commands")
suspend fun sendCommands(@Path("id") deviceId: String, @Body body: StCommandsRequest)
}
/** Builds a [SmartThingsApi] bound to a Personal Access Token. */
object SmartThingsClient {
private const val BASE_URL = "https://api.smartthings.com/"
val json = Json { ignoreUnknownKeys = true; coerceInputValues = true }
fun create(token: String): SmartThingsApi {
val auth = Interceptor { chain ->
val req = chain.request().newBuilder()
.addHeader("Authorization", "Bearer $token")
.addHeader("Accept", "application/json")
.build()
chain.proceed(req)
}
val client = OkHttpClient.Builder()
.addInterceptor(auth)
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()
return Retrofit.Builder()
.baseUrl(BASE_URL)
.client(client)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(SmartThingsApi::class.java)
}
}

View File

@@ -0,0 +1,77 @@
package kr.tkrmagid.easyappliance.data.smartthings
import kr.tkrmagid.easyappliance.data.Device
import kr.tkrmagid.easyappliance.data.DeviceRepository
import kr.tkrmagid.easyappliance.data.DeviceStatus
import kr.tkrmagid.easyappliance.data.DeviceType
import kr.tkrmagid.easyappliance.data.MacroStep
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonPrimitive
/** [DeviceRepository] backed by the real SmartThings Cloud API. */
class SmartThingsDeviceRepository(
private val api: SmartThingsApi,
) : DeviceRepository {
private val typeCache = mutableMapOf<String, DeviceType>()
override suspend fun listDevices(): List<Device> {
return api.listDevices().items.map { d ->
val caps = d.components.flatMap { c -> c.capabilities.map { it.id } }.toSet()
val type = inferType(caps, d.label, d.name)
typeCache[d.deviceId] = type
Device(
id = d.deviceId,
label = d.label ?: d.name ?: "기기",
type = type,
online = true,
)
}
}
override suspend fun getStatus(deviceId: String): DeviceStatus {
val json = api.deviceStatus(deviceId)
val type = typeCache[deviceId] ?: DeviceType.OTHER
return SmartThingsStatusMapper.map(json, type)
}
override suspend fun setPower(deviceId: String, on: Boolean) {
api.sendCommands(
deviceId,
StCommandsRequest(
listOf(StCommand(capability = "switch", command = if (on) "on" else "off")),
),
)
}
override suspend fun runMacro(deviceId: String, steps: List<MacroStep>) {
val commands = steps.map { step ->
StCommand(
capability = step.capability,
command = step.command,
arguments = step.args.map { it.toJsonElement() },
)
} + StCommand(capability = "switch", command = "on")
api.sendCommands(deviceId, StCommandsRequest(commands))
}
private fun String.toJsonElement(): JsonElement =
toIntOrNull()?.let { JsonPrimitive(it) }
?: toDoubleOrNull()?.let { JsonPrimitive(it) }
?: JsonPrimitive(this)
private fun inferType(caps: Set<String>, label: String?, name: String?): DeviceType {
val text = "${label.orEmpty()} ${name.orEmpty()}"
return when {
caps.any { it.contains("washerOperatingState", ignoreCase = true) } -> DeviceType.WASHER
caps.any { it.contains("airConditionerMode", ignoreCase = true) } -> DeviceType.AIRCONDITIONER
caps.any { it.contains("refrigeration", ignoreCase = true) } -> DeviceType.REFRIGERATOR
caps.contains("tvChannel") || caps.contains("mediaPlayback") -> DeviceType.TV
text.contains("세탁") || text.contains("washer", true) -> DeviceType.WASHER
text.contains("에어컨") || text.contains("aircon", true) || text.contains("air conditioner", true) -> DeviceType.AIRCONDITIONER
text.contains("냉장") || text.contains("fridge", true) -> DeviceType.REFRIGERATOR
text.contains("tv", true) -> DeviceType.TV
else -> DeviceType.OTHER
}
}
}

View File

@@ -0,0 +1,74 @@
package kr.tkrmagid.easyappliance.data.smartthings
import kr.tkrmagid.easyappliance.data.DeviceStatus
import kr.tkrmagid.easyappliance.data.DeviceType
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
/**
* Best-effort mapping from a SmartThings `/status` payload to our [DeviceStatus].
* SmartThings status is deeply nested with capability-specific keys; this reads
* the common capabilities defensively and returns nulls for anything missing.
* Refine per real device once a token is available.
*/
object SmartThingsStatusMapper {
fun map(status: JsonObject, type: DeviceType): DeviceStatus {
val main = status["components"]?.jsonObject?.get("main")?.jsonObject
?: return DeviceStatus(powerOn = false)
val powerOn = attr(main, "switch", "switch")?.contentOrNull() == "on"
return when (type) {
DeviceType.WASHER -> mapWasher(main, powerOn)
DeviceType.AIRCONDITIONER -> mapAircon(main, powerOn)
else -> DeviceStatus(powerOn = powerOn, operatingState = if (powerOn) "켜짐" else "꺼짐")
}
}
private fun mapWasher(main: JsonObject, powerOn: Boolean): DeviceStatus {
val machine = attr(main, "washerOperatingState", "machineState")?.contentOrNull()
?: attr(main, "samsungce.washerOperatingState", "operatingState")?.contentOrNull()
val state = when (machine) {
"run" -> "운전 중"
"pause" -> "일시정지"
"stop" -> "정지"
else -> if (powerOn) "켜짐" else "꺼짐"
}
val remaining = numberAttr(main, "samsungce.washerOperatingState", "remainingTime")
?.let { secondsToMinutes(it) }
return DeviceStatus(
powerOn = powerOn,
operatingState = state,
remainingMinutes = remaining,
)
}
private fun mapAircon(main: JsonObject, powerOn: Boolean): DeviceStatus {
val current = numberAttr(main, "temperatureMeasurement", "temperature")
val target = numberAttr(main, "thermostatCoolingSetpoint", "coolingSetpoint")
?: numberAttr(main, "custom.thermostatSetpointControl", "coolingSetpoint")
return DeviceStatus(
powerOn = powerOn,
operatingState = if (powerOn) "냉방 중" else "꺼짐",
currentTemperature = current,
targetTemperature = if (powerOn) target else null,
)
}
private fun attr(main: JsonObject, capability: String, attribute: String): JsonPrimitive? =
runCatching {
main[capability]?.jsonObject?.get(attribute)?.jsonObject?.get("value") as? JsonPrimitive
}.getOrNull()
private fun numberAttr(main: JsonObject, capability: String, attribute: String): Int? =
attr(main, capability, attribute)?.let { it.contentOrNull()?.toDoubleOrNull()?.toInt() }
private fun JsonPrimitive.contentOrNull(): String? =
runCatching { jsonPrimitive.content }.getOrNull()?.takeIf { it != "null" }
private fun secondsToMinutes(seconds: Int): Int =
if (seconds > 300) (seconds + 59) / 60 else seconds
}

View File

@@ -0,0 +1,49 @@
package kr.tkrmagid.easyappliance.ui
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.pointerInput
import kotlinx.coroutines.withTimeoutOrNull
/**
* Hidden admin unlock: the user must press BOTH top corners at the same time and
* hold for ~1.5s. This is intentionally hard to trigger by accident so older
* users don't stumble into the admin screen, while a caretaker can still open it.
*/
fun Modifier.adminUnlockGesture(
holdMillis: Long = 1500L,
cornerFraction: Float = 0.30f,
onUnlock: () -> Unit,
): Modifier = this.pointerInput(Unit) {
val zoneW = size.width * cornerFraction
val zoneH = size.height * cornerFraction
fun PointerEvent.bothTopCornersHeld(): Boolean {
val pressed = changes.filter { it.pressed }.map { it.position }
val topLeft = pressed.any { it.x < zoneW && it.y < zoneH }
val topRight = pressed.any { it.x > size.width - zoneW && it.y < zoneH }
return topLeft && topRight
}
while (true) {
// 1) Wait until both top corners are pressed simultaneously.
awaitPointerEventScope {
do { } while (!awaitPointerEvent().bothTopCornersHeld())
}
// 2) They are held now. If they stay held for the full duration, the inner
// wait never returns and withTimeoutOrNull yields null -> unlock. If a
// finger lifts, an event fires, the loop returns, and we abort.
val releasedEarly = withTimeoutOrNull(holdMillis) {
awaitPointerEventScope {
do { } while (awaitPointerEvent().bothTopCornersHeld())
}
}
if (releasedEarly == null) {
onUnlock()
// Wait for fingers to lift so we don't immediately re-trigger.
awaitPointerEventScope {
do { } while (awaitPointerEvent().bothTopCornersHeld())
}
}
}
}

View File

@@ -0,0 +1,328 @@
package kr.tkrmagid.easyappliance.ui
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.text.KeyboardOptions
import kr.tkrmagid.easyappliance.data.Device
import kr.tkrmagid.easyappliance.data.DeviceImages
import kr.tkrmagid.easyappliance.data.DeviceMacro
import kr.tkrmagid.easyappliance.data.ImageStorage
import kr.tkrmagid.easyappliance.vm.HomeUiState
import kr.tkrmagid.easyappliance.vm.displayLabelFor
import kr.tkrmagid.easyappliance.vm.imageRefFor
import kr.tkrmagid.easyappliance.vm.macroFor
@Composable
fun AdminScreen(
state: HomeUiState,
onSelectDevice: (String) -> Unit,
onToggleRemaining: (Boolean) -> Unit,
onToggleReservation: (Boolean) -> Unit,
onTogglePowerControls: (Boolean) -> Unit,
onSetLabel: (String, String) -> Unit,
onSetImageRef: (String, String) -> Unit,
onSetToken: (String) -> Unit,
onSetMacro: (String, DeviceMacro) -> Unit,
onPreviewWarning: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val selected = state.selected
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
) {
Text("관리자 설정", style = MaterialTheme.typography.headlineLarge)
Spacer(Modifier.height(24.dp))
SectionTitle("조작할 기기 선택")
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
state.devices.forEach { device ->
DeviceRow(
device = device,
subtitle = state.displayLabelFor(device),
selected = device.id == selected?.id,
onClick = { onSelectDevice(device.id) },
)
}
if (state.devices.isEmpty()) {
Text(
"표시할 기기가 없습니다.",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(16.dp),
)
}
}
}
if (selected != null) {
Spacer(Modifier.height(28.dp))
SectionTitle("이름 바꾸기")
NameEditor(
current = state.displayLabelFor(selected),
onSave = { onSetLabel(selected.id, it) },
)
Spacer(Modifier.height(28.dp))
SectionTitle("사진 바꾸기")
ImagePicker(
currentRef = state.imageRefFor(selected),
onPickBuiltin = { onSetImageRef(selected.id, it) },
onPickCustom = { onSetImageRef(selected.id, it) },
deviceId = selected.id,
)
Spacer(Modifier.height(28.dp))
SectionTitle("매크로 (시작 자동화)")
MacroEditor(
device = selected,
macro = state.macroFor(selected) ?: DeviceMacro(),
onChange = { onSetMacro(selected.id, it) },
)
}
Spacer(Modifier.height(28.dp))
SectionTitle("표시할 정보 / 기능")
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(Modifier.fillMaxWidth().padding(8.dp)) {
ToggleRow("남은 시간 표시", state.settings.showRemainingTime, onToggleRemaining)
HorizontalDivider()
ToggleRow("예약 정보 표시", state.settings.showReservation, onToggleReservation)
HorizontalDivider()
ToggleRow("켜기 / 끄기 버튼 표시", state.settings.showPowerControls, onTogglePowerControls)
}
}
Spacer(Modifier.height(28.dp))
SectionTitle("SmartThings 연결")
TokenEditor(
usingRealApi = state.usingRealApi,
onSave = onSetToken,
)
Spacer(Modifier.height(20.dp))
OutlinedButton(
onClick = { onPreviewWarning(); onBack() },
modifier = Modifier.fillMaxWidth().height(64.dp),
) {
Text("연결 경고창 미리보기 (테스트)", style = MaterialTheme.typography.bodyLarge)
}
Spacer(Modifier.height(28.dp))
Button(
onClick = onBack,
modifier = Modifier.fillMaxWidth().height(76.dp),
) {
Text("저장하고 닫기", style = MaterialTheme.typography.labelLarge)
}
}
}
@Composable
private fun NameEditor(current: String, onSave: (String) -> Unit) {
var text by remember(current) { mutableStateOf(current) }
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = text,
onValueChange = { text = it },
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.size(12.dp))
Button(onClick = { onSave(text) }, modifier = Modifier.height(56.dp)) {
Text("저장")
}
}
}
@Composable
private fun ImagePicker(
currentRef: String,
onPickBuiltin: (String) -> Unit,
onPickCustom: (String) -> Unit,
deviceId: String,
) {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.PickVisualMedia(),
) { uri ->
if (uri != null) {
ImageStorage.saveDeviceImage(context, uri, deviceId)?.let(onPickCustom)
}
}
Column(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
DeviceImage(imageRef = currentRef, modifier = Modifier.size(72.dp))
Spacer(Modifier.size(16.dp))
Text("현재 사진", style = MaterialTheme.typography.bodyLarge)
}
Spacer(Modifier.height(16.dp))
Text("기본 사진 중 선택", style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(8.dp))
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
DeviceImages.builtins.forEach { b ->
val ref = DeviceImages.refForKey(b.key)
val isSel = ref == currentRef
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.border(
width = if (isSel) 3.dp else 1.dp,
color = if (isSel) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant,
shape = RoundedCornerShape(12.dp),
)
.clickable { onPickBuiltin(ref) }
.padding(6.dp),
) {
DeviceImage(imageRef = ref, modifier = Modifier.size(48.dp))
Text(b.label, style = MaterialTheme.typography.bodyMedium)
}
}
}
Spacer(Modifier.height(16.dp))
OutlinedButton(
onClick = {
launcher.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
)
},
modifier = Modifier.fillMaxWidth().height(60.dp),
) {
Text("갤러리에서 사진 추가", style = MaterialTheme.typography.bodyLarge)
}
}
}
@Composable
private fun TokenEditor(usingRealApi: Boolean, onSave: (String) -> Unit) {
var token by remember { mutableStateOf("") }
var reveal by remember { mutableStateOf(false) }
Column(Modifier.fillMaxWidth()) {
Text(
if (usingRealApi) "상태: 실기기 연결 (토큰 설정됨)" else "상태: 샘플 모드 (토큰 없음)",
style = MaterialTheme.typography.bodyLarge,
color = if (usingRealApi) MaterialTheme.colorScheme.secondary else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = token,
onValueChange = { token = it },
singleLine = true,
label = { Text("SmartThings 토큰 입력") },
visualTransformation = if (reveal) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth(),
)
Row(
Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = reveal, onCheckedChange = { reveal = it })
Text(" 토큰 보기", style = MaterialTheme.typography.bodyMedium)
}
Button(
onClick = { if (token.isNotBlank()) onSave(token) },
modifier = Modifier.height(56.dp),
) {
Text("토큰 저장")
}
}
}
}
@Composable
private fun SectionTitle(text: String) {
Text(
text,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 12.dp),
)
}
@Composable
private fun DeviceRow(device: Device, subtitle: String, selected: Boolean, onClick: () -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.selectable(selected = selected, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = selected, onClick = onClick)
Spacer(Modifier.size(8.dp))
Text(subtitle, style = MaterialTheme.typography.titleLarge)
}
}
@Composable
private fun ToggleRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}

View File

@@ -0,0 +1,89 @@
package kr.tkrmagid.easyappliance.ui
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import kr.tkrmagid.easyappliance.data.koreanLabel
import kr.tkrmagid.easyappliance.vm.AppViewModel
import kr.tkrmagid.easyappliance.vm.displayLabelFor
import kotlinx.coroutines.delay
private enum class Screen { HOME, ADMIN }
private const val POLL_OK_MS = 15_000L
private const val POLL_OFFLINE_MS = 5_000L
@Composable
fun AppRoot() {
val vm: AppViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
var screen by rememberSaveable { mutableStateOf(Screen.HOME) }
// Refresh when returning to the foreground...
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { vm.refresh() }
// ...and poll while composed; retry faster while disconnected so the warning
// clears itself as soon as the device/API is reachable again.
LaunchedEffect(Unit) {
while (true) {
val connected = vm.state.value.connected
delay(if (connected) POLL_OK_MS else POLL_OFFLINE_MS)
if (vm.state.value.connected) vm.refresh() else vm.retryConnection()
}
}
// System / Samsung back: admin -> home; block back while the warning is up.
BackHandler(enabled = screen == Screen.ADMIN) { screen = Screen.HOME }
BackHandler(enabled = state.showConnectionWarning) { /* must use the buttons */ }
Scaffold(modifier = Modifier.fillMaxSize()) { padding ->
Box(Modifier.fillMaxSize().padding(padding)) {
when (screen) {
Screen.HOME -> HomeScreen(
state = state,
onTogglePower = vm::togglePower,
onRunMacro = vm::runMacro,
onRefresh = vm::refresh,
modifier = Modifier
.fillMaxSize()
.adminUnlockGesture { screen = Screen.ADMIN },
)
Screen.ADMIN -> AdminScreen(
state = state,
onSelectDevice = vm::selectDevice,
onToggleRemaining = vm::setShowRemainingTime,
onToggleReservation = vm::setShowReservation,
onTogglePowerControls = vm::setShowPowerControls,
onSetLabel = vm::setDeviceLabel,
onSetImageRef = vm::setDeviceImage,
onSetToken = vm::setToken,
onSetMacro = vm::setDeviceMacro,
onPreviewWarning = vm::previewConnectionWarning,
onBack = { screen = Screen.HOME },
)
}
if (state.showConnectionWarning) {
val selected = state.selected
ConnectionWarning(
deviceTypeLabel = selected?.type?.koreanLabel() ?: "기기",
deviceName = selected?.let { state.displayLabelFor(it) } ?: "기기",
onRetry = vm::retryConnection,
onDismiss = vm::dismissWarning,
)
}
}
}
}

View File

@@ -0,0 +1,108 @@
package kr.tkrmagid.easyappliance.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.WifiOff
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
/**
* Full-screen modal shown when the selected device / API is unreachable.
* Big headline for older users, a mandatory "다시 연결하기" button, and a small
* "x" in the corner for users who know to dismiss it manually.
*/
@Composable
fun ConnectionWarning(
deviceTypeLabel: String,
deviceName: String,
onRetry: () -> Unit,
onDismiss: () -> Unit,
) {
val noRipple = remember { MutableInteractionSource() }
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xCC000000))
// Swallow taps so the screen behind cannot be operated while offline.
.clickable(interactionSource = noRipple, indication = null) {},
contentAlignment = Alignment.Center,
) {
Card(
modifier = Modifier
.widthIn(max = 520.dp)
.fillMaxWidth()
.padding(24.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
Box(Modifier.fillMaxWidth()) {
IconButton(
onClick = onDismiss,
modifier = Modifier.align(Alignment.TopEnd).padding(4.dp),
) {
Icon(Icons.Filled.Close, contentDescription = "닫기")
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(
Icons.Filled.WifiOff,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(64.dp),
)
Spacer(Modifier.height(16.dp))
Text(
"${deviceTypeLabel}와 연결할 수 없습니다",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(12.dp))
Text(
"API 또는 '${deviceName}' 과(와) 연결 실패",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(28.dp))
Button(
onClick = onRetry,
modifier = Modifier.fillMaxWidth().height(80.dp),
) {
Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(28.dp))
Text(" 다시 연결하기", style = MaterialTheme.typography.labelLarge)
}
}
}
}
}
}

View File

@@ -0,0 +1,37 @@
package kr.tkrmagid.easyappliance.ui
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import coil.compose.AsyncImage
import kr.tkrmagid.easyappliance.data.DeviceImages
/**
* Renders a device image from an image reference: a built-in drawable
* ("builtin:<key>") or a file/content Uri string (custom photo).
*/
@Composable
fun DeviceImage(
imageRef: String,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit,
) {
val builtinRes = DeviceImages.resForRef(imageRef)
if (builtinRes != null) {
Image(
painter = painterResource(builtinRes),
contentDescription = null,
modifier = modifier,
contentScale = contentScale,
)
} else {
AsyncImage(
model = imageRef,
contentDescription = null,
modifier = modifier,
contentScale = ContentScale.Crop,
)
}
}

View File

@@ -0,0 +1,268 @@
package kr.tkrmagid.easyappliance.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kr.tkrmagid.easyappliance.data.DeviceType
import androidx.compose.material.icons.filled.PlayArrow
import kr.tkrmagid.easyappliance.vm.HomeUiState
import kr.tkrmagid.easyappliance.vm.displayLabelFor
import kr.tkrmagid.easyappliance.vm.imageRefFor
import kr.tkrmagid.easyappliance.vm.macroFor
private val BigButtonHeight = 84.dp
@Composable
fun HomeScreen(
state: HomeUiState,
onTogglePower: (Boolean) -> Unit,
onRunMacro: () -> Unit,
onRefresh: () -> Unit,
modifier: Modifier = Modifier,
) {
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
// Responsive: cap content width on large / unfolded screens, stack the
// power buttons vertically on very narrow screens (flip closed cover).
val narrow = maxWidth < 340.dp
val contentMaxWidth = if (maxWidth < 600.dp) maxWidth else 600.dp
val device = state.selected
if (device == null) {
EmptyState()
return@BoxWithConstraints
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column(
modifier = Modifier.widthIn(max = contentMaxWidth).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
DeviceImage(
imageRef = state.imageRefFor(device),
modifier = Modifier.size(if (narrow) 96.dp else 128.dp),
)
Spacer(Modifier.height(12.dp))
Text(
text = state.displayLabelFor(device),
style = MaterialTheme.typography.headlineLarge,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(20.dp))
StatusCard(state)
if (state.settings.showPowerControls) {
Spacer(Modifier.height(28.dp))
val macroOn = state.macroFor(device)?.enabled == true
if (macroOn) {
MacroControls(
isOn = state.status?.powerOn == true,
onStart = onRunMacro,
onOff = { onTogglePower(false) },
)
} else {
PowerControls(
isOn = state.status?.powerOn == true,
stacked = narrow,
onTogglePower = onTogglePower,
)
}
}
Spacer(Modifier.height(20.dp))
OutlinedButton(
onClick = onRefresh,
modifier = Modifier.fillMaxWidth().height(BigButtonHeight),
) {
Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(30.dp))
Text(" 새로고침", style = MaterialTheme.typography.labelLarge)
}
}
}
}
}
@Composable
private fun StatusCard(state: HomeUiState) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (state.loading && state.status == null) {
CircularProgressIndicator()
Spacer(Modifier.height(12.dp))
Text("불러오는 중...", style = MaterialTheme.typography.bodyLarge)
return@Column
}
val status = state.status
val on = status?.powerOn == true
Text(
text = status?.operatingState ?: if (on) "켜짐" else "꺼짐",
style = MaterialTheme.typography.displayLarge,
color = if (on) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
if (state.settings.showRemainingTime && status?.remainingMinutes != null) {
Spacer(Modifier.height(12.dp))
Text(
"남은 시간 약 ${status.remainingMinutes}",
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
)
}
if (state.selected?.type == DeviceType.AIRCONDITIONER) {
status?.currentTemperature?.let {
Spacer(Modifier.height(12.dp))
val target = status.targetTemperature?.let { t -> " · 설정 ${t}" } ?: ""
Text(
"현재 ${it}$target",
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
)
}
}
if (state.settings.showReservation) {
status?.reservation?.let {
Spacer(Modifier.height(12.dp))
Text("예약: $it", style = MaterialTheme.typography.titleLarge, textAlign = TextAlign.Center)
}
}
}
}
}
@Composable
private fun PowerControls(isOn: Boolean, stacked: Boolean, onTogglePower: (Boolean) -> Unit) {
val onButton: @Composable (Modifier) -> Unit = { m ->
Button(
onClick = { onTogglePower(true) },
enabled = !isOn,
modifier = m.height(BigButtonHeight),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary,
contentColor = Color.White,
),
) {
Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, modifier = Modifier.size(30.dp))
Text(" 켜기", style = MaterialTheme.typography.labelLarge)
}
}
val offButton: @Composable (Modifier) -> Unit = { m ->
Button(
onClick = { onTogglePower(false) },
enabled = isOn,
modifier = m.height(BigButtonHeight),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = Color.White,
),
) {
Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, modifier = Modifier.size(30.dp))
Text(" 끄기", style = MaterialTheme.typography.labelLarge)
}
}
if (stacked) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
onButton(Modifier.fillMaxWidth())
offButton(Modifier.fillMaxWidth())
}
} else {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
onButton(Modifier.weight(1f))
offButton(Modifier.weight(1f))
}
}
}
@Composable
private fun MacroControls(isOn: Boolean, onStart: () -> Unit, onOff: () -> Unit) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Button(
onClick = onStart,
modifier = Modifier.fillMaxWidth().height(96.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary,
contentColor = Color.White,
),
) {
Icon(Icons.Filled.PlayArrow, contentDescription = null, modifier = Modifier.size(36.dp))
Text(" 시작", style = MaterialTheme.typography.headlineMedium)
}
Button(
onClick = onOff,
enabled = isOn,
modifier = Modifier.fillMaxWidth().height(BigButtonHeight),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = Color.White,
),
) {
Icon(Icons.Filled.PowerSettingsNew, contentDescription = null, modifier = Modifier.size(30.dp))
Text(" 끄기", style = MaterialTheme.typography.labelLarge)
}
}
}
@Composable
private fun EmptyState() {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"등록된 기기가 없습니다",
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(16.dp))
Text(
"관리자 화면에서 기기를 선택해 주세요.\n(화면 위쪽 양쪽 모서리를 동시에 길게 누르세요)",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
}
}

View File

@@ -0,0 +1,197 @@
package kr.tkrmagid.easyappliance.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kr.tkrmagid.easyappliance.data.Device
import kr.tkrmagid.easyappliance.data.DeviceMacro
import kr.tkrmagid.easyappliance.data.DeviceType
import kr.tkrmagid.easyappliance.data.MacroStep
private const val TEMP_CAP = "custom.washerWaterTemperature"
private const val TEMP_CMD = "setWasherWaterTemperature"
private const val LEVEL_CAP = "custom.washerWaterLevel"
private const val LEVEL_CMD = "setWasherWaterLevel"
private data class Preset(val value: String, val label: String)
private val TEMP_PRESETS = listOf(
Preset("cold", "냉수"),
Preset("30", "30도"),
Preset("40", "40도"),
Preset("60", "60도"),
)
private val LEVEL_PRESETS = listOf(
Preset("low", "낮음"),
Preset("medium", "중간"),
Preset("high", "높음"),
)
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun MacroEditor(device: Device, macro: DeviceMacro, onChange: (DeviceMacro) -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(Modifier.fillMaxWidth().padding(12.dp)) {
Row(
Modifier.fillMaxWidth().padding(4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
"매크로 사용 (시작 시 자동 설정)",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
Switch(
checked = macro.enabled,
onCheckedChange = { onChange(macro.copy(enabled = it)) },
)
}
if (!macro.enabled) return@Column
if (device.type == DeviceType.WASHER) {
Spacer(Modifier.height(8.dp))
Text("물 온도", style = MaterialTheme.typography.bodyMedium)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TEMP_PRESETS.forEach { p ->
val selected = macro.steps.any { it.capability == TEMP_CAP && it.args == listOf(p.value) }
FilterChip(
selected = selected,
onClick = {
onChange(
macro.upsert(
MacroStep(TEMP_CAP, TEMP_CMD, listOf(p.value), "물 온도 ${p.label}"),
),
)
},
label = { Text(p.label) },
)
}
}
Spacer(Modifier.height(8.dp))
Text("수위(물 높이)", style = MaterialTheme.typography.bodyMedium)
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
LEVEL_PRESETS.forEach { p ->
val selected = macro.steps.any { it.capability == LEVEL_CAP && it.args == listOf(p.value) }
FilterChip(
selected = selected,
onClick = {
onChange(
macro.upsert(
MacroStep(LEVEL_CAP, LEVEL_CMD, listOf(p.value), "수위 ${p.label}"),
),
)
},
label = { Text(p.label) },
)
}
}
Spacer(Modifier.height(4.dp))
Text(
"※ 삼성 세탁기 기준 예시 명령입니다. 실제 기기에서 동작하지 않으면 아래 '직접 추가'로 조정하세요.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(Modifier.height(12.dp))
Text("현재 매크로 동작", style = MaterialTheme.typography.bodyMedium)
if (macro.steps.isEmpty()) {
Text(
"설정된 동작이 없습니다. 시작 시 전원만 켜집니다.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 4.dp),
)
} else {
macro.steps.forEach { step ->
Row(
Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
step.label.ifBlank { "${step.capability} · ${step.command}" },
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
IconButton(onClick = { onChange(macro.removeStep(step)) }) {
Icon(Icons.Filled.Delete, contentDescription = "삭제")
}
}
}
}
Spacer(Modifier.height(12.dp))
AdvancedStepAdder(onAdd = { onChange(macro.upsert(it)) })
}
}
}
@Composable
private fun AdvancedStepAdder(onAdd: (MacroStep) -> Unit) {
var cap by remember { mutableStateOf("") }
var cmd by remember { mutableStateOf("") }
var arg by remember { mutableStateOf("") }
Column(Modifier.fillMaxWidth()) {
Text("직접 추가 (고급)", style = MaterialTheme.typography.bodyMedium)
OutlinedTextField(
value = cap, onValueChange = { cap = it }, singleLine = true,
label = { Text("capability") }, modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = cmd, onValueChange = { cmd = it }, singleLine = true,
label = { Text("command") }, modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = arg, onValueChange = { arg = it }, singleLine = true,
label = { Text("argument (선택)") }, modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
Button(
onClick = {
if (cap.isNotBlank() && cmd.isNotBlank()) {
val args = if (arg.isBlank()) emptyList() else listOf(arg.trim())
onAdd(MacroStep(cap.trim(), cmd.trim(), args, "$cap · $cmd"))
cap = ""; cmd = ""; arg = ""
}
},
modifier = Modifier.height(52.dp),
) { Text("명령 추가") }
}
}
private fun DeviceMacro.upsert(step: MacroStep): DeviceMacro =
copy(steps = steps.filterNot { it.capability == step.capability } + step)
private fun DeviceMacro.removeStep(step: MacroStep): DeviceMacro =
copy(steps = steps.filterNot { it.capability == step.capability && it.command == step.command && it.args == step.args })

View File

@@ -0,0 +1,50 @@
package kr.tkrmagid.easyappliance.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
// High-contrast, calm palette tuned for older users.
private val BrandPrimary = Color(0xFF1565C0)
private val BrandPrimaryDark = Color(0xFF90CAF9)
private val LightColors = lightColorScheme(
primary = BrandPrimary,
onPrimary = Color.White,
primaryContainer = Color(0xFFD6E4FF),
onPrimaryContainer = Color(0xFF001A41),
secondary = Color(0xFF2E7D32),
onSecondary = Color.White,
error = Color(0xFFC62828),
onError = Color.White,
background = Color(0xFFFDFDFD),
onBackground = Color(0xFF1A1A1A),
surface = Color(0xFFFFFFFF),
onSurface = Color(0xFF1A1A1A),
surfaceVariant = Color(0xFFEEF2F7),
onSurfaceVariant = Color(0xFF33383E),
)
private val DarkColors = darkColorScheme(
primary = BrandPrimaryDark,
onPrimary = Color(0xFF00305F),
background = Color(0xFF121212),
onBackground = Color(0xFFF2F2F2),
surface = Color(0xFF1E1E1E),
onSurface = Color(0xFFF2F2F2),
)
@Composable
fun EasyApplianceTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColors else LightColors,
typography = ElderlyTypography,
content = content,
)
}

View File

@@ -0,0 +1,17 @@
package kr.tkrmagid.easyappliance.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Larger-than-default type scale so text stays readable for older users.
val ElderlyTypography = Typography(
displayLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 48.sp, lineHeight = 56.sp),
headlineLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 40.sp, lineHeight = 48.sp),
headlineMedium = TextStyle(fontWeight = FontWeight.Bold, fontSize = 32.sp, lineHeight = 40.sp),
titleLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 28.sp, lineHeight = 34.sp),
bodyLarge = TextStyle(fontWeight = FontWeight.Normal, fontSize = 22.sp, lineHeight = 30.sp),
bodyMedium = TextStyle(fontWeight = FontWeight.Normal, fontSize = 20.sp, lineHeight = 28.sp),
labelLarge = TextStyle(fontWeight = FontWeight.Bold, fontSize = 24.sp, lineHeight = 30.sp),
)

View File

@@ -0,0 +1,190 @@
package kr.tkrmagid.easyappliance.vm
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kr.tkrmagid.easyappliance.data.AppSettings
import kr.tkrmagid.easyappliance.data.Device
import kr.tkrmagid.easyappliance.data.DeviceRepository
import kr.tkrmagid.easyappliance.data.DeviceStatus
import kr.tkrmagid.easyappliance.data.MockDeviceRepository
import kr.tkrmagid.easyappliance.data.DeviceImages
import kr.tkrmagid.easyappliance.data.DeviceMacro
import kr.tkrmagid.easyappliance.data.SettingsRepository
import kr.tkrmagid.easyappliance.data.smartthings.SmartThingsClient
import kr.tkrmagid.easyappliance.data.smartthings.SmartThingsDeviceRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class HomeUiState(
val loading: Boolean = true,
val devices: List<Device> = emptyList(),
val selected: Device? = null,
val status: DeviceStatus? = null,
val settings: AppSettings = AppSettings(),
val usingRealApi: Boolean = false,
/** False when the selected device / API cannot be reached. */
val connected: Boolean = true,
/** True after the user closes the warning with the small "x". */
val warningDismissed: Boolean = false,
val error: String? = null,
) {
val showConnectionWarning: Boolean get() = !connected && !warningDismissed
}
class AppViewModel(app: Application) : AndroidViewModel(app) {
private val settingsRepo = SettingsRepository(app)
private var deviceRepo: DeviceRepository = MockDeviceRepository()
private var activeToken: String? = null
private var lastSelectedId: String? = null
/** Preview flag so the admin can see the warning UI without a real outage. */
private var forcedWarning = false
private val _state = MutableStateFlow(HomeUiState())
val state: StateFlow<HomeUiState> = _state.asStateFlow()
init {
viewModelScope.launch {
settingsRepo.settings.collect { settings -> applySettings(settings) }
}
}
private suspend fun applySettings(settings: AppSettings) {
val tokenChanged = settings.smartThingsToken != activeToken
if (tokenChanged) {
activeToken = settings.smartThingsToken
deviceRepo = if (settings.smartThingsToken.isNullOrBlank()) {
MockDeviceRepository()
} else {
SmartThingsDeviceRepository(SmartThingsClient.create(settings.smartThingsToken))
}
}
_state.update {
it.copy(settings = settings, usingRealApi = !settings.smartThingsToken.isNullOrBlank())
}
if (tokenChanged || _state.value.devices.isEmpty()) {
loadDevices()
}
resolveSelectionAndStatus(settings)
}
private suspend fun loadDevices() {
runCatching { deviceRepo.listDevices() }
.onSuccess { devices ->
_state.update { it.copy(devices = devices, connected = true) }
}
.onFailure {
_state.update { it.copy(loading = false, connected = false) }
}
}
private suspend fun resolveSelectionAndStatus(settings: AppSettings) {
val devices = _state.value.devices
val selected = devices.firstOrNull { it.id == settings.selectedDeviceId }
?: devices.firstOrNull()
_state.update { it.copy(selected = selected) }
if (selected == null) {
_state.update { it.copy(loading = false) }
return
}
if (selected.id != lastSelectedId) {
lastSelectedId = selected.id
loadStatus(selected.id)
} else {
_state.update { it.copy(loading = false) }
}
}
private suspend fun loadStatus(deviceId: String) {
runCatching { deviceRepo.getStatus(deviceId) }
.onSuccess { status ->
_state.update {
it.copy(
loading = false,
status = status,
connected = !forcedWarning,
warningDismissed = if (!forcedWarning) false else it.warningDismissed,
error = null,
)
}
}
.onFailure {
_state.update { it.copy(loading = false, connected = false) }
}
}
/** Refresh selected device status; also used by the periodic poll. */
fun refresh() {
val id = _state.value.selected?.id ?: return
viewModelScope.launch { loadStatus(id) }
}
/** "다시 연결하기" — clear preview flag and re-attempt list + status. */
fun retryConnection() {
forcedWarning = false
viewModelScope.launch {
loadDevices()
resolveSelectionAndStatus(_state.value.settings)
}
}
fun dismissWarning() {
_state.update { it.copy(warningDismissed = true) }
}
/** Admin preview of the disconnection warning (testing aid). */
fun previewConnectionWarning() {
forcedWarning = true
_state.update { it.copy(connected = false, warningDismissed = false) }
}
fun selectDevice(deviceId: String) {
viewModelScope.launch { settingsRepo.setSelectedDevice(deviceId) }
}
fun togglePower(on: Boolean) {
val id = _state.value.selected?.id ?: return
viewModelScope.launch {
runCatching { deviceRepo.setPower(id, on) }
.onFailure { _state.update { s -> s.copy(connected = false) } }
loadStatus(id)
}
}
/** Run the selected device's start macro (apply presets, then start). */
fun runMacro() {
val id = _state.value.selected?.id ?: return
val macro = _state.value.settings.deviceMacros[id] ?: return
viewModelScope.launch {
runCatching { deviceRepo.runMacro(id, macro.steps) }
.onFailure { _state.update { s -> s.copy(connected = false) } }
loadStatus(id)
}
}
fun setDeviceMacro(deviceId: String, macro: DeviceMacro) {
viewModelScope.launch { settingsRepo.setDeviceMacro(deviceId, macro) }
}
fun setShowRemainingTime(value: Boolean) { viewModelScope.launch { settingsRepo.setShowRemainingTime(value) } }
fun setShowReservation(value: Boolean) { viewModelScope.launch { settingsRepo.setShowReservation(value) } }
fun setShowPowerControls(value: Boolean) { viewModelScope.launch { settingsRepo.setShowPowerControls(value) } }
fun setToken(token: String) { viewModelScope.launch { settingsRepo.setToken(token) } }
fun setDeviceLabel(deviceId: String, label: String) { viewModelScope.launch { settingsRepo.setDeviceLabel(deviceId, label) } }
fun setDeviceImage(deviceId: String, imageRef: String) { viewModelScope.launch { settingsRepo.setDeviceImage(deviceId, imageRef) } }
}
/** Display name for a device, applying the admin's custom-name override. */
fun HomeUiState.displayLabelFor(device: Device): String =
settings.labelOverrides[device.id]?.takeIf { it.isNotBlank() } ?: device.label
/** Image reference for a device: custom override, else the type default. */
fun HomeUiState.imageRefFor(device: Device): String =
settings.imageOverrides[device.id] ?: DeviceImages.defaultRefFor(device.type)
/** The device's macro, or null if none configured. */
fun HomeUiState.macroFor(device: Device): DeviceMacro? = settings.deviceMacros[device.id]

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- washing machine body -->
<path
android:fillColor="#FFFFFF"
android:pathData="M36,30 h36 a4,4 0 0 1 4,4 v40 a4,4 0 0 1 -4,4 h-36 a4,4 0 0 1 -4,-4 v-40 a4,4 0 0 1 4,-4 z" />
<!-- top control bar -->
<path
android:fillColor="#1565C0"
android:pathData="M36,36 h36 v6 h-36 z" />
<!-- knob -->
<path
android:fillColor="#1565C0"
android:pathData="M67,38 m-2,0 a2,2 0 1,0 4,0 a2,2 0 1,0 -4,0" />
<!-- door outer -->
<path
android:fillColor="#1565C0"
android:pathData="M54,62 m-14,0 a14,14 0 1,0 28,0 a14,14 0 1,0 -28,0" />
<!-- door inner -->
<path
android:fillColor="#FFFFFF"
android:pathData="M54,62 m-9,0 a9,9 0 1,0 18,0 a9,9 0 1,0 -18,0" />
</vector>

View File

@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="96dp" android:height="96dp"
android:viewportWidth="48" android:viewportHeight="48">
<path android:fillColor="#1565C0"
android:pathData="M6,14 h36 a3,3 0 0 1 3,3 v10 a3,3 0 0 1 -3,3 h-36 a3,3 0 0 1 -3,-3 v-10 a3,3 0 0 1 3,-3 z" />
<path android:fillColor="#FFFFFF" android:pathData="M7,25 h34 v2.5 h-34 z" />
<path android:fillColor="#90CAF9" android:pathData="M8,19 h20 v2 h-20 z" />
<path android:fillColor="#42A5F5"
android:strokeColor="#42A5F5" android:strokeWidth="1.4"
android:pathData="M12,33 q2,3 4,0 M22,33 q2,3 4,0 M32,33 q2,3 4,0" />
</vector>

View File

@@ -0,0 +1,8 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="96dp" android:height="96dp"
android:viewportWidth="48" android:viewportHeight="48">
<path android:fillColor="#1565C0"
android:pathData="M10,10 h28 a4,4 0 0 1 4,4 v20 a4,4 0 0 1 -4,4 h-28 a4,4 0 0 1 -4,-4 v-20 a4,4 0 0 1 4,-4 z" />
<path android:fillColor="#FFFFFF" android:pathData="M24,24 m-6,0 a6,6 0 1,0 12,0 a6,6 0 1,0 -12,0" />
<path android:fillColor="#1565C0" android:pathData="M24,24 m-2.5,0 a2.5,2.5 0 1,0 5,0 a2.5,2.5 0 1,0 -5,0" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="96dp" android:height="96dp"
android:viewportWidth="48" android:viewportHeight="48">
<path android:fillColor="#1565C0"
android:pathData="M14,4 h20 a3,3 0 0 1 3,3 v34 a3,3 0 0 1 -3,3 h-20 a3,3 0 0 1 -3,-3 v-34 a3,3 0 0 1 3,-3 z" />
<path android:fillColor="#FFFFFF" android:pathData="M11,18 h26 v2 h-26 z" />
<path android:fillColor="#90CAF9" android:pathData="M15,9 h3 v6 h-3 z" />
<path android:fillColor="#90CAF9" android:pathData="M15,23 h3 v7 h-3 z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="96dp" android:height="96dp"
android:viewportWidth="48" android:viewportHeight="48">
<path android:fillColor="#1565C0"
android:pathData="M6,10 h36 a3,3 0 0 1 3,3 v18 a3,3 0 0 1 -3,3 h-36 a3,3 0 0 1 -3,-3 v-18 a3,3 0 0 1 3,-3 z" />
<path android:fillColor="#BBDEFB" android:pathData="M7,14 h34 v16 h-34 z" />
<path android:fillColor="#1565C0" android:pathData="M20,38 h8 v2 h-8 z" />
<path android:fillColor="#1565C0" android:pathData="M16,40 h16 v2 h-16 z" />
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="96dp" android:height="96dp"
android:viewportWidth="48" android:viewportHeight="48">
<path android:fillColor="#1565C0"
android:pathData="M12,6 h24 a3,3 0 0 1 3,3 v30 a3,3 0 0 1 -3,3 h-24 a3,3 0 0 1 -3,-3 v-30 a3,3 0 0 1 3,-3 z" />
<path android:fillColor="#FFFFFF" android:pathData="M12,10 h24 v4 h-24 z" />
<path android:fillColor="#90CAF9" android:pathData="M31,12 m-1.6,0 a1.6,1.6 0 1,0 3.2,0 a1.6,1.6 0 1,0 -3.2,0" />
<path android:fillColor="#FFFFFF" android:pathData="M24,27 m-11,0 a11,11 0 1,0 22,0 a11,11 0 1,0 -22,0" />
<path android:fillColor="#42A5F5" android:pathData="M24,27 m-7,0 a7,7 0 1,0 14,0 a7,7 0 1,0 -14,0" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="brand_primary">#1565C0</color>
<color name="brand_background">#FFFFFF</color>
<color name="ic_launcher_background">#1565C0</color>
</resources>

View File

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

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.EasyAppliance" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@color/brand_primary</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>

View File

@@ -0,0 +1,50 @@
package kr.tkrmagid.easyappliance
import kotlinx.coroutines.test.runTest
import kr.tkrmagid.easyappliance.data.MockDeviceRepository
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class MockDeviceRepositoryTest {
@Test
fun listsBothSampleDevices() = runTest {
val repo = MockDeviceRepository()
val ids = repo.listDevices().map { it.id }
assertEquals(listOf("washer-1", "aircon-1"), ids)
}
@Test
fun powerToggleIsReflectedInStatus() = runTest {
val repo = MockDeviceRepository()
assertFalse(repo.getStatus("washer-1").powerOn)
repo.setPower("washer-1", true)
val on = repo.getStatus("washer-1")
assertTrue(on.powerOn)
assertEquals("운전 중", on.operatingState)
assertEquals(32, on.remainingMinutes)
repo.setPower("washer-1", false)
assertFalse(repo.getStatus("washer-1").powerOn)
}
@Test
fun runMacroStartsDevice() = runTest {
val repo = MockDeviceRepository()
assertFalse(repo.getStatus("washer-1").powerOn)
repo.runMacro("washer-1", emptyList())
assertTrue(repo.getStatus("washer-1").powerOn)
}
@Test
fun airconReportsTemperatureWhenOn() = runTest {
val repo = MockDeviceRepository()
val status = repo.getStatus("aircon-1")
assertTrue(status.powerOn)
assertEquals(27, status.currentTemperature)
assertEquals(24, status.targetTemperature)
}
}

View File

@@ -0,0 +1,74 @@
package kr.tkrmagid.easyappliance
import kr.tkrmagid.easyappliance.data.DeviceType
import kr.tkrmagid.easyappliance.data.smartthings.SmartThingsStatusMapper
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class SmartThingsStatusMapperTest {
private fun obj(json: String) = Json.parseToJsonElement(json).jsonObject
@Test
fun mapsRunningWasher() {
val status = obj(
"""
{"components":{"main":{
"switch":{"switch":{"value":"on"}},
"washerOperatingState":{"machineState":{"value":"run"}},
"samsungce.washerOperatingState":{"remainingTime":{"value":1980}}
}}}
""".trimIndent(),
)
val result = SmartThingsStatusMapper.map(status, DeviceType.WASHER)
assertTrue(result.powerOn)
assertEquals("운전 중", result.operatingState)
assertEquals(33, result.remainingMinutes)
}
@Test
fun mapsAircon() {
val status = obj(
"""
{"components":{"main":{
"switch":{"switch":{"value":"on"}},
"temperatureMeasurement":{"temperature":{"value":27}},
"thermostatCoolingSetpoint":{"coolingSetpoint":{"value":24}}
}}}
""".trimIndent(),
)
val result = SmartThingsStatusMapper.map(status, DeviceType.AIRCONDITIONER)
assertTrue(result.powerOn)
assertEquals("냉방 중", result.operatingState)
assertEquals(27, result.currentTemperature)
assertEquals(24, result.targetTemperature)
}
@Test
fun offDeviceHasNoTarget() {
val status = obj(
"""
{"components":{"main":{
"switch":{"switch":{"value":"off"}},
"temperatureMeasurement":{"temperature":{"value":28}},
"thermostatCoolingSetpoint":{"coolingSetpoint":{"value":22}}
}}}
""".trimIndent(),
)
val result = SmartThingsStatusMapper.map(status, DeviceType.AIRCONDITIONER)
assertTrue(!result.powerOn)
assertEquals("꺼짐", result.operatingState)
assertEquals(28, result.currentTemperature)
assertNull(result.targetTemperature)
}
@Test
fun missingComponentsIsSafe() {
val result = SmartThingsStatusMapper.map(obj("""{}"""), DeviceType.WASHER)
assertTrue(!result.powerOn)
}
}