Port manage/settings flows to Compose and wire settings persistence.

Add contract-driven state/action/event view models for manage and settings, migrate settings UI toward legacy preference rows (with anonymous stub behavior), and back SettingsServiceImpl with SharedPreferences for real toggle/city storage.

Made-with: Cursor
This commit is contained in:
2026-03-01 15:00:34 +01:00
parent c155a3cc2e
commit 1823d0bf1b
13 changed files with 936 additions and 35 deletions

View File

@@ -1,8 +1,47 @@
package org.db3.airmq.features.manage
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import org.db3.airmq.features.common.MockScreenScaffold
import org.db3.airmq.features.common.ScreenAction
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.flow.collectLatest
import org.db3.airmq.features.common.AirMqButton
import org.db3.airmq.features.common.AirMqButtonStyle
import org.db3.airmq.features.manage.ManageScreenContract.Action
import org.db3.airmq.features.manage.ManageScreenContract.DeviceItem
import org.db3.airmq.features.manage.ManageScreenContract.Event
import org.db3.airmq.features.manage.ManageScreenContract.State
import org.db3.airmq.features.manage.ManageScreenContract.UserMode
import org.db3.airmq.ui.theme.AirMQTheme
import org.db3.airmq.ui.theme.LegacyNavGradientEnd
import org.db3.airmq.ui.theme.LegacyNavGradientStart
@Composable
fun ManageScreen(
@@ -12,19 +51,276 @@ fun ManageScreen(
onOpenLogin: () -> Unit,
onOpenLocation: () -> Unit,
onOpenWidgetConstructor: () -> Unit,
onBackToDashboard: () -> Unit
onBackToDashboard: () -> Unit,
viewModel: ManageViewModel = hiltViewModel()
) {
MockScreenScaffold(
title = "Manage",
subtitle = "Bottom-tab equivalent: manage",
actions = listOf(
ScreenAction("Open Device", onOpenDevice),
ScreenAction("Start Setup", onOpenSetup),
ScreenAction("Open Settings", onOpenSettings),
ScreenAction("Open Login", onOpenLogin),
ScreenAction("Select Location", onOpenLocation),
ScreenAction("Open Widget Constructor", onOpenWidgetConstructor),
ScreenAction("Back to Dashboard", onBackToDashboard)
)
val uiState by viewModel.uiState.collectAsState()
LaunchedEffect(viewModel) {
viewModel.actions.collectLatest { action ->
when (action) {
Action.OpenLogin -> onOpenLogin()
Action.OpenSettings -> onOpenSettings()
Action.OpenSetup -> onOpenSetup()
is Action.OpenDevice -> onOpenDevice()
is Action.OpenLocation -> onOpenLocation()
}
}
}
ManageScreenContent(
uiState = uiState,
onEvent = viewModel::onEvent,
onOpenWidgetConstructor = onOpenWidgetConstructor,
onBackToDashboard = onBackToDashboard
)
}
@Composable
private fun ManageScreenContent(
uiState: State,
onEvent: (Event) -> Unit,
onOpenWidgetConstructor: () -> Unit,
onBackToDashboard: () -> Unit
) {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
ProfileHeader(
name = uiState.userName,
email = uiState.userEmail,
onSettingsClick = { onEvent(Event.SettingsClicked) }
)
when (uiState.userMode) {
UserMode.ANONYMOUS -> AnonymousContent(
devicesLabel = uiState.devicesLabel,
onSignIn = { onEvent(Event.SignInClicked) }
)
UserMode.AUTHORIZED -> AuthorizedContent(
devicesLabel = uiState.devicesLabel,
devices = uiState.devices,
onOpenSetup = { onEvent(Event.SetupClicked) },
onOpenDevice = { onEvent(Event.DeviceClicked(it)) },
onOpenLocation = { onEvent(Event.DeviceLocationClicked(it)) }
)
}
Spacer(modifier = Modifier.height(8.dp))
AirMqButton(
text = "Open Widget Constructor",
onClick = onOpenWidgetConstructor,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
)
AirMqButton(
text = "Back to Dashboard",
onClick = onBackToDashboard,
style = AirMqButtonStyle.Outlined,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
}
}
@Composable
private fun ProfileHeader(
name: String,
email: String,
onSettingsClick: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(
brush = Brush.verticalGradient(
colors = listOf(LegacyNavGradientEnd, LegacyNavGradientStart)
)
)
.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Manage",
style = MaterialTheme.typography.headlineSmall,
color = Color.White
)
AirMqButton(
text = "Settings",
onClick = onSettingsClick,
style = AirMqButtonStyle.Text
)
}
Spacer(modifier = Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.background(color = Color.White.copy(alpha = 0.25f), shape = CircleShape)
.padding(horizontal = 14.dp, vertical = 10.dp)
) {
Text(
text = name.firstOrNull()?.uppercase() ?: "A",
color = Color.White,
style = MaterialTheme.typography.titleMedium
)
}
Spacer(modifier = Modifier.padding(horizontal = 8.dp))
Column {
Text(
text = name,
color = Color.White,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold
)
Text(
text = email,
color = Color.White.copy(alpha = 0.8f),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
@Composable
private fun AnonymousContent(
devicesLabel: String,
onSignIn: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = devicesLabel,
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(16.dp))
AirMqButton(
text = "Sign in",
onClick = onSignIn,
style = AirMqButtonStyle.Gradient,
modifier = Modifier.fillMaxWidth()
)
}
}
@Composable
private fun AuthorizedContent(
devicesLabel: String,
devices: List<DeviceItem>,
onOpenSetup: () -> Unit,
onOpenDevice: (String) -> Unit,
onOpenLocation: (String) -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(text = devicesLabel, style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(12.dp))
AirMqButton(
text = "Setup",
onClick = onOpenSetup,
style = AirMqButtonStyle.Gradient,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(devices, key = { it.id }) { device ->
DeviceRow(
item = device,
onOpenDevice = { onOpenDevice(device.id) },
onOpenLocation = { onOpenLocation(device.id) }
)
}
}
}
}
@Composable
private fun DeviceRow(
item: DeviceItem,
onOpenDevice: () -> Unit,
onOpenLocation: () -> Unit
) {
Card(
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = Color.White),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
) {
Text(text = item.name, style = MaterialTheme.typography.titleMedium)
Text(
text = item.status,
style = MaterialTheme.typography.bodyMedium,
color = Color.Gray
)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AirMqButton(
text = "Open",
onClick = onOpenDevice,
style = AirMqButtonStyle.Contained,
modifier = Modifier.weight(1f)
)
AirMqButton(
text = if (item.hasLocation) "Show on map" else "Set location",
onClick = onOpenLocation,
style = AirMqButtonStyle.Outlined,
modifier = Modifier.weight(1f)
)
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun ManageScreenAnonymousPreview() {
AirMQTheme {
ManageScreenContent(
uiState = State(
userMode = UserMode.ANONYMOUS
),
onEvent = {},
onOpenWidgetConstructor = {},
onBackToDashboard = {}
)
}
}
@Preview(showBackground = true)
@Composable
private fun ManageScreenAuthorizedPreview() {
AirMQTheme {
ManageScreenContent(
uiState = State(
userMode = UserMode.AUTHORIZED,
userName = "Anton Betsun",
userEmail = "messbees@gmail.com",
devicesLabel = "My devices",
devices = listOf(
DeviceItem("1", "AirMQ #1", "Online", true),
DeviceItem("2", "AirMQ #2", "Offline", false)
)
),
onEvent = {},
onOpenWidgetConstructor = {},
onBackToDashboard = {}
)
}
}

View File

@@ -0,0 +1,40 @@
package org.db3.airmq.features.manage
object ManageScreenContract {
enum class UserMode {
ANONYMOUS,
AUTHORIZED
}
data class DeviceItem(
val id: String,
val name: String,
val status: String,
val hasLocation: Boolean
)
data class State(
val userMode: UserMode = UserMode.ANONYMOUS,
val userName: String = "Anonymous",
val userEmail: String = "Please sign in to access your devices.",
val devicesLabel: String = "Sign in to see your devices",
val devices: List<DeviceItem> = emptyList()
)
sealed interface Action {
data object OpenSettings : Action
data object OpenSetup : Action
data object OpenLogin : Action
data class OpenDevice(val deviceId: String) : Action
data class OpenLocation(val deviceId: String) : Action
}
sealed interface Event {
data object SettingsClicked : Event
data object SetupClicked : Event
data object SignInClicked : Event
data class DeviceClicked(val deviceId: String) : Event
data class DeviceLocationClicked(val deviceId: String) : Event
}
}

View File

@@ -0,0 +1,72 @@
package org.db3.airmq.features.manage
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import org.db3.airmq.features.manage.ManageScreenContract.Action
import org.db3.airmq.features.manage.ManageScreenContract.DeviceItem
import org.db3.airmq.features.manage.ManageScreenContract.Event
import org.db3.airmq.features.manage.ManageScreenContract.State
import org.db3.airmq.features.manage.ManageScreenContract.UserMode
@HiltViewModel
class ManageViewModel @Inject constructor() : ViewModel() {
// Temporary migration stub: keep screen in anonymous mode.
private val forceAnonymous = true
private val _uiState = MutableStateFlow(initialState())
val uiState: StateFlow<State> = _uiState.asStateFlow()
private val _actions = MutableSharedFlow<Action>(extraBufferCapacity = 1)
val actions: SharedFlow<Action> = _actions.asSharedFlow()
fun onEvent(event: Event) {
when (event) {
Event.SettingsClicked -> _actions.tryEmit(Action.OpenSettings)
Event.SetupClicked -> _actions.tryEmit(Action.OpenSetup)
Event.SignInClicked -> _actions.tryEmit(Action.OpenLogin)
is Event.DeviceClicked -> _actions.tryEmit(Action.OpenDevice(event.deviceId))
is Event.DeviceLocationClicked -> _actions.tryEmit(Action.OpenLocation(event.deviceId))
}
}
private fun initialState(): State {
val mode = if (forceAnonymous) {
UserMode.ANONYMOUS
} else {
UserMode.AUTHORIZED
}
return when (mode) {
UserMode.ANONYMOUS -> State(
userMode = UserMode.ANONYMOUS
)
UserMode.AUTHORIZED -> State(
userMode = UserMode.AUTHORIZED,
userName = "Anton Betsun",
userEmail = "messbees@gmail.com",
devicesLabel = "My devices",
devices = listOf(
DeviceItem(
id = "device-1",
name = "AirMQ #42",
status = "Online",
hasLocation = true
),
DeviceItem(
id = "device-2",
name = "AirMQ #17",
status = "Offline",
hasLocation = false
)
)
)
}
}
}

View File

@@ -1,22 +1,281 @@
package org.db3.airmq.features.settings
import android.widget.Toast
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.heightIn
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import org.db3.airmq.features.common.MockScreenScaffold
import org.db3.airmq.features.common.ScreenAction
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.flow.collectLatest
import org.db3.airmq.features.settings.SettingsScreenContract.Action
import org.db3.airmq.features.settings.SettingsScreenContract.Event
import org.db3.airmq.features.settings.SettingsScreenContract.State
import org.db3.airmq.features.settings.SettingsScreenContract.UserMode
import org.db3.airmq.ui.theme.AirMQTheme
@Composable
fun SettingsScreen(
onOpenDebug: () -> Unit,
onOpenCity: () -> Unit,
onLogOutToManage: () -> Unit
onLogOutToManage: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel()
) {
MockScreenScaffold(
title = "Settings",
subtitle = "Settings and account actions.",
actions = listOf(
ScreenAction("Open Debug", onOpenDebug),
ScreenAction("Open City", onOpenCity),
ScreenAction("Log Out to Manage", onLogOutToManage)
)
val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current
LaunchedEffect(viewModel) {
viewModel.actions.collectLatest { action ->
when (action) {
Action.OpenDebug -> onOpenDebug()
Action.LogOutToManage -> onLogOutToManage()
is Action.ShowMessage -> Toast.makeText(context, action.message, Toast.LENGTH_SHORT).show()
}
}
}
SettingsScreenContent(
uiState = uiState,
onEvent = viewModel::onEvent
)
}
@Composable
private fun SettingsScreenContent(
uiState: State,
onEvent: (Event) -> Unit
) {
val screenHorizontalPadding = 16.dp
val iconSize = 20.dp
val iconTextGap = 12.dp
val headerTextStart = iconSize + iconTextGap
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
if (uiState.isLoading) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator()
}
return@Scaffold
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(horizontal = screenHorizontalPadding, vertical = 12.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
Text(
text = "Application",
style = MaterialTheme.typography.labelLarge,
color = Color(0xFF607D8B),
modifier = Modifier.padding(start = headerTextStart, top = 4.dp, bottom = 4.dp)
)
PreferenceRow(
iconName = "ic_pref_city",
iconFallbackRes = android.R.drawable.ic_menu_myplaces,
title = "City in dashboard",
summary = uiState.city,
iconSize = iconSize,
iconTextGap = iconTextGap,
onClick = { onEvent(Event.CityClicked) }
)
PreferenceCheckRow(
iconName = "ic_pref_notifications",
iconFallbackRes = android.R.drawable.ic_dialog_info,
title = "Notify when device becomes offline",
checked = uiState.deviceStatusNotificationsEnabled,
iconSize = iconSize,
iconTextGap = iconTextGap,
onToggle = { onEvent(Event.DeviceStatusNotificationsChanged(it)) }
)
PreferenceCheckRow(
iconName = "ic_pref_offline_devices",
iconFallbackRes = android.R.drawable.ic_menu_mylocation,
title = "Show offline devices",
checked = uiState.offlineDevicesVisible,
iconSize = iconSize,
iconTextGap = iconTextGap,
onToggle = { onEvent(Event.OfflineDevicesVisibilityChanged(it)) }
)
PreferenceRow(
iconName = "ic_pref_info",
iconFallbackRes = android.R.drawable.ic_dialog_info,
title = "About",
iconSize = iconSize,
iconTextGap = iconTextGap,
onClick = { onEvent(Event.AboutClicked) }
)
if (uiState.userMode == UserMode.AUTHORIZED) {
PreferenceRow(
iconName = "ic_pref_debug",
iconFallbackRes = android.R.drawable.ic_lock_power_off,
title = "Log out",
iconSize = iconSize,
iconTextGap = iconTextGap,
onClick = { onEvent(Event.LogOutClicked) }
)
}
}
}
}
@Composable
private fun PreferenceRow(
iconName: String,
iconFallbackRes: Int,
title: String,
summary: String? = null,
iconSize: androidx.compose.ui.unit.Dp,
iconTextGap: androidx.compose.ui.unit.Dp,
onClick: () -> Unit
) {
val context = LocalContext.current
val iconRes = remember(iconName, iconFallbackRes) {
context.resources.getIdentifier(iconName, "drawable", context.packageName)
.takeIf { it != 0 } ?: iconFallbackRes
}
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 44.dp)
.clickable(onClick = onClick)
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(iconTextGap)
) {
Icon(
painter = painterResource(id = iconRes),
contentDescription = null,
modifier = Modifier.size(iconSize),
tint = Color(0x99333333)
)
Column {
Text(text = title, style = MaterialTheme.typography.bodyLarge, color = Color(0xFF222222))
if (summary != null) {
Text(
text = summary,
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF6B6B6B)
)
}
}
}
}
@Composable
private fun PreferenceCheckRow(
iconName: String,
iconFallbackRes: Int,
title: String,
checked: Boolean,
iconSize: androidx.compose.ui.unit.Dp,
iconTextGap: androidx.compose.ui.unit.Dp,
onToggle: (Boolean) -> Unit
) {
val context = LocalContext.current
val iconRes = remember(iconName, iconFallbackRes) {
context.resources.getIdentifier(iconName, "drawable", context.packageName)
.takeIf { it != 0 } ?: iconFallbackRes
}
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 44.dp)
.clickable { onToggle(!checked) }
.padding(vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = iconRes),
contentDescription = null,
modifier = Modifier.size(iconSize),
tint = Color(0x99333333)
)
Spacer(modifier = Modifier.size(iconTextGap))
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = Color(0xFF222222),
modifier = Modifier.weight(1f)
)
Checkbox(
checked = checked,
onCheckedChange = onToggle,
colors = CheckboxDefaults.colors(
checkedColor = Color(0xFF2F6FA8),
uncheckedColor = Color(0xFF8C8C8C),
checkmarkColor = Color.White
)
)
}
}
@Preview(name = "Settings Anonymous", showBackground = true, showSystemUi = true)
@Composable
private fun SettingsScreenAnonymousPreview() {
AirMQTheme {
SettingsScreenContent(
uiState = State(
userMode = UserMode.ANONYMOUS,
city = "Minsk",
deviceStatusNotificationsEnabled = true,
offlineDevicesVisible = false,
advancedEnabled = false,
isLoading = false
),
onEvent = {}
)
}
}
@Preview(name = "Settings Advanced", showBackground = true, showSystemUi = true)
@Composable
private fun SettingsScreenAdvancedPreview() {
AirMQTheme {
SettingsScreenContent(
uiState = State(
userMode = UserMode.ANONYMOUS,
city = "Minsk",
deviceStatusNotificationsEnabled = true,
offlineDevicesVisible = true,
advancedEnabled = true,
isLoading = false
),
onEvent = {}
)
}
}

