Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
067170802a | ||
|
|
2287e1cf83 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,4 +12,3 @@
|
|||||||
.cxx
|
.cxx
|
||||||
app/build/
|
app/build/
|
||||||
build/
|
build/
|
||||||
.kotlin/
|
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ android {
|
|||||||
applicationId = "kr.tkrmagid.easyappliance"
|
applicationId = "kr.tkrmagid.easyappliance"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 3
|
versionCode = 5
|
||||||
versionName = "0.3.0"
|
versionName = "0.3.2"
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(na
|
|||||||
|
|
||||||
/** User/admin choices that must survive app restarts. */
|
/** User/admin choices that must survive app restarts. */
|
||||||
data class AppSettings(
|
data class AppSettings(
|
||||||
|
/** App-level display name shown as the home header; null = default. */
|
||||||
|
val appName: String? = null,
|
||||||
val selectedDeviceId: String? = null,
|
val selectedDeviceId: String? = null,
|
||||||
val showRemainingTime: Boolean = true,
|
val showRemainingTime: Boolean = true,
|
||||||
val showReservation: Boolean = true,
|
val showReservation: Boolean = true,
|
||||||
@@ -36,6 +38,7 @@ class SettingsRepository(private val context: Context) {
|
|||||||
|
|
||||||
val settings: Flow<AppSettings> = context.dataStore.data.map { p ->
|
val settings: Flow<AppSettings> = context.dataStore.data.map { p ->
|
||||||
AppSettings(
|
AppSettings(
|
||||||
|
appName = p[KEY_APP_NAME]?.takeIf { it.isNotBlank() },
|
||||||
selectedDeviceId = p[KEY_SELECTED_DEVICE],
|
selectedDeviceId = p[KEY_SELECTED_DEVICE],
|
||||||
showRemainingTime = p[KEY_SHOW_REMAINING] ?: true,
|
showRemainingTime = p[KEY_SHOW_REMAINING] ?: true,
|
||||||
showReservation = p[KEY_SHOW_RESERVATION] ?: true,
|
showReservation = p[KEY_SHOW_RESERVATION] ?: true,
|
||||||
@@ -47,6 +50,9 @@ class SettingsRepository(private val context: Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun setAppName(name: String) =
|
||||||
|
edit { it[KEY_APP_NAME] = name.trim() }
|
||||||
|
|
||||||
suspend fun setSelectedDevice(deviceId: String) =
|
suspend fun setSelectedDevice(deviceId: String) =
|
||||||
edit { it[KEY_SELECTED_DEVICE] = deviceId }
|
edit { it[KEY_SELECTED_DEVICE] = deviceId }
|
||||||
|
|
||||||
@@ -93,6 +99,7 @@ class SettingsRepository(private val context: Context) {
|
|||||||
else runCatching { Json.decodeFromString<Map<String, DeviceMacro>>(raw) }.getOrDefault(emptyMap())
|
else runCatching { Json.decodeFromString<Map<String, DeviceMacro>>(raw) }.getOrDefault(emptyMap())
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
val KEY_APP_NAME = stringPreferencesKey("app_name")
|
||||||
val KEY_SELECTED_DEVICE = stringPreferencesKey("selected_device_id")
|
val KEY_SELECTED_DEVICE = stringPreferencesKey("selected_device_id")
|
||||||
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")
|
||||||
|
|||||||
@@ -18,10 +18,13 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.selection.selectable
|
import androidx.compose.foundation.selection.selectable
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
@@ -49,6 +52,7 @@ import kr.tkrmagid.easyappliance.data.DeviceImages
|
|||||||
import kr.tkrmagid.easyappliance.data.DeviceMacro
|
import kr.tkrmagid.easyappliance.data.DeviceMacro
|
||||||
import kr.tkrmagid.easyappliance.data.ImageStorage
|
import kr.tkrmagid.easyappliance.data.ImageStorage
|
||||||
import kr.tkrmagid.easyappliance.vm.HomeUiState
|
import kr.tkrmagid.easyappliance.vm.HomeUiState
|
||||||
|
import kr.tkrmagid.easyappliance.vm.appDisplayName
|
||||||
import kr.tkrmagid.easyappliance.vm.displayLabelFor
|
import kr.tkrmagid.easyappliance.vm.displayLabelFor
|
||||||
import kr.tkrmagid.easyappliance.vm.imageRefFor
|
import kr.tkrmagid.easyappliance.vm.imageRefFor
|
||||||
import kr.tkrmagid.easyappliance.vm.macroFor
|
import kr.tkrmagid.easyappliance.vm.macroFor
|
||||||
@@ -57,6 +61,8 @@ import kr.tkrmagid.easyappliance.vm.macroFor
|
|||||||
fun AdminScreen(
|
fun AdminScreen(
|
||||||
state: HomeUiState,
|
state: HomeUiState,
|
||||||
onSelectDevice: (String) -> Unit,
|
onSelectDevice: (String) -> Unit,
|
||||||
|
onRefreshDevices: () -> Unit,
|
||||||
|
onSetAppName: (String) -> Unit,
|
||||||
onToggleRemaining: (Boolean) -> Unit,
|
onToggleRemaining: (Boolean) -> Unit,
|
||||||
onToggleReservation: (Boolean) -> Unit,
|
onToggleReservation: (Boolean) -> Unit,
|
||||||
onTogglePowerControls: (Boolean) -> Unit,
|
onTogglePowerControls: (Boolean) -> Unit,
|
||||||
@@ -78,6 +84,13 @@ fun AdminScreen(
|
|||||||
Text("관리자 설정", style = MaterialTheme.typography.headlineLarge)
|
Text("관리자 설정", style = MaterialTheme.typography.headlineLarge)
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
SectionTitle("앱 이름 바꾸기")
|
||||||
|
NameEditor(
|
||||||
|
current = state.appDisplayName(),
|
||||||
|
onSave = onSetAppName,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(28.dp))
|
||||||
SectionTitle("조작할 기기 선택")
|
SectionTitle("조작할 기기 선택")
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -102,9 +115,18 @@ fun AdminScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onRefreshDevices,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(60.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(26.dp))
|
||||||
|
Text(" 목록 새로고침", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
|
||||||
if (selected != null) {
|
if (selected != null) {
|
||||||
Spacer(Modifier.height(28.dp))
|
Spacer(Modifier.height(28.dp))
|
||||||
SectionTitle("이름 바꾸기")
|
SectionTitle("기기 이름 바꾸기")
|
||||||
NameEditor(
|
NameEditor(
|
||||||
current = state.displayLabelFor(selected),
|
current = state.displayLabelFor(selected),
|
||||||
onSave = { onSetLabel(selected.id, it) },
|
onSave = { onSetLabel(selected.id, it) },
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
package kr.tkrmagid.easyappliance.ui
|
package kr.tkrmagid.easyappliance.ui
|
||||||
|
|
||||||
|
import android.app.ActivityManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.ContextWrapper
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -12,17 +16,25 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import kr.tkrmagid.easyappliance.data.koreanLabel
|
import kr.tkrmagid.easyappliance.data.koreanLabel
|
||||||
import kr.tkrmagid.easyappliance.vm.AppViewModel
|
import kr.tkrmagid.easyappliance.vm.AppViewModel
|
||||||
|
import kr.tkrmagid.easyappliance.vm.appDisplayName
|
||||||
import kr.tkrmagid.easyappliance.vm.displayLabelFor
|
import kr.tkrmagid.easyappliance.vm.displayLabelFor
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
private enum class Screen { HOME, ADMIN }
|
private enum class Screen { HOME, ADMIN }
|
||||||
|
|
||||||
|
private tailrec fun Context.findActivity(): ComponentActivity? = when (this) {
|
||||||
|
is ComponentActivity -> this
|
||||||
|
is ContextWrapper -> baseContext.findActivity()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
private const val POLL_OK_MS = 15_000L
|
private const val POLL_OK_MS = 15_000L
|
||||||
private const val POLL_OFFLINE_MS = 5_000L
|
private const val POLL_OFFLINE_MS = 5_000L
|
||||||
|
|
||||||
@@ -32,6 +44,16 @@ fun AppRoot() {
|
|||||||
val state by vm.state.collectAsStateWithLifecycle()
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
var screen by rememberSaveable { mutableStateOf(Screen.HOME) }
|
var screen by rememberSaveable { mutableStateOf(Screen.HOME) }
|
||||||
|
|
||||||
|
// Reflect the custom app name in the OS: the label shown in the Recents/task
|
||||||
|
// switcher updates at runtime (the launcher icon label stays fixed by Android).
|
||||||
|
val context = LocalContext.current
|
||||||
|
val appName = state.appDisplayName()
|
||||||
|
LaunchedEffect(appName) {
|
||||||
|
context.findActivity()?.setTaskDescription(
|
||||||
|
ActivityManager.TaskDescription(appName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Refresh when returning to the foreground...
|
// Refresh when returning to the foreground...
|
||||||
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { vm.refresh() }
|
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { vm.refresh() }
|
||||||
// ...and poll while composed; retry faster while disconnected so the warning
|
// ...and poll while composed; retry faster while disconnected so the warning
|
||||||
@@ -55,7 +77,6 @@ fun AppRoot() {
|
|||||||
state = state,
|
state = state,
|
||||||
onTogglePower = vm::togglePower,
|
onTogglePower = vm::togglePower,
|
||||||
onRunMacro = vm::runMacro,
|
onRunMacro = vm::runMacro,
|
||||||
onRefresh = vm::refresh,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.adminUnlockGesture { screen = Screen.ADMIN },
|
.adminUnlockGesture { screen = Screen.ADMIN },
|
||||||
@@ -63,6 +84,8 @@ fun AppRoot() {
|
|||||||
Screen.ADMIN -> AdminScreen(
|
Screen.ADMIN -> AdminScreen(
|
||||||
state = state,
|
state = state,
|
||||||
onSelectDevice = vm::selectDevice,
|
onSelectDevice = vm::selectDevice,
|
||||||
|
onRefreshDevices = vm::refreshDevices,
|
||||||
|
onSetAppName = vm::setAppName,
|
||||||
onToggleRemaining = vm::setShowRemainingTime,
|
onToggleRemaining = vm::setShowRemainingTime,
|
||||||
onToggleReservation = vm::setShowReservation,
|
onToggleReservation = vm::setShowReservation,
|
||||||
onTogglePowerControls = vm::setShowPowerControls,
|
onTogglePowerControls = vm::setShowPowerControls,
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ 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.material.icons.filled.Refresh
|
|
||||||
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 +22,6 @@ import androidx.compose.material3.CardDefaults
|
|||||||
import androidx.compose.material3.CircularProgressIndicator
|
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.OutlinedButton
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -34,6 +32,7 @@ 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 kr.tkrmagid.easyappliance.vm.HomeUiState
|
import kr.tkrmagid.easyappliance.vm.HomeUiState
|
||||||
|
import kr.tkrmagid.easyappliance.vm.appDisplayName
|
||||||
import kr.tkrmagid.easyappliance.vm.displayLabelFor
|
import kr.tkrmagid.easyappliance.vm.displayLabelFor
|
||||||
import kr.tkrmagid.easyappliance.vm.imageRefFor
|
import kr.tkrmagid.easyappliance.vm.imageRefFor
|
||||||
import kr.tkrmagid.easyappliance.vm.macroFor
|
import kr.tkrmagid.easyappliance.vm.macroFor
|
||||||
@@ -45,7 +44,6 @@ fun HomeScreen(
|
|||||||
state: HomeUiState,
|
state: HomeUiState,
|
||||||
onTogglePower: (Boolean) -> Unit,
|
onTogglePower: (Boolean) -> Unit,
|
||||||
onRunMacro: () -> Unit,
|
onRunMacro: () -> Unit,
|
||||||
onRefresh: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||||
@@ -71,6 +69,13 @@ fun HomeScreen(
|
|||||||
modifier = Modifier.widthIn(max = contentMaxWidth).fillMaxWidth(),
|
modifier = Modifier.widthIn(max = contentMaxWidth).fillMaxWidth(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
|
Text(
|
||||||
|
text = state.appDisplayName(),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
DeviceImage(
|
DeviceImage(
|
||||||
imageRef = state.imageRefFor(device),
|
imageRef = state.imageRefFor(device),
|
||||||
modifier = Modifier.size(if (narrow) 96.dp else 128.dp),
|
modifier = Modifier.size(if (narrow) 96.dp else 128.dp),
|
||||||
@@ -102,15 +107,6 @@ fun HomeScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,14 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
viewModelScope.launch { loadStatus(id) }
|
viewModelScope.launch { loadStatus(id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reload the device list from the repository (admin "목록 새로고침"). */
|
||||||
|
fun refreshDevices() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
loadDevices()
|
||||||
|
resolveSelectionAndStatus(_state.value.settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** "다시 연결하기" — clear preview flag and re-attempt list + status. */
|
/** "다시 연결하기" — clear preview flag and re-attempt list + status. */
|
||||||
fun retryConnection() {
|
fun retryConnection() {
|
||||||
forcedWarning = false
|
forcedWarning = false
|
||||||
@@ -170,6 +178,7 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
viewModelScope.launch { settingsRepo.setDeviceMacro(deviceId, macro) }
|
viewModelScope.launch { settingsRepo.setDeviceMacro(deviceId, macro) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setAppName(name: String) { viewModelScope.launch { settingsRepo.setAppName(name) } }
|
||||||
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) } }
|
||||||
@@ -188,3 +197,8 @@ 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]
|
||||||
|
|
||||||
|
const val DEFAULT_APP_NAME = "쉬운 가전 리모컨"
|
||||||
|
|
||||||
|
/** App-level display name shown as the home header. */
|
||||||
|
fun HomeUiState.appDisplayName(): String = settings.appName ?: DEFAULT_APP_NAME
|
||||||
|
|||||||
Reference in New Issue
Block a user