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