View File

@@ -0,0 +1,34 @@
package org.db3.airmq.features.settings
object SettingsScreenContract {
enum class UserMode {
ANONYMOUS,
AUTHORIZED
}
data class State(
val userMode: UserMode = UserMode.ANONYMOUS,
val city: String = "Minsk",
val deviceStatusNotificationsEnabled: Boolean = true,
val offlineDevicesVisible: Boolean = false,
val advancedEnabled: Boolean = false,
val isLoading: Boolean = true
)
sealed interface Action {
data object OpenDebug : Action
data object LogOutToManage : Action
data class ShowMessage(val message: String) : Action
}
sealed interface Event {
data object CityClicked : Event
data object AboutClicked : Event
data object DebugClicked : Event
data object LogOutClicked : Event
data class DeviceStatusNotificationsChanged(val enabled: Boolean) : Event
data class OfflineDevicesVisibilityChanged(val enabled: Boolean) : Event
data class AdvancedChanged(val enabled: Boolean) : Event
}
}

View File

@@ -0,0 +1,99 @@
package org.db3.airmq.features.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.db3.airmq.features.settings.SettingsScreenContract.Action
import org.db3.airmq.features.settings.SettingsScreenContract.Event
import org.db3.airmq.features.settings.SettingsScreenContract.State
import org.db3.airmq.features.settings.SettingsScreenContract.UserMode
import org.db3.airmq.sdk.settings.SettingsService
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val settingsService: SettingsService
) : ViewModel() {
// Temporary migration stub: keep screen in anonymous mode.
private val forceAnonymous = true
private val _uiState = MutableStateFlow(State())
val uiState: StateFlow<State> = _uiState.asStateFlow()
private val _actions = MutableSharedFlow<Action>(extraBufferCapacity = 1)
val actions: SharedFlow<Action> = _actions.asSharedFlow()
init {
loadSettings()
}
fun onEvent(event: Event) {
when (event) {
Event.CityClicked -> _actions.tryEmit(Action.ShowMessage("Coming Soon"))
Event.AboutClicked -> _actions.tryEmit(Action.ShowMessage("Coming Soon"))
Event.DebugClicked -> _actions.tryEmit(Action.OpenDebug)
Event.LogOutClicked -> _actions.tryEmit(Action.LogOutToManage)
is Event.DeviceStatusNotificationsChanged -> {
updateToggle(
toggle = { settingsService.setDeviceStatusNotificationsEnabled(event.enabled) },
updateState = { copy(deviceStatusNotificationsEnabled = event.enabled) }
)
}
is Event.OfflineDevicesVisibilityChanged -> {
updateToggle(
toggle = { settingsService.setOfflineDevicesVisible(event.enabled) },
updateState = { copy(offlineDevicesVisible = event.enabled) }
)
}
is Event.AdvancedChanged -> {
updateToggle(
toggle = { settingsService.setAdvancedEnabled(event.enabled) },
updateState = { copy(advancedEnabled = event.enabled) }
)
}
}
}
private fun loadSettings() {
viewModelScope.launch(Dispatchers.IO) {
val city = settingsService.getCity() ?: "Minsk"
val deviceStatus = settingsService.getDeviceStatusNotificationsEnabled()
val offlineVisible = settingsService.getOfflineDevicesVisible()
val advanced = settingsService.getAdvancedEnabled()
_uiState.value = _uiState.value.copy(
userMode = if (forceAnonymous) UserMode.ANONYMOUS else UserMode.AUTHORIZED,
city = city,
deviceStatusNotificationsEnabled = deviceStatus,
offlineDevicesVisible = offlineVisible,
advancedEnabled = advanced,
isLoading = false
)
}
}
private fun updateToggle(
toggle: suspend () -> Result<Unit>,
updateState: State.() -> State
) {
viewModelScope.launch(Dispatchers.IO) {
val previous = _uiState.value
_uiState.value = previous.updateState()
val result = toggle()
if (result.isFailure) {
_uiState.value = previous
_actions.tryEmit(
Action.ShowMessage(result.exceptionOrNull()?.message ?: "Failed to save settings")
)
}
}
}
}

View File

@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#333333"
android:alpha="0.6">
<path
android:fillColor="@android:color/white"
android:pathData="M12.09,2.91C10.08,0.9 7.07,0.49 4.65,1.67L8.28,5.3c0.39,0.39 0.39,1.02 0,1.41L6.69,8.3c-0.39,0.4 -1.02,0.4 -1.41,0L1.65,4.67C0.48,7.1 0.89,10.09 2.9,12.1c1.86,1.86 4.58,2.35 6.89,1.48l7.96,7.96c1.03,1.03 2.69,1.03 3.71,0 1.03,-1.03 1.03,-2.69 0,-3.71L13.54,9.9c0.92,-2.34 0.44,-5.1 -1.45,-6.99z"/>
</vector>

View File

@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#333333"
android:alpha="0.6"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M15,11L15,5.83c0,-0.53 -0.21,-1.04 -0.59,-1.41L12.7,2.71c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.7,1.7C9.21,4.79 9,5.3 9,5.83L9,7L5,7c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2v-6c0,-1.1 -0.9,-2 -2,-2h-4zM7,19L5,19v-2h2v2zM7,15L5,15v-2h2v2zM7,11L5,11L5,9h2v2zM13,19h-2v-2h2v2zM13,15h-2v-2h2v2zM13,11h-2L11,9h2v2zM13,7h-2L11,5h2v2zM19,19h-2v-2h2v2zM19,15h-2v-2h2v2z" />
</vector>

