diff --git a/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreen.kt b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreen.kt index 96e209f..97ce5fa 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreen.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreen.kt @@ -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, + 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 = {} + ) + } +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreenContract.kt b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreenContract.kt new file mode 100644 index 0000000..8a052fb --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreenContract.kt @@ -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 = 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 + } +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/manage/ManageViewModel.kt b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageViewModel.kt new file mode 100644 index 0000000..73220ba --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageViewModel.kt @@ -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 = _uiState.asStateFlow() + + private val _actions = MutableSharedFlow(extraBufferCapacity = 1) + val actions: SharedFlow = _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 + ) + ) + ) + } + } +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/settings/SettingsScreen.kt b/app/src/main/kotlin/org/db3/airmq/features/settings/SettingsScreen.kt index ca7f438..46e968b 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/settings/SettingsScreen.kt @@ -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 = {} + ) + } +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/settings/SettingsScreenContract.kt b/app/src/main/kotlin/org/db3/airmq/features/settings/SettingsScreenContract.kt new file mode 100644 index 0000000..0ae087e --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/settings/SettingsScreenContract.kt @@ -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 + } +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/settings/SettingsViewModel.kt b/app/src/main/kotlin/org/db3/airmq/features/settings/SettingsViewModel.kt new file mode 100644 index 0000000..1c5e028 --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/settings/SettingsViewModel.kt @@ -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 = _uiState.asStateFlow() + + private val _actions = MutableSharedFlow(extraBufferCapacity = 1) + val actions: SharedFlow = _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, + 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") + ) + } + } + } +} diff --git a/app/src/main/res/drawable/ic_pref_advanced.xml b/app/src/main/res/drawable/ic_pref_advanced.xml new file mode 100644 index 0000000..45e16d5 --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_advanced.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_pref_city.xml b/app/src/main/res/drawable/ic_pref_city.xml new file mode 100644 index 0000000..22d617a --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_city.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_pref_debug.xml b/app/src/main/res/drawable/ic_pref_debug.xml new file mode 100644 index 0000000..c493c88 --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_debug.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_pref_info.xml b/app/src/main/res/drawable/ic_pref_info.xml new file mode 100644 index 0000000..f0a3543 --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_info.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_pref_notifications.xml b/app/src/main/res/drawable/ic_pref_notifications.xml new file mode 100644 index 0000000..c6cdc39 --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_notifications.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_pref_offline_devices.xml b/app/src/main/res/drawable/ic_pref_offline_devices.xml new file mode 100644 index 0000000..b52e959 --- /dev/null +++ b/app/src/main/res/drawable/ic_pref_offline_devices.xml @@ -0,0 +1,14 @@ + + + + diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/settings/SettingsServiceImpl.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/settings/SettingsServiceImpl.kt index 5f5a536..e9b8b48 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/settings/SettingsServiceImpl.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/settings/SettingsServiceImpl.kt @@ -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 = 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 = 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 = - 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 = - 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 = - 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 + } }