Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71f30279d0 | ||
|
|
ffe8f93b0a |
@@ -24,8 +24,8 @@ android {
|
|||||||
applicationId = "kr.tkrmagid.easyappliance"
|
applicationId = "kr.tkrmagid.easyappliance"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = (project.findProperty("verCode") as String?)?.toInt() ?: 10
|
versionCode = (project.findProperty("verCode") as String?)?.toInt() ?: 12
|
||||||
versionName = (project.findProperty("verName") as String?) ?: "0.3.7"
|
versionName = (project.findProperty("verName") as String?) ?: "0.3.9"
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="false"
|
||||||
|
android:usesCleartextTraffic="false"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher"
|
android:roundIcon="@mipmap/ic_launcher"
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ object DeviceImages {
|
|||||||
|
|
||||||
val builtins: List<Builtin> = listOf(
|
val builtins: List<Builtin> = listOf(
|
||||||
Builtin("washer", "세탁기", R.drawable.img_washer),
|
Builtin("washer", "세탁기", R.drawable.img_washer),
|
||||||
Builtin("aircon", "에어컨", R.drawable.img_aircon),
|
Builtin("aircon", "벽걸이 에어컨", R.drawable.img_aircon),
|
||||||
|
Builtin("aircon_stand", "스탠드 에어컨", R.drawable.img_aircon_stand),
|
||||||
Builtin("fridge", "냉장고", R.drawable.img_fridge),
|
Builtin("fridge", "냉장고", R.drawable.img_fridge),
|
||||||
Builtin("tv", "TV", R.drawable.img_tv),
|
Builtin("tv", "TV", R.drawable.img_tv),
|
||||||
Builtin("device", "기타 기기", R.drawable.img_device),
|
Builtin("device", "기타 기기", R.drawable.img_device),
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class MockDeviceRepository : DeviceRepository {
|
|||||||
operatingState = if (on) "운전 중" else "꺼짐",
|
operatingState = if (on) "운전 중" else "꺼짐",
|
||||||
running = on,
|
running = on,
|
||||||
currentSettings = if (on) "물 온도 40도 · 탈수 강 · 헹굼 3회" else null,
|
currentSettings = if (on) "물 온도 40도 · 탈수 강 · 헹굼 3회" else null,
|
||||||
|
jobPhase = if (on) "헹굼 중" else null,
|
||||||
remainingMinutes = if (on) 32 else null,
|
remainingMinutes = if (on) 32 else null,
|
||||||
reservation = null,
|
reservation = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ data class DeviceStatus(
|
|||||||
* Only the few important fields; null when not running / not reported.
|
* Only the few important fields; null when not running / not reported.
|
||||||
*/
|
*/
|
||||||
val currentSettings: String? = null,
|
val currentSettings: String? = null,
|
||||||
|
/** Current job phase while running, e.g. "세탁 중", "헹굼 중", "탈수 중". */
|
||||||
|
val jobPhase: String? = null,
|
||||||
/** Remaining minutes for a running cycle, if reported. */
|
/** Remaining minutes for a running cycle, if reported. */
|
||||||
val remainingMinutes: Int? = null,
|
val remainingMinutes: Int? = null,
|
||||||
/** Reservation / scheduled-start description, if set. */
|
/** Reservation / scheduled-start description, if set. */
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ data class AppSettings(
|
|||||||
val showRemainingTime: Boolean = true,
|
val showRemainingTime: Boolean = true,
|
||||||
val showReservation: Boolean = true,
|
val showReservation: Boolean = true,
|
||||||
val showPowerControls: Boolean = true,
|
val showPowerControls: Boolean = true,
|
||||||
|
/** Ask for confirmation before start / power off (guards against mis-taps). */
|
||||||
|
val confirmBeforeAction: Boolean = true,
|
||||||
/** SmartThings Personal Access Token; null/blank means use sample data. */
|
/** SmartThings Personal Access Token; null/blank means use sample data. */
|
||||||
val smartThingsToken: String? = null,
|
val smartThingsToken: String? = null,
|
||||||
/** deviceId -> custom display name. */
|
/** deviceId -> custom display name. */
|
||||||
@@ -43,7 +45,9 @@ class SettingsRepository(private val context: Context) {
|
|||||||
showRemainingTime = p[KEY_SHOW_REMAINING] ?: true,
|
showRemainingTime = p[KEY_SHOW_REMAINING] ?: true,
|
||||||
showReservation = p[KEY_SHOW_RESERVATION] ?: true,
|
showReservation = p[KEY_SHOW_RESERVATION] ?: true,
|
||||||
showPowerControls = p[KEY_SHOW_POWER] ?: 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]),
|
labelOverrides = decodeMap(p[KEY_LABEL_OVERRIDES]),
|
||||||
imageOverrides = decodeMap(p[KEY_IMAGE_OVERRIDES]),
|
imageOverrides = decodeMap(p[KEY_IMAGE_OVERRIDES]),
|
||||||
deviceMacros = decodeMacros(p[KEY_MACROS]),
|
deviceMacros = decodeMacros(p[KEY_MACROS]),
|
||||||
@@ -65,8 +69,27 @@ class SettingsRepository(private val context: Context) {
|
|||||||
suspend fun setShowPowerControls(value: Boolean) =
|
suspend fun setShowPowerControls(value: Boolean) =
|
||||||
edit { it[KEY_SHOW_POWER] = value }
|
edit { it[KEY_SHOW_POWER] = value }
|
||||||
|
|
||||||
suspend fun setToken(token: String) =
|
suspend fun setConfirmBeforeAction(value: Boolean) =
|
||||||
edit { it[KEY_TOKEN] = token.trim() }
|
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 ->
|
suspend fun setDeviceLabel(deviceId: String, label: String) = edit { prefs ->
|
||||||
val map = decodeMap(prefs[KEY_LABEL_OVERRIDES]).toMutableMap()
|
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_REMAINING = booleanPreferencesKey("show_remaining_time")
|
||||||
val KEY_SHOW_RESERVATION = booleanPreferencesKey("show_reservation")
|
val KEY_SHOW_RESERVATION = booleanPreferencesKey("show_reservation")
|
||||||
val KEY_SHOW_POWER = booleanPreferencesKey("show_power_controls")
|
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 = stringPreferencesKey("smartthings_token")
|
||||||
|
val KEY_TOKEN_ENC = stringPreferencesKey("smartthings_token_enc")
|
||||||
val KEY_LABEL_OVERRIDES = stringPreferencesKey("label_overrides_json")
|
val KEY_LABEL_OVERRIDES = stringPreferencesKey("label_overrides_json")
|
||||||
val KEY_IMAGE_OVERRIDES = stringPreferencesKey("image_overrides_json")
|
val KEY_IMAGE_OVERRIDES = stringPreferencesKey("image_overrides_json")
|
||||||
val KEY_MACROS = stringPreferencesKey("device_macros_json")
|
val KEY_MACROS = stringPreferencesKey("device_macros_json")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -51,12 +51,19 @@ object SmartThingsStatusMapper {
|
|||||||
spin?.let { "탈수 $it" },
|
spin?.let { "탈수 $it" },
|
||||||
rinse?.let { "헹굼 ${it}회" },
|
rinse?.let { "헹굼 ${it}회" },
|
||||||
).joinToString(" · ").ifBlank { null }
|
).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(
|
return DeviceStatus(
|
||||||
powerOn = powerOn,
|
powerOn = powerOn,
|
||||||
operatingState = state,
|
operatingState = state,
|
||||||
running = running,
|
running = running,
|
||||||
currentSettings = if (running) settings else null,
|
currentSettings = if (running) settings else null,
|
||||||
|
jobPhase = if (running) jobPhase else null,
|
||||||
remainingMinutes = remaining,
|
remainingMinutes = remaining,
|
||||||
|
reservation = reservation,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +107,19 @@ object SmartThingsStatusMapper {
|
|||||||
else -> raw
|
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()) {
|
private fun acModeLabel(raw: String): String = when (raw.lowercase()) {
|
||||||
"cool" -> "냉방"
|
"cool" -> "냉방"
|
||||||
"dry" -> "제습"
|
"dry" -> "제습"
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ fun AdminScreen(
|
|||||||
onToggleRemaining: (Boolean) -> Unit,
|
onToggleRemaining: (Boolean) -> Unit,
|
||||||
onToggleReservation: (Boolean) -> Unit,
|
onToggleReservation: (Boolean) -> Unit,
|
||||||
onTogglePowerControls: (Boolean) -> Unit,
|
onTogglePowerControls: (Boolean) -> Unit,
|
||||||
|
onToggleConfirmAction: (Boolean) -> Unit,
|
||||||
onSetLabel: (String, String) -> Unit,
|
onSetLabel: (String, String) -> Unit,
|
||||||
onSetImageRef: (String, String) -> Unit,
|
onSetImageRef: (String, String) -> Unit,
|
||||||
onSetToken: (String) -> Unit,
|
onSetToken: (String) -> Unit,
|
||||||
@@ -171,6 +172,8 @@ fun AdminScreen(
|
|||||||
ToggleRow("예약 정보 표시", state.settings.showReservation, onToggleReservation)
|
ToggleRow("예약 정보 표시", state.settings.showReservation, onToggleReservation)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
ToggleRow("켜기 / 끄기 버튼 표시", state.settings.showPowerControls, onTogglePowerControls)
|
ToggleRow("켜기 / 끄기 버튼 표시", state.settings.showPowerControls, onTogglePowerControls)
|
||||||
|
HorizontalDivider()
|
||||||
|
ToggleRow("시작 / 끄기 전 확인창", state.settings.confirmBeforeAction, onToggleConfirmAction)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ fun AppRoot() {
|
|||||||
state = state,
|
state = state,
|
||||||
onTogglePower = vm::togglePower,
|
onTogglePower = vm::togglePower,
|
||||||
onRunMacro = vm::runMacro,
|
onRunMacro = vm::runMacro,
|
||||||
|
onClearNotice = vm::clearNotice,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.adminUnlockGesture { screen = Screen.ADMIN },
|
.adminUnlockGesture { screen = Screen.ADMIN },
|
||||||
@@ -89,6 +90,7 @@ fun AppRoot() {
|
|||||||
onToggleRemaining = vm::setShowRemainingTime,
|
onToggleRemaining = vm::setShowRemainingTime,
|
||||||
onToggleReservation = vm::setShowReservation,
|
onToggleReservation = vm::setShowReservation,
|
||||||
onTogglePowerControls = vm::setShowPowerControls,
|
onTogglePowerControls = vm::setShowPowerControls,
|
||||||
|
onToggleConfirmAction = vm::setConfirmBeforeAction,
|
||||||
onSetLabel = vm::setDeviceLabel,
|
onSetLabel = vm::setDeviceLabel,
|
||||||
onSetImageRef = vm::setDeviceImage,
|
onSetImageRef = vm::setDeviceImage,
|
||||||
onSetToken = vm::setToken,
|
onSetToken = vm::setToken,
|
||||||
|
|||||||
@@ -16,20 +16,21 @@ import kr.tkrmagid.easyappliance.data.DeviceImages
|
|||||||
fun DeviceImage(
|
fun DeviceImage(
|
||||||
imageRef: String,
|
imageRef: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
contentDescription: String? = null,
|
||||||
contentScale: ContentScale = ContentScale.Fit,
|
contentScale: ContentScale = ContentScale.Fit,
|
||||||
) {
|
) {
|
||||||
val builtinRes = DeviceImages.resForRef(imageRef)
|
val builtinRes = DeviceImages.resForRef(imageRef)
|
||||||
if (builtinRes != null) {
|
if (builtinRes != null) {
|
||||||
Image(
|
Image(
|
||||||
painter = painterResource(builtinRes),
|
painter = painterResource(builtinRes),
|
||||||
contentDescription = null,
|
contentDescription = contentDescription,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = imageRef,
|
model = imageRef,
|
||||||
contentDescription = null,
|
contentDescription = contentDescription,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.PowerSettingsNew
|
import androidx.compose.material.icons.filled.PowerSettingsNew
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
@@ -23,7 +24,12 @@ import androidx.compose.material3.CircularProgressIndicator
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
@@ -31,6 +37,9 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kr.tkrmagid.easyappliance.data.DeviceType
|
import kr.tkrmagid.easyappliance.data.DeviceType
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
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.HomeUiState
|
||||||
import kr.tkrmagid.easyappliance.vm.appDisplayName
|
import kr.tkrmagid.easyappliance.vm.appDisplayName
|
||||||
import kr.tkrmagid.easyappliance.vm.displayLabelFor
|
import kr.tkrmagid.easyappliance.vm.displayLabelFor
|
||||||
@@ -44,6 +53,7 @@ fun HomeScreen(
|
|||||||
state: HomeUiState,
|
state: HomeUiState,
|
||||||
onTogglePower: (Boolean) -> Unit,
|
onTogglePower: (Boolean) -> Unit,
|
||||||
onRunMacro: () -> Unit,
|
onRunMacro: () -> Unit,
|
||||||
|
onClearNotice: () -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||||
@@ -58,6 +68,12 @@ fun HomeScreen(
|
|||||||
return@BoxWithConstraints
|
return@BoxWithConstraints
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optional confirmation before start / power actions (guards mis-taps).
|
||||||
|
var pending by remember { mutableStateOf<PendingAction?>(null) }
|
||||||
|
val request: (String, () -> Unit) -> Unit = { msg, act ->
|
||||||
|
if (state.settings.confirmBeforeAction) pending = PendingAction(msg, act) else act()
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
@@ -69,6 +85,10 @@ fun HomeScreen(
|
|||||||
modifier = Modifier.widthIn(max = contentMaxWidth).fillMaxWidth(),
|
modifier = Modifier.widthIn(max = contentMaxWidth).fillMaxWidth(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
|
state.notice?.let { msg ->
|
||||||
|
NoticeCard(message = msg, onClose = onClearNotice)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = state.appDisplayName(),
|
text = state.appDisplayName(),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
@@ -78,6 +98,7 @@ fun HomeScreen(
|
|||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
DeviceImage(
|
DeviceImage(
|
||||||
imageRef = state.imageRefFor(device),
|
imageRef = state.imageRefFor(device),
|
||||||
|
contentDescription = state.displayLabelFor(device),
|
||||||
modifier = Modifier.size(if (narrow) 96.dp else 128.dp),
|
modifier = Modifier.size(if (narrow) 96.dp else 128.dp),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
@@ -108,22 +129,84 @@ fun HomeScreen(
|
|||||||
if (macroOn) {
|
if (macroOn) {
|
||||||
MacroControls(
|
MacroControls(
|
||||||
isOn = state.status?.powerOn == true,
|
isOn = state.status?.powerOn == true,
|
||||||
onStart = onRunMacro,
|
onStart = { request("정말 시작할까요?") { onRunMacro() } },
|
||||||
onOff = { onTogglePower(false) },
|
onOff = { request("정말 끌까요?") { onTogglePower(false) } },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
PowerControls(
|
PowerControls(
|
||||||
isOn = state.status?.powerOn == true,
|
isOn = state.status?.powerOn == true,
|
||||||
stacked = narrow,
|
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
|
@Composable
|
||||||
private fun StatusCard(state: HomeUiState) {
|
private fun StatusCard(state: HomeUiState) {
|
||||||
Card(
|
Card(
|
||||||
@@ -150,6 +233,16 @@ private fun StatusCard(state: HomeUiState) {
|
|||||||
textAlign = TextAlign.Center,
|
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) {
|
if (state.settings.showRemainingTime && status?.remainingMinutes != null) {
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ data class HomeUiState(
|
|||||||
val connected: Boolean = true,
|
val connected: Boolean = true,
|
||||||
/** True after the user closes the warning with the small "x". */
|
/** True after the user closes the warning with the small "x". */
|
||||||
val warningDismissed: Boolean = false,
|
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 error: String? = null,
|
||||||
) {
|
) {
|
||||||
val showConnectionWarning: Boolean get() = !connected && !warningDismissed
|
val showConnectionWarning: Boolean get() = !connected && !warningDismissed
|
||||||
@@ -56,6 +60,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val state: StateFlow<HomeUiState> = _state.asStateFlow()
|
val state: StateFlow<HomeUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
viewModelScope.launch { settingsRepo.migratePlaintextToken() }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
settingsRepo.settings.collect { settings -> applySettings(settings) }
|
settingsRepo.settings.collect { settings -> applySettings(settings) }
|
||||||
}
|
}
|
||||||
@@ -119,11 +124,20 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
.onSuccess { devices ->
|
.onSuccess { devices ->
|
||||||
_state.update { it.copy(devices = devices, connected = true) }
|
_state.update { it.copy(devices = devices, connected = true) }
|
||||||
}
|
}
|
||||||
.onFailure {
|
.onFailure { e ->
|
||||||
_state.update { it.copy(loading = false, connected = false) }
|
_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) {
|
private suspend fun resolveSelectionAndStatus(settings: AppSettings) {
|
||||||
val devices = _state.value.devices
|
val devices = _state.value.devices
|
||||||
val selected = devices.firstOrNull { it.id == settings.selectedDeviceId }
|
val selected = devices.firstOrNull { it.id == settings.selectedDeviceId }
|
||||||
@@ -150,15 +164,24 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
status = status,
|
status = status,
|
||||||
connected = !forcedWarning,
|
connected = !forcedWarning,
|
||||||
warningDismissed = if (!forcedWarning) false else it.warningDismissed,
|
warningDismissed = if (!forcedWarning) false else it.warningDismissed,
|
||||||
|
lastUpdatedAt = System.currentTimeMillis(),
|
||||||
error = null,
|
error = null,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onFailure {
|
.onFailure { e ->
|
||||||
_state.update { it.copy(loading = false, connected = false) }
|
_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. */
|
/** Refresh selected device status; also used by the periodic poll. */
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
val id = _state.value.selected?.id ?: return
|
val id = _state.value.selected?.id ?: return
|
||||||
@@ -200,7 +223,8 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val id = _state.value.selected?.id ?: return
|
val id = _state.value.selected?.id ?: return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { deviceRepo.setPower(id, on) }
|
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)
|
loadStatus(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,11 +235,15 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
val macro = _state.value.settings.deviceMacros[id] ?: return
|
val macro = _state.value.settings.deviceMacros[id] ?: return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { deviceRepo.runMacro(id, macro.steps) }
|
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)
|
loadStatus(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun commandFailMsg(e: Throwable): String =
|
||||||
|
if (e.isAuthError()) TOKEN_INVALID_MSG else "명령을 보내지 못했어요. 잠시 후 다시 시도해 주세요."
|
||||||
|
|
||||||
fun setDeviceMacro(deviceId: String, macro: DeviceMacro) {
|
fun setDeviceMacro(deviceId: String, macro: DeviceMacro) {
|
||||||
viewModelScope.launch { settingsRepo.setDeviceMacro(deviceId, macro) }
|
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 setShowRemainingTime(value: Boolean) { viewModelScope.launch { settingsRepo.setShowRemainingTime(value) } }
|
||||||
fun setShowReservation(value: Boolean) { viewModelScope.launch { settingsRepo.setShowReservation(value) } }
|
fun setShowReservation(value: Boolean) { viewModelScope.launch { settingsRepo.setShowReservation(value) } }
|
||||||
fun setShowPowerControls(value: Boolean) { viewModelScope.launch { settingsRepo.setShowPowerControls(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 setToken(token: String) { viewModelScope.launch { settingsRepo.setToken(token) } }
|
||||||
fun setDeviceLabel(deviceId: String, label: String) { viewModelScope.launch { settingsRepo.setDeviceLabel(deviceId, label) } }
|
fun setDeviceLabel(deviceId: String, label: String) { viewModelScope.launch { settingsRepo.setDeviceLabel(deviceId, label) } }
|
||||||
fun setDeviceImage(deviceId: String, imageRef: String) { viewModelScope.launch { settingsRepo.setDeviceImage(deviceId, imageRef) } }
|
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. */
|
/** The device's macro, or null if none configured. */
|
||||||
fun HomeUiState.macroFor(device: Device): DeviceMacro? = settings.deviceMacros[device.id]
|
fun HomeUiState.macroFor(device: Device): DeviceMacro? = settings.deviceMacros[device.id]
|
||||||
|
|
||||||
|
private const val TOKEN_INVALID_MSG =
|
||||||
|
"SmartThings 토큰이 올바르지 않습니다. 관리자 화면에서 토큰을 다시 확인해 주세요."
|
||||||
|
|
||||||
const val DEFAULT_APP_NAME = "쉬운 가전 리모컨"
|
const val DEFAULT_APP_NAME = "쉬운 가전 리모컨"
|
||||||
|
|
||||||
/** App-level display name shown as the home header. */
|
/** App-level display name shown as the home header. */
|
||||||
|
|||||||
17
app/src/main/res/drawable/img_aircon_stand.xml
Normal file
17
app/src/main/res/drawable/img_aircon_stand.xml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="96dp" android:height="96dp"
|
||||||
|
android:viewportWidth="48" android:viewportHeight="48">
|
||||||
|
<!-- tall standing body -->
|
||||||
|
<path android:fillColor="#1565C0"
|
||||||
|
android:pathData="M18,5 h12 a3,3 0 0 1 3,3 v32 a3,3 0 0 1 -3,3 h-12 a3,3 0 0 1 -3,-3 v-32 a3,3 0 0 1 3,-3 z" />
|
||||||
|
<!-- top intake vent -->
|
||||||
|
<path android:fillColor="#90CAF9" android:pathData="M19.5,9 h9 v3 h-9 z" />
|
||||||
|
<!-- display strip -->
|
||||||
|
<path android:fillColor="#FFFFFF" android:pathData="M21,15 h6 v2 h-6 z" />
|
||||||
|
<!-- lower air outlet -->
|
||||||
|
<path android:fillColor="#FFFFFF" android:pathData="M18.5,34 h11 v2.4 h-11 z" />
|
||||||
|
<!-- airflow curls under the outlet -->
|
||||||
|
<path android:fillColor="#00000000"
|
||||||
|
android:strokeColor="#42A5F5" android:strokeWidth="1.3"
|
||||||
|
android:pathData="M20,39 q1.5,2.5 3,0 M25,39 q1.5,2.5 3,0" />
|
||||||
|
</vector>
|
||||||
@@ -28,6 +28,7 @@ class MockDeviceRepositoryTest {
|
|||||||
assertEquals(32, on.remainingMinutes)
|
assertEquals(32, on.remainingMinutes)
|
||||||
assertTrue(on.running)
|
assertTrue(on.running)
|
||||||
assertEquals("물 온도 40도 · 탈수 강 · 헹굼 3회", on.currentSettings)
|
assertEquals("물 온도 40도 · 탈수 강 · 헹굼 3회", on.currentSettings)
|
||||||
|
assertEquals("헹굼 중", on.jobPhase)
|
||||||
|
|
||||||
repo.setPower("washer-1", false)
|
repo.setPower("washer-1", false)
|
||||||
val off = repo.getStatus("washer-1")
|
val off = repo.getStatus("washer-1")
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ class SmartThingsStatusMapperTest {
|
|||||||
"""
|
"""
|
||||||
{"components":{"main":{
|
{"components":{"main":{
|
||||||
"switch":{"switch":{"value":"on"}},
|
"switch":{"switch":{"value":"on"}},
|
||||||
"washerOperatingState":{"machineState":{"value":"run"}},
|
"washerOperatingState":{"machineState":{"value":"run"},"washerJobState":{"value":"rinse"}},
|
||||||
"samsungce.washerOperatingState":{"remainingTime":{"value":1980}},
|
"samsungce.washerOperatingState":{"remainingTime":{"value":1980}},
|
||||||
|
"samsungce.washerDelayEnd":{"remainingTime":{"value":0}},
|
||||||
"custom.washerWaterTemperature":{"washerWaterTemperature":{"value":"60"}},
|
"custom.washerWaterTemperature":{"washerWaterTemperature":{"value":"60"}},
|
||||||
"custom.washerSpinLevel":{"washerSpinLevel":{"value":"extraHigh"}},
|
"custom.washerSpinLevel":{"washerSpinLevel":{"value":"extraHigh"}},
|
||||||
"custom.washerRinseCycles":{"washerRinseCycles":{"value":"3"}}
|
"custom.washerRinseCycles":{"washerRinseCycles":{"value":"3"}}
|
||||||
@@ -33,6 +34,23 @@ class SmartThingsStatusMapperTest {
|
|||||||
assertEquals(33, result.remainingMinutes)
|
assertEquals(33, result.remainingMinutes)
|
||||||
assertTrue(result.running)
|
assertTrue(result.running)
|
||||||
assertEquals("물 온도 60도 · 탈수 최강 · 헹굼 3회", result.currentSettings)
|
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
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user