View File

@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#333333"
android:alpha="0.6">
<path
android:fillColor="@android:color/white"
android:pathData="M19,8h-1.81c-0.45,-0.78 -1.07,-1.45 -1.82,-1.96l0.93,-0.93c0.39,-0.39 0.39,-1.02 0,-1.41 -0.39,-0.39 -1.02,-0.39 -1.41,0l-1.47,1.47C12.96,5.06 12.49,5 12,5s-0.96,0.06 -1.41,0.17L9.11,3.7c-0.39,-0.39 -1.02,-0.39 -1.41,0 -0.39,0.39 -0.39,1.02 0,1.41l0.92,0.93C7.88,6.55 7.26,7.22 6.81,8L5,8c-0.55,0 -1,0.45 -1,1s0.45,1 1,1h1.09c-0.05,0.33 -0.09,0.66 -0.09,1v1L5,12c-0.55,0 -1,0.45 -1,1s0.45,1 1,1h1v1c0,0.34 0.04,0.67 0.09,1L5,16c-0.55,0 -1,0.45 -1,1s0.45,1 1,1h1.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3L19,18c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1h-1.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h1c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1h-1v-1c0,-0.34 -0.04,-0.67 -0.09,-1L19,10c0.55,0 1,-0.45 1,-1s-0.45,-1 -1,-1zM13,16h-2c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1h2c0.55,0 1,0.45 1,1s-0.45,1 -1,1zM13,12h-2c-0.55,0 -1,-0.45 -1,-1s0.45,-1 1,-1h2c0.55,0 1,0.45 1,1s-0.45,1 -1,1z"
tools:ignore="VectorPath" />
</vector>

