diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index e79df7d..9ac6a06 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -24,8 +24,8 @@ android {
applicationId = "kr.tkrmagid.easyappliance"
minSdk = 24
targetSdk = 35
- versionCode = (project.findProperty("verCode") as String?)?.toInt() ?: 11
- versionName = (project.findProperty("verName") as String?) ?: "0.3.8"
+ versionCode = (project.findProperty("verCode") as String?)?.toInt() ?: 12
+ versionName = (project.findProperty("verName") as String?) ?: "0.3.9"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a89a578..2376244 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -6,7 +6,8 @@
custom display name. */
@@ -43,7 +45,9 @@ class SettingsRepository(private val context: Context) {
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() },
+ confirmBeforeAction = p[KEY_CONFIRM_ACTION] ?: true,
+ smartThingsToken = (p[KEY_TOKEN_ENC]?.let { TokenCrypto.decrypt(it) } ?: p[KEY_TOKEN])
+ ?.takeIf { it.isNotBlank() },
labelOverrides = decodeMap(p[KEY_LABEL_OVERRIDES]),
imageOverrides = decodeMap(p[KEY_IMAGE_OVERRIDES]),
deviceMacros = decodeMacros(p[KEY_MACROS]),
@@ -65,8 +69,27 @@ class SettingsRepository(private val context: Context) {
suspend fun setShowPowerControls(value: Boolean) =
edit { it[KEY_SHOW_POWER] = value }
- suspend fun setToken(token: String) =
- edit { it[KEY_TOKEN] = token.trim() }
+ suspend fun setConfirmBeforeAction(value: Boolean) =
+ edit { it[KEY_CONFIRM_ACTION] = value }
+
+ suspend fun setToken(token: String) = edit { p ->
+ val t = token.trim()
+ if (t.isBlank()) {
+ p.remove(KEY_TOKEN_ENC); p.remove(KEY_TOKEN)
+ } else {
+ val enc = TokenCrypto.encrypt(t)
+ if (enc != null) { p[KEY_TOKEN_ENC] = enc; p.remove(KEY_TOKEN) }
+ else p[KEY_TOKEN] = t // last-resort fallback if the keystore is unavailable
+ }
+ }
+
+ /** One-time move of any legacy plaintext token into encrypted storage. */
+ suspend fun migratePlaintextToken() = edit { p ->
+ val legacy = p[KEY_TOKEN]
+ if (!legacy.isNullOrBlank() && p[KEY_TOKEN_ENC] == null) {
+ TokenCrypto.encrypt(legacy)?.let { p[KEY_TOKEN_ENC] = it; p.remove(KEY_TOKEN) }
+ }
+ }
suspend fun setDeviceLabel(deviceId: String, label: String) = edit { prefs ->
val map = decodeMap(prefs[KEY_LABEL_OVERRIDES]).toMutableMap()
@@ -104,7 +127,9 @@ class SettingsRepository(private val context: Context) {
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_CONFIRM_ACTION = booleanPreferencesKey("confirm_before_action")
val KEY_TOKEN = stringPreferencesKey("smartthings_token")
+ val KEY_TOKEN_ENC = stringPreferencesKey("smartthings_token_enc")
val KEY_LABEL_OVERRIDES = stringPreferencesKey("label_overrides_json")
val KEY_IMAGE_OVERRIDES = stringPreferencesKey("image_overrides_json")
val KEY_MACROS = stringPreferencesKey("device_macros_json")
diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/data/TokenCrypto.kt b/app/src/main/java/kr/tkrmagid/easyappliance/data/TokenCrypto.kt
new file mode 100644
index 0000000..4d7012a
--- /dev/null
+++ b/app/src/main/java/kr/tkrmagid/easyappliance/data/TokenCrypto.kt
@@ -0,0 +1,57 @@
+package kr.tkrmagid.easyappliance.data
+
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import android.util.Base64
+import java.security.KeyStore
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+
+/**
+ * Encrypts the SmartThings token at rest using an AES/GCM key held in the
+ * AndroidKeyStore (never leaves the device, not included in backups). Returns
+ * null on any failure so callers can fall back gracefully.
+ */
+object TokenCrypto {
+ private const val KEYSTORE = "AndroidKeyStore"
+ private const val ALIAS = "st_token_key"
+ private const val TRANSFORM = "AES/GCM/NoPadding"
+ private const val IV_LEN = 12
+ private const val TAG_BITS = 128
+
+ private fun secretKey(): SecretKey {
+ val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) }
+ (ks.getEntry(ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
+ val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
+ gen.init(
+ KeyGenParameterSpec.Builder(
+ ALIAS,
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
+ )
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .build(),
+ )
+ return gen.generateKey()
+ }
+
+ /** base64(iv + ciphertext), or null if encryption is unavailable. */
+ fun encrypt(plain: String): String? = runCatching {
+ val cipher = Cipher.getInstance(TRANSFORM)
+ cipher.init(Cipher.ENCRYPT_MODE, secretKey())
+ val ct = cipher.doFinal(plain.toByteArray(Charsets.UTF_8))
+ Base64.encodeToString(cipher.iv + ct, Base64.NO_WRAP)
+ }.getOrNull()
+
+ /** Recovers the plaintext from [encrypt] output, or null if it can't be read. */
+ fun decrypt(blob: String): String? = runCatching {
+ val data = Base64.decode(blob, Base64.NO_WRAP)
+ val iv = data.copyOfRange(0, IV_LEN)
+ val ct = data.copyOfRange(IV_LEN, data.size)
+ val cipher = Cipher.getInstance(TRANSFORM)
+ cipher.init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(TAG_BITS, iv))
+ String(cipher.doFinal(ct), Charsets.UTF_8)
+ }.getOrNull()
+}
diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/data/smartthings/SmartThingsStatusMapper.kt b/app/src/main/java/kr/tkrmagid/easyappliance/data/smartthings/SmartThingsStatusMapper.kt
index c3dc450..1d6d585 100644
--- a/app/src/main/java/kr/tkrmagid/easyappliance/data/smartthings/SmartThingsStatusMapper.kt
+++ b/app/src/main/java/kr/tkrmagid/easyappliance/data/smartthings/SmartThingsStatusMapper.kt
@@ -51,12 +51,19 @@ object SmartThingsStatusMapper {
spin?.let { "탈수 $it" },
rinse?.let { "헹굼 ${it}회" },
).joinToString(" · ").ifBlank { null }
+ val jobPhase = (attr(main, "washerOperatingState", "washerJobState")?.contentOrNull()
+ ?: attr(main, "samsungce.washerOperatingState", "washerJobState")?.contentOrNull())
+ ?.let { washerJobPhaseLabel(it) }
+ val reserveMinutes = numberAttr(main, "samsungce.washerDelayEnd", "remainingTime")
+ val reservation = reserveMinutes?.takeIf { it > 0 }?.let { "약 ${it}분 뒤 완료 예약" }
return DeviceStatus(
powerOn = powerOn,
operatingState = state,
running = running,
currentSettings = if (running) settings else null,
+ jobPhase = if (running) jobPhase else null,
remainingMinutes = remaining,
+ reservation = reservation,
)
}
@@ -100,6 +107,19 @@ object SmartThingsStatusMapper {
else -> raw
}
+ private fun washerJobPhaseLabel(raw: String): String? = when (raw.lowercase()) {
+ "wash" -> "세탁 중"
+ "rinse" -> "헹굼 중"
+ "spin" -> "탈수 중"
+ "weightsensing" -> "무게 감지 중"
+ "soak" -> "불림 중"
+ "drying" -> "건조 중"
+ "cooling" -> "냉각 중"
+ "finish" -> "마무리 중"
+ "none" -> null
+ else -> null
+ }
+
private fun acModeLabel(raw: String): String = when (raw.lowercase()) {
"cool" -> "냉방"
"dry" -> "제습"
diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/ui/AdminScreen.kt b/app/src/main/java/kr/tkrmagid/easyappliance/ui/AdminScreen.kt
index 3f7c09b..5385261 100644
--- a/app/src/main/java/kr/tkrmagid/easyappliance/ui/AdminScreen.kt
+++ b/app/src/main/java/kr/tkrmagid/easyappliance/ui/AdminScreen.kt
@@ -66,6 +66,7 @@ fun AdminScreen(
onToggleRemaining: (Boolean) -> Unit,
onToggleReservation: (Boolean) -> Unit,
onTogglePowerControls: (Boolean) -> Unit,
+ onToggleConfirmAction: (Boolean) -> Unit,
onSetLabel: (String, String) -> Unit,
onSetImageRef: (String, String) -> Unit,
onSetToken: (String) -> Unit,
@@ -171,6 +172,8 @@ fun AdminScreen(
ToggleRow("예약 정보 표시", state.settings.showReservation, onToggleReservation)
HorizontalDivider()
ToggleRow("켜기 / 끄기 버튼 표시", state.settings.showPowerControls, onTogglePowerControls)
+ HorizontalDivider()
+ ToggleRow("시작 / 끄기 전 확인창", state.settings.confirmBeforeAction, onToggleConfirmAction)
}
}
diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/ui/AppRoot.kt b/app/src/main/java/kr/tkrmagid/easyappliance/ui/AppRoot.kt
index 0dfaba2..47f033f 100644
--- a/app/src/main/java/kr/tkrmagid/easyappliance/ui/AppRoot.kt
+++ b/app/src/main/java/kr/tkrmagid/easyappliance/ui/AppRoot.kt
@@ -77,6 +77,7 @@ fun AppRoot() {
state = state,
onTogglePower = vm::togglePower,
onRunMacro = vm::runMacro,
+ onClearNotice = vm::clearNotice,
modifier = Modifier
.fillMaxSize()
.adminUnlockGesture { screen = Screen.ADMIN },
@@ -89,6 +90,7 @@ fun AppRoot() {
onToggleRemaining = vm::setShowRemainingTime,
onToggleReservation = vm::setShowReservation,
onTogglePowerControls = vm::setShowPowerControls,
+ onToggleConfirmAction = vm::setConfirmBeforeAction,
onSetLabel = vm::setDeviceLabel,
onSetImageRef = vm::setDeviceImage,
onSetToken = vm::setToken,
diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/ui/DeviceImage.kt b/app/src/main/java/kr/tkrmagid/easyappliance/ui/DeviceImage.kt
index 04a60e6..33d9bd4 100644
--- a/app/src/main/java/kr/tkrmagid/easyappliance/ui/DeviceImage.kt
+++ b/app/src/main/java/kr/tkrmagid/easyappliance/ui/DeviceImage.kt
@@ -16,20 +16,21 @@ import kr.tkrmagid.easyappliance.data.DeviceImages
fun DeviceImage(
imageRef: String,
modifier: Modifier = Modifier,
+ contentDescription: String? = null,
contentScale: ContentScale = ContentScale.Fit,
) {
val builtinRes = DeviceImages.resForRef(imageRef)
if (builtinRes != null) {
Image(
painter = painterResource(builtinRes),
- contentDescription = null,
+ contentDescription = contentDescription,
modifier = modifier,
contentScale = contentScale,
)
} else {
AsyncImage(
model = imageRef,
- contentDescription = null,
+ contentDescription = contentDescription,
modifier = modifier,
contentScale = ContentScale.Crop,
)
diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/ui/HomeScreen.kt b/app/src/main/java/kr/tkrmagid/easyappliance/ui/HomeScreen.kt
index 8869531..9e4272b 100644
--- a/app/src/main/java/kr/tkrmagid/easyappliance/ui/HomeScreen.kt
+++ b/app/src/main/java/kr/tkrmagid/easyappliance/ui/HomeScreen.kt
@@ -15,6 +15,7 @@ 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.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
@@ -23,7 +24,12 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
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.graphics.Color
@@ -31,6 +37,9 @@ 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 java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
import kr.tkrmagid.easyappliance.vm.HomeUiState
import kr.tkrmagid.easyappliance.vm.appDisplayName
import kr.tkrmagid.easyappliance.vm.displayLabelFor
@@ -44,6 +53,7 @@ fun HomeScreen(
state: HomeUiState,
onTogglePower: (Boolean) -> Unit,
onRunMacro: () -> Unit,
+ onClearNotice: () -> Unit = {},
modifier: Modifier = Modifier,
) {
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
@@ -58,6 +68,12 @@ fun HomeScreen(
return@BoxWithConstraints
}
+ // Optional confirmation before start / power actions (guards mis-taps).
+ var pending by remember { mutableStateOf(null) }
+ val request: (String, () -> Unit) -> Unit = { msg, act ->
+ if (state.settings.confirmBeforeAction) pending = PendingAction(msg, act) else act()
+ }
+
Column(
modifier = Modifier
.fillMaxSize()
@@ -69,6 +85,10 @@ fun HomeScreen(
modifier = Modifier.widthIn(max = contentMaxWidth).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
+ state.notice?.let { msg ->
+ NoticeCard(message = msg, onClose = onClearNotice)
+ Spacer(Modifier.height(16.dp))
+ }
Text(
text = state.appDisplayName(),
style = MaterialTheme.typography.titleLarge,
@@ -78,6 +98,7 @@ fun HomeScreen(
Spacer(Modifier.height(16.dp))
DeviceImage(
imageRef = state.imageRefFor(device),
+ contentDescription = state.displayLabelFor(device),
modifier = Modifier.size(if (narrow) 96.dp else 128.dp),
)
Spacer(Modifier.height(12.dp))
@@ -108,22 +129,84 @@ fun HomeScreen(
if (macroOn) {
MacroControls(
isOn = state.status?.powerOn == true,
- onStart = onRunMacro,
- onOff = { onTogglePower(false) },
+ onStart = { request("정말 시작할까요?") { onRunMacro() } },
+ onOff = { request("정말 끌까요?") { onTogglePower(false) } },
)
} else {
PowerControls(
isOn = state.status?.powerOn == true,
stacked = narrow,
- onTogglePower = onTogglePower,
+ onTogglePower = { on ->
+ request(if (on) "정말 켤까요?" else "정말 끌까요?") { onTogglePower(on) }
+ },
)
}
}
+
+ state.lastUpdatedAt?.let { ts ->
+ Spacer(Modifier.height(16.dp))
+ Text(
+ text = "마지막 확인 ${formatTime(ts)}" + if (!state.connected) " · 오프라인" else "",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ )
+ }
}
}
+
+ pending?.let { p ->
+ ConfirmDialog(
+ message = p.message,
+ onConfirm = { pending = null; p.onConfirm() },
+ onCancel = { pending = null },
+ )
+ }
}
}
+private data class PendingAction(val message: String, val onConfirm: () -> Unit)
+
+private fun formatTime(epochMillis: Long): String =
+ SimpleDateFormat("a h:mm", Locale.KOREAN).format(Date(epochMillis))
+
+@Composable
+private fun NoticeCard(message: String, onClose: () -> Unit) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer,
+ contentColor = MaterialTheme.colorScheme.onErrorContainer,
+ ),
+ ) {
+ Row(
+ Modifier.fillMaxWidth().padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(message, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
+ TextButton(onClick = onClose) { Text("확인") }
+ }
+ }
+}
+
+@Composable
+private fun ConfirmDialog(message: String, onConfirm: () -> Unit, onCancel: () -> Unit) {
+ AlertDialog(
+ onDismissRequest = onCancel,
+ title = { Text(message, style = MaterialTheme.typography.headlineMedium) },
+ confirmButton = {
+ Button(onClick = onConfirm, modifier = Modifier.height(60.dp)) {
+ Text("예", style = MaterialTheme.typography.labelLarge)
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onCancel, modifier = Modifier.height(60.dp)) {
+ Text("아니오", style = MaterialTheme.typography.labelLarge)
+ }
+ },
+ )
+}
+
@Composable
private fun StatusCard(state: HomeUiState) {
Card(
@@ -150,6 +233,16 @@ private fun StatusCard(state: HomeUiState) {
textAlign = TextAlign.Center,
)
+ status?.jobPhase?.let {
+ Spacer(Modifier.height(4.dp))
+ Text(
+ it,
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.primary,
+ textAlign = TextAlign.Center,
+ )
+ }
+
if (state.settings.showRemainingTime && status?.remainingMinutes != null) {
Spacer(Modifier.height(12.dp))
Text(
diff --git a/app/src/main/java/kr/tkrmagid/easyappliance/vm/AppViewModel.kt b/app/src/main/java/kr/tkrmagid/easyappliance/vm/AppViewModel.kt
index 117691f..3a76dad 100644
--- a/app/src/main/java/kr/tkrmagid/easyappliance/vm/AppViewModel.kt
+++ b/app/src/main/java/kr/tkrmagid/easyappliance/vm/AppViewModel.kt
@@ -37,6 +37,10 @@ data class HomeUiState(
val connected: Boolean = true,
/** True after the user closes the warning with the small "x". */
val warningDismissed: Boolean = false,
+ /** Transient, large-text notice shown to the user (e.g. command failed). */
+ val notice: String? = null,
+ /** When the shown status was last refreshed successfully (epoch millis). */
+ val lastUpdatedAt: Long? = null,
val error: String? = null,
) {
val showConnectionWarning: Boolean get() = !connected && !warningDismissed
@@ -56,6 +60,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
val state: StateFlow = _state.asStateFlow()
init {
+ viewModelScope.launch { settingsRepo.migratePlaintextToken() }
viewModelScope.launch {
settingsRepo.settings.collect { settings -> applySettings(settings) }
}
@@ -119,11 +124,20 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
.onSuccess { devices ->
_state.update { it.copy(devices = devices, connected = true) }
}
- .onFailure {
- _state.update { it.copy(loading = false, connected = false) }
+ .onFailure { e ->
+ _state.update {
+ it.copy(
+ loading = false,
+ connected = false,
+ notice = if (e.isAuthError()) TOKEN_INVALID_MSG else it.notice,
+ )
+ }
}
}
+ private fun Throwable.isAuthError(): Boolean =
+ this is retrofit2.HttpException && (code() == 401 || code() == 403)
+
private suspend fun resolveSelectionAndStatus(settings: AppSettings) {
val devices = _state.value.devices
val selected = devices.firstOrNull { it.id == settings.selectedDeviceId }
@@ -150,15 +164,24 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
status = status,
connected = !forcedWarning,
warningDismissed = if (!forcedWarning) false else it.warningDismissed,
+ lastUpdatedAt = System.currentTimeMillis(),
error = null,
)
}
}
- .onFailure {
- _state.update { it.copy(loading = false, connected = false) }
+ .onFailure { e ->
+ _state.update {
+ it.copy(
+ loading = false,
+ connected = false,
+ notice = if (e.isAuthError()) TOKEN_INVALID_MSG else it.notice,
+ )
+ }
}
}
+ fun clearNotice() { _state.update { it.copy(notice = null) } }
+
/** Refresh selected device status; also used by the periodic poll. */
fun refresh() {
val id = _state.value.selected?.id ?: return
@@ -200,7 +223,8 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
val id = _state.value.selected?.id ?: return
viewModelScope.launch {
runCatching { deviceRepo.setPower(id, on) }
- .onFailure { _state.update { s -> s.copy(connected = false) } }
+ .onSuccess { _state.update { s -> s.copy(notice = null) } }
+ .onFailure { e -> _state.update { s -> s.copy(connected = false, notice = commandFailMsg(e)) } }
loadStatus(id)
}
}
@@ -211,11 +235,15 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
val macro = _state.value.settings.deviceMacros[id] ?: return
viewModelScope.launch {
runCatching { deviceRepo.runMacro(id, macro.steps) }
- .onFailure { _state.update { s -> s.copy(connected = false) } }
+ .onSuccess { _state.update { s -> s.copy(notice = null) } }
+ .onFailure { e -> _state.update { s -> s.copy(connected = false, notice = commandFailMsg(e)) } }
loadStatus(id)
}
}
+ private fun commandFailMsg(e: Throwable): String =
+ if (e.isAuthError()) TOKEN_INVALID_MSG else "명령을 보내지 못했어요. 잠시 후 다시 시도해 주세요."
+
fun setDeviceMacro(deviceId: String, macro: DeviceMacro) {
viewModelScope.launch { settingsRepo.setDeviceMacro(deviceId, macro) }
}
@@ -224,6 +252,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
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 setConfirmBeforeAction(value: Boolean) { viewModelScope.launch { settingsRepo.setConfirmBeforeAction(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) } }
@@ -240,6 +269,9 @@ fun HomeUiState.imageRefFor(device: Device): String =
/** The device's macro, or null if none configured. */
fun HomeUiState.macroFor(device: Device): DeviceMacro? = settings.deviceMacros[device.id]
+private const val TOKEN_INVALID_MSG =
+ "SmartThings 토큰이 올바르지 않습니다. 관리자 화면에서 토큰을 다시 확인해 주세요."
+
const val DEFAULT_APP_NAME = "쉬운 가전 리모컨"
/** App-level display name shown as the home header. */
diff --git a/app/src/test/java/kr/tkrmagid/easyappliance/MockDeviceRepositoryTest.kt b/app/src/test/java/kr/tkrmagid/easyappliance/MockDeviceRepositoryTest.kt
index 24621df..c9af3d9 100644
--- a/app/src/test/java/kr/tkrmagid/easyappliance/MockDeviceRepositoryTest.kt
+++ b/app/src/test/java/kr/tkrmagid/easyappliance/MockDeviceRepositoryTest.kt
@@ -28,6 +28,7 @@ class MockDeviceRepositoryTest {
assertEquals(32, on.remainingMinutes)
assertTrue(on.running)
assertEquals("물 온도 40도 · 탈수 강 · 헹굼 3회", on.currentSettings)
+ assertEquals("헹굼 중", on.jobPhase)
repo.setPower("washer-1", false)
val off = repo.getStatus("washer-1")
diff --git a/app/src/test/java/kr/tkrmagid/easyappliance/SmartThingsStatusMapperTest.kt b/app/src/test/java/kr/tkrmagid/easyappliance/SmartThingsStatusMapperTest.kt
index d45ae2b..f39708c 100644
--- a/app/src/test/java/kr/tkrmagid/easyappliance/SmartThingsStatusMapperTest.kt
+++ b/app/src/test/java/kr/tkrmagid/easyappliance/SmartThingsStatusMapperTest.kt
@@ -19,8 +19,9 @@ class SmartThingsStatusMapperTest {
"""
{"components":{"main":{
"switch":{"switch":{"value":"on"}},
- "washerOperatingState":{"machineState":{"value":"run"}},
+ "washerOperatingState":{"machineState":{"value":"run"},"washerJobState":{"value":"rinse"}},
"samsungce.washerOperatingState":{"remainingTime":{"value":1980}},
+ "samsungce.washerDelayEnd":{"remainingTime":{"value":0}},
"custom.washerWaterTemperature":{"washerWaterTemperature":{"value":"60"}},
"custom.washerSpinLevel":{"washerSpinLevel":{"value":"extraHigh"}},
"custom.washerRinseCycles":{"washerRinseCycles":{"value":"3"}}
@@ -33,6 +34,23 @@ class SmartThingsStatusMapperTest {
assertEquals(33, result.remainingMinutes)
assertTrue(result.running)
assertEquals("물 온도 60도 · 탈수 최강 · 헹굼 3회", result.currentSettings)
+ assertEquals("헹굼 중", result.jobPhase)
+ assertNull(result.reservation)
+ }
+
+ @Test
+ fun mapsWasherReservation() {
+ val status = obj(
+ """
+ {"components":{"main":{
+ "switch":{"switch":{"value":"on"}},
+ "washerOperatingState":{"machineState":{"value":"stop"}},
+ "samsungce.washerDelayEnd":{"remainingTime":{"value":120}}
+ }}}
+ """.trimIndent(),
+ )
+ val result = SmartThingsStatusMapper.map(status, DeviceType.WASHER)
+ assertEquals("약 120분 뒤 완료 예약", result.reservation)
}
@Test