View File

@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#333333"
android:alpha="0.6">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,17c-0.55,0 -1,-0.45 -1,-1v-4c0,-0.55 0.45,-1 1,-1s1,0.45 1,1v4c0,0.55 -0.45,1 -1,1zM13,9h-2L11,7h2v2z"/>
</vector>

View File

@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="#333333"
android:alpha="0.6"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.89,2 2,2zM18,16v-5c0,-3.07 -1.64,-5.64 -4.5,-6.32L13.5,4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-1.29,1.29c-0.63,0.63 -0.19,1.71 0.7,1.71h13.17c0.89,0 1.34,-1.08 0.71,-1.71L18,16z" />
</vector>

View File

@@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#333333"
android:alpha="0.6">
<path
android:fillColor="@android:color/white"
android:pathData="M14,10V3.26C13.35,3.09 12.68,3 12,3c-4.2,0 -8,3.22 -8,8.2c0,3.18 2.45,6.92 7.34,11.23c0.38,0.33 0.95,0.33 1.33,0C17.55,18.12 20,14.38 20,11.2c0,-0.41 -0.04,-0.81 -0.09,-1.2H14zM12,13c-1.1,0 -2,-0.9 -2,-2c0,-1.1 0.9,-2 2,-2s2,0.9 2,2C14,12.1 13.1,13 12,13z"/>
<path
android:fillColor="@android:color/white"
android:pathData="M22.54,2.88l-1.42,-1.42l-2.12,2.13l-2.12,-2.13l-1.42,1.42l2.13,2.12l-2.13,2.12l1.42,1.42l2.12,-2.13l2.12,2.13l1.42,-1.42l-2.13,-2.12z"/>
</vector>

View File

@@ -1,25 +1,55 @@
package org.db3.airmq.sdk.settings
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
class SettingsServiceImpl @Inject constructor() : SettingsService {
override suspend fun getCity(): String? = throw NotImplementedError("getCity is not implemented yet")
class SettingsServiceImpl @Inject constructor(
@ApplicationContext context: Context
) : SettingsService {
private val sharedPreferences =
context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
override suspend fun setCity(city: String?): Result<Unit> = throw NotImplementedError("setCity is not implemented yet")
override suspend fun getCity(): String? = sharedPreferences.getString(KEY_CITY, DEFAULT_CITY)
override suspend fun getOfflineDevicesVisible(): Boolean = false
override suspend fun setCity(city: String?): Result<Unit> = runCatching {
sharedPreferences.edit().putString(KEY_CITY, city ?: DEFAULT_CITY).apply()
}
override suspend fun getOfflineDevicesVisible(): Boolean =
sharedPreferences.getBoolean(KEY_OFFLINE_DEVICES, DEFAULT_OFFLINE_DEVICES)
override suspend fun setOfflineDevicesVisible(visible: Boolean): Result<Unit> =
throw NotImplementedError("setOfflineDevicesVisible is not implemented yet")
runCatching {
sharedPreferences.edit().putBoolean(KEY_OFFLINE_DEVICES, visible).apply()
}
override suspend fun getAdvancedEnabled(): Boolean = throw NotImplementedError("getAdvancedEnabled is not implemented yet")
override suspend fun getAdvancedEnabled(): Boolean =
sharedPreferences.getBoolean(KEY_SHOW_ADVANCED, DEFAULT_SHOW_ADVANCED)
override suspend fun setAdvancedEnabled(enabled: Boolean): Result<Unit> =
throw NotImplementedError("setAdvancedEnabled is not implemented yet")
runCatching {
sharedPreferences.edit().putBoolean(KEY_SHOW_ADVANCED, enabled).apply()
}
override suspend fun getDeviceStatusNotificationsEnabled(): Boolean =
throw NotImplementedError("getDeviceStatusNotificationsEnabled is not implemented yet")
sharedPreferences.getBoolean(KEY_DEVICE_STATUS_NOTIFICATIONS, DEFAULT_DEVICE_STATUS_NOTIFICATIONS)
override suspend fun setDeviceStatusNotificationsEnabled(enabled: Boolean): Result<Unit> =
throw NotImplementedError("setDeviceStatusNotificationsEnabled is not implemented yet")
runCatching {
sharedPreferences.edit().putBoolean(KEY_DEVICE_STATUS_NOTIFICATIONS, enabled).apply()
}
private companion object {
private const val PREFERENCES_NAME = "airmq_settings"
private const val KEY_CITY = "dashboard_city"
private const val KEY_OFFLINE_DEVICES = "offline_devices"
private const val KEY_SHOW_ADVANCED = "show_advanced"
private const val KEY_DEVICE_STATUS_NOTIFICATIONS = "device_status"
private const val DEFAULT_CITY = "Minsk"
private const val DEFAULT_OFFLINE_DEVICES = false
private const val DEFAULT_SHOW_ADVANCED = false
private const val DEFAULT_DEVICE_STATUS_NOTIFICATIONS = true
}
}