diff --git a/.cursor/rules/app-recreation-core.mdc b/.cursor/rules/app-recreation-core.mdc index 5250a45..eb4d547 100644 --- a/.cursor/rules/app-recreation-core.mdc +++ b/.cursor/rules/app-recreation-core.mdc @@ -45,3 +45,14 @@ Use this stack as the default foundation for all implementation work in `airmq-a 5. Apollo GraphQL for GraphQL schema integration, client generation, and network operations. If a requested change conflicts with this baseline, ask for explicit approval before introducing an alternative. + +## Temporary or implicit implementations + +When implementing something implicitly or obviously temporary, document it in `temp.md` at the project root with all important details: + +- What was implemented +- Why it is temporary or implicit +- Location (files, components, modules) +- Any follow-up work or conditions for proper replacement + +Create `temp.md` if it does not exist. diff --git a/.idea/markdown.xml b/.idea/markdown.xml new file mode 100644 index 0000000..c61ea33 --- /dev/null +++ b/.idea/markdown.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/kotlin/org/db3/airmq/features/device/DeviceSettingsScreen.kt b/app/src/main/kotlin/org/db3/airmq/features/device/DeviceSettingsScreen.kt index 340f46e..faa17a6 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/device/DeviceSettingsScreen.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/device/DeviceSettingsScreen.kt @@ -1,25 +1,33 @@ package org.db3.airmq.features.device +import android.R as AndroidR +import android.content.Intent +import android.net.Uri import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.statusBarsPadding 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.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Switch @@ -27,24 +35,44 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -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.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import kotlinx.coroutines.flow.collectLatest import org.db3.airmq.R +import org.db3.airmq.sdk.device.domain.DeviceModel import org.db3.airmq.features.common.AirMQButton +import org.db3.airmq.ui.theme.AirMQTheme import org.db3.airmq.features.common.AirMQButtonStyle -import org.db3.airmq.sdk.device.domain.OnlineStatus import org.db3.airmq.ui.theme.LegacyBackground +import org.db3.airmq.ui.theme.LegacyBlack12 +import androidx.core.net.toUri + +private const val INFO_URL = "https://docs.google.com/document/d/1JC41nHE5foYhS6beDFTA5a8dQqX3IQoqHJYZbPRtmuM/edit" + +private fun roundLocation(d: Double): String = + "%.5f".format(d).replace(',', '.') + +private fun DeviceModel.iconRes(): Int = when (this) { + DeviceModel.Basic -> R.drawable.ic_device_basic_active_10 + DeviceModel.Mobile -> R.drawable.ic_device_mobile_active_10 + DeviceModel.Solar -> R.drawable.ic_device_solar_active_10 + DeviceModel.Radiation -> R.drawable.ic_device_radiation_active_10 + DeviceModel.Custom -> R.drawable.ic_device_custom_active_10 +} @Composable fun DeviceSettingsScreen( @@ -52,11 +80,15 @@ fun DeviceSettingsScreen( onOpenLocation: () -> Unit, onShowOnMap: () -> Unit, onNavigateBack: () -> Unit, - viewModel: DeviceSettingsViewModel = hiltViewModel() + viewModel: DeviceSettingsViewModel = hiltViewModel( + creationCallback = { factory: DeviceSettingsViewModel.Factory -> + factory.create(deviceId) + } + ) ) { val uiState by viewModel.uiState.collectAsState() val snackbarHostState = remember { SnackbarHostState() } - val toastDoneText = stringResource(R.string.toast_done) + val context = LocalContext.current LaunchedEffect(viewModel) { viewModel.actions.collectLatest { action -> @@ -64,21 +96,23 @@ fun DeviceSettingsScreen( is DeviceSettingsScreenContract.Action.ShowError -> { snackbarHostState.showSnackbar(action.message) } - is DeviceSettingsScreenContract.Action.ShowSuccess -> { - snackbarHostState.showSnackbar(toastDoneText) - } + is DeviceSettingsScreenContract.Action.ShowSuccess -> { /* no snackbar for success */ } is DeviceSettingsScreenContract.Action.OpenLocation -> onOpenLocation() is DeviceSettingsScreenContract.Action.ShowOnMap -> onShowOnMap() + is DeviceSettingsScreenContract.Action.OpenInfoUrl -> { + context.startActivity(Intent(Intent.ACTION_VIEW, INFO_URL.toUri())) + } } } } Scaffold( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxWidth(), topBar = { Row( modifier = Modifier .fillMaxWidth() + .statusBarsPadding() .background(LegacyBackground) .padding(16.dp), verticalAlignment = Alignment.CenterVertically @@ -90,26 +124,42 @@ fun DeviceSettingsScreen( ) } Text( - text = stringResource(R.string.title_device), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(start = 8.dp) + text = stringResource(R.string.title_device_config), + style = MaterialTheme.typography.titleLarge.copy( + fontSize = 24.sp, + fontWeight = FontWeight.Medium + ), + modifier = Modifier + .weight(1f) + .padding(start = 8.dp), + textAlign = androidx.compose.ui.text.style.TextAlign.Center ) + Spacer(modifier = Modifier.size(48.dp)) } }, - snackbarHost = { SnackbarHost(snackbarHostState) } + snackbarHost = { + SnackbarHost( + hostState = snackbarHostState, + snackbar = { data -> + Snackbar( + snackbarData = data, + containerColor = MaterialTheme.colorScheme.inverseSurface, + contentColor = MaterialTheme.colorScheme.inverseOnSurface + ) + } + ) + } ) { innerPadding -> Box( modifier = Modifier - .fillMaxSize() + .fillMaxWidth() .padding(innerPadding) ) { when { - uiState.isLoading -> { - CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center) - ) + uiState.device == null && uiState.isLoading -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) } - uiState.device == null -> { + uiState.device == null && !uiState.isLoading -> { Text( text = stringResource(R.string.text_nothing_to_show), modifier = Modifier @@ -118,26 +168,48 @@ fun DeviceSettingsScreen( ) } else -> { - DeviceSettingsContent( - state = uiState, - onEvent = viewModel::onEvent, - deviceName = uiState.device?.name ?: "" - ) + Box(modifier = Modifier.fillMaxWidth()) { + DeviceSettingsContent( + state = uiState, + onEvent = viewModel::onEvent + ) + if (uiState.isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.3f)), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + } } } } } } +private val PreviewHeaderColor = Color(0xFF1F5DA5) +private val PreviewGreyColor = Color(0x61000000) +private val PreviewBlack54 = Color(0x8A000000) +private val PreviewOrange = Color(0xFFFF6F00) + @Composable private fun DeviceSettingsContent( state: DeviceSettingsScreenContract.State, onEvent: (DeviceSettingsScreenContract.Event) -> Unit, - deviceName: String + labelColorOverride: Color? = null, + greyColorOverride: Color? = null, + iconResOverride: Int? = null ) { val device = state.device!! var showRenameDialog by remember { mutableStateOf(false) } - var renameText by remember(deviceName) { mutableStateOf(deviceName) } + var showConfigComingSoonDialog by remember { mutableStateOf(false) } + var renameText by remember(device.name) { mutableStateOf(device.name) } + val headerColor = labelColorOverride ?: colorResource(R.color.headerColor) + val onSurfaceVariant = MaterialTheme.colorScheme.onSurfaceVariant + val greyColor = greyColorOverride ?: colorResource(R.color.black38) if (showRenameDialog) { AlertDialog( @@ -178,9 +250,23 @@ private fun DeviceSettingsContent( } ) } + + if (showConfigComingSoonDialog) { + AlertDialog( + onDismissRequest = { showConfigComingSoonDialog = false }, + title = { Text(stringResource(R.string.coming_soon)) }, + text = { Text(stringResource(R.string.coming_soon)) }, + confirmButton = { + androidx.compose.material3.TextButton(onClick = { showConfigComingSoonDialog = false }) { + Text(stringResource(R.string.button_ok)) + } + } + ) + } + val scrollState = rememberScrollState() - val statusIcon = when (device.toOnlineStatus()) { - OnlineStatus.Online -> R.drawable.device_chip_online + val statusIcon = when { + state.isConnected -> R.drawable.device_chip_online else -> R.drawable.device_chip_offline } @@ -189,115 +275,458 @@ private fun DeviceSettingsContent( .fillMaxWidth() .verticalScroll(scrollState) .background(LegacyBackground) - .padding(16.dp) ) { + HorizontalDivider(color = LegacyBlack12) Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .height(72.dp) + .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( - painter = painterResource(R.drawable.ic_chip), + painter = painterResource( + iconResOverride ?: device.model.iconRes() + ), contentDescription = null, - modifier = Modifier.size(48.dp) + modifier = Modifier.size(40.dp) ) Spacer(modifier = Modifier.size(16.dp)) Column(modifier = Modifier.weight(1f)) { Text( text = device.name, - style = MaterialTheme.typography.headlineSmall + style = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp) ) Text( - text = device.model, - style = MaterialTheme.typography.bodyMedium, - color = Color.Gray + text = device.model.displayName, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp), + color = greyColor ) } - Icon( - painter = painterResource(statusIcon), - contentDescription = device.toOnlineStatus().toString(), - modifier = Modifier.size(24.dp) - ) - } - - if (state.isPendingSync) { Text( - text = stringResource(R.string.text_pending_sync), - style = MaterialTheme.typography.bodySmall, - color = Color.Gray, - modifier = Modifier.padding(top = 8.dp) + text = if (state.isConnected) stringResource(R.string.text_device_connected) else "", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp), + color = greyColor ) + if (!state.isConnected) { + Icon( + painter = painterResource(statusIcon), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } } + HorizontalDivider(color = LegacyBlack12) - Spacer(modifier = Modifier.height(24.dp)) - - AirMQButton( - text = stringResource(R.string.button_rename), + ConfigRow( + label = stringResource(R.string.text_device_name), + value = device.name, onClick = { showRenameDialog = true }, - style = AirMQButtonStyle.Outlined, - modifier = Modifier.fillMaxWidth() + trailingIcon = R.drawable.ic_edit, + iconTint = (labelColorOverride?.let { PreviewBlack54 } ?: colorResource(R.color.black54)), + labelColor = headerColor ) - Spacer(modifier = Modifier.height(12.dp)) + ConfigRowReadOnly( + label = stringResource(R.string.text_device_id), + value = device.id, + labelColor = headerColor + ) - if (device.hasLocation()) { - Text( - text = stringResource( + ConfigRowReadOnly( + label = stringResource(R.string.text_device_ip), + value = device.deviceAddress, + labelColor = headerColor + ) + + ConfigRowWithIndicator( + label = stringResource(R.string.text_device_config_version), + value = device.configVersion, + showIndicator = state.configVersionNeedsUpdate, + indicatorColor = labelColorOverride?.let { PreviewOrange } ?: colorResource(R.color.colorOrange), + onClick = { if (state.configVersionNeedsUpdate) showConfigComingSoonDialog = true }, + trailingIcon = if (state.configVersionNeedsUpdate) { + R.drawable.ic_upload + } else null, + iconTint = labelColorOverride?.let { PreviewBlack54 } ?: colorResource(R.color.black54), + labelColor = headerColor, + indicatorDrawableRes = null + ) + + ConfigRowReadOnly( + label = stringResource(R.string.text_device_wifi), + value = state.wifiSsid, + labelColor = headerColor + ) + + HorizontalDivider(color = LegacyBlack12) + + ConfigRowWithIndicator( + label = stringResource(R.string.text_device_location), + value = if (device.hasLocation()) { + stringResource( R.string.text_device_location_set, - device.latitude!!, - device.longitude!! - ), - style = MaterialTheme.typography.bodyMedium - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - AirMQButton( - text = stringResource(R.string.title_location), - onClick = { onEvent(DeviceSettingsScreenContract.Event.OpenLocationClicked) }, - style = AirMQButtonStyle.Outlined, - modifier = Modifier.weight(1f) + roundLocation(device.latitude!!), + roundLocation(device.longitude!!) ) - AirMQButton( - text = stringResource(R.string.button_view_on_map), - onClick = { onEvent(DeviceSettingsScreenContract.Event.ShowOnMapClicked) }, - style = AirMQButtonStyle.Outlined, - modifier = Modifier.weight(1f) - ) - } - } else { - AirMQButton( - text = stringResource(R.string.button_register), - onClick = { onEvent(DeviceSettingsScreenContract.Event.OpenLocationClicked) }, - style = AirMQButtonStyle.Outlined, - modifier = Modifier.fillMaxWidth() - ) - } + } else { + stringResource(R.string.text_device_location_unset) + }, + showIndicator = !device.hasLocation(), + indicatorColor = labelColorOverride?.let { PreviewOrange } ?: colorResource(R.color.colorOrange), + onClick = { onEvent(DeviceSettingsScreenContract.Event.OpenLocationClicked) }, + trailingIcon = if (device.hasLocation()) null else R.drawable.ic_edit, + iconTint = labelColorOverride?.let { PreviewBlack54 } ?: colorResource(R.color.black54), + labelColor = headerColor, + indicatorDrawableRes = null + ) - Spacer(modifier = Modifier.height(12.dp)) + ToggleRow( + title = stringResource(R.string.text_device_narodmon_title), + subtitle = stringResource(R.string.text_device_narodmon), + checked = device.isNarodmonOn, + onCheckedChange = { onEvent(DeviceSettingsScreenContract.Event.NarodmonToggled(it)) }, + onInfoClick = { onEvent(DeviceSettingsScreenContract.Event.NarodmonInfoClicked) }, + labelColor = headerColor, + infoIconTint = labelColorOverride?.let { PreviewBlack54 } ?: colorResource(R.color.black54), + infoIconResOverride = null + ) - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = stringResource(R.string.text_data_sharing), - style = MaterialTheme.typography.bodyLarge - ) - Switch( - checked = device.dataSharingEnabled, - onCheckedChange = { onEvent(DeviceSettingsScreenContract.Event.DataSharingToggled(it)) } - ) - } + ToggleRow( + title = stringResource(R.string.text_device_luftdata_title), + subtitle = stringResource(R.string.text_device_luftdata), + checked = device.isLuftdataOn, + onCheckedChange = { onEvent(DeviceSettingsScreenContract.Event.LuftdataToggled(it)) }, + onInfoClick = { onEvent(DeviceSettingsScreenContract.Event.LuftdataInfoClicked) }, + labelColor = headerColor, + infoIconTint = labelColorOverride?.let { PreviewBlack54 } ?: colorResource(R.color.black54), + infoIconResOverride = null + ) - Spacer(modifier = Modifier.height(12.dp)) + VisibilityRow( + label = stringResource(R.string.text_device_visibility), + visibilityText = when { + !device.hasLocation() -> stringResource(R.string.text_device_visibility_not_registered) + device.isPublic -> stringResource(R.string.text_device_visibility_visible) + else -> stringResource(R.string.text_device_visibility_private) + }, + publishButtonText = if (device.isPublic) { + stringResource(R.string.button_hide) + } else { + stringResource(R.string.button_publish) + }, + isPublishButtonEnabled = device.hasLocation(), + onPublishClick = { + if (device.isPublic) { + onEvent(DeviceSettingsScreenContract.Event.VisibilityHideClicked) + } else { + onEvent(DeviceSettingsScreenContract.Event.VisibilityPublishClicked) + } + }, + labelColor = greyColor + ) + Spacer(modifier = Modifier.height(20.dp)) AirMQButton( - text = stringResource(R.string.button_firmware_update), - onClick = { onEvent(DeviceSettingsScreenContract.Event.FirmwareUpdateClicked) }, - style = AirMQButtonStyle.Gradient, - modifier = Modifier.fillMaxWidth() + text = stringResource(R.string.button_view_on_map), + onClick = { onEvent(DeviceSettingsScreenContract.Event.ShowOnMapClicked) }, + style = AirMQButtonStyle.Contained, + enabled = device.hasLocation(), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 80.dp) + ) + Spacer(modifier = Modifier.height(20.dp)) + } +} + +@Composable +private fun VisibilityRow( + label: String, + visibilityText: String, + publishButtonText: String, + isPublishButtonEnabled: Boolean, + onPublishClick: () -> Unit, + labelColor: Color +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp), + color = labelColor + ) + Text( + text = visibilityText, + style = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp) + ) + } + AirMQButton( + text = publishButtonText, + onClick = onPublishClick, + enabled = isPublishButtonEnabled, + style = AirMQButtonStyle.Text, + modifier = Modifier.width(170.dp) + ) + } +} + +@Composable +private fun ConfigRow( + label: String, + value: String, + onClick: () -> Unit, + trailingIcon: Int, + iconTint: Color, + labelColor: Color +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, fontWeight = FontWeight.Medium), + color = labelColor + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp) + ) + } + Icon( + painter = painterResource(trailingIcon), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = iconTint + ) + } +} + +@Composable +private fun ConfigRowReadOnly( + label: String, + value: String, + labelColor: Color +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = label, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, fontWeight = FontWeight.Medium), + color = labelColor + ) + Text( + text = value, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp) + ) + } + } +} + +@Composable +private fun ConfigRowWithIndicator( + label: String, + value: String, + showIndicator: Boolean, + indicatorColor: Color, + onClick: () -> Unit, + trailingIcon: Int?, + iconTint: Color, + labelColor: Color, + indicatorDrawableRes: Int? = null +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, fontWeight = FontWeight.Medium), + color = labelColor + ) + if (showIndicator) { + Spacer(modifier = Modifier.size(5.dp)) + Icon( + painter = painterResource(indicatorDrawableRes ?: R.drawable.circle_indicator), + contentDescription = null, + modifier = Modifier.size(10.dp), + tint = indicatorColor + ) + } + } + Text( + text = value, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp) + ) + } + if (trailingIcon != null) { + Icon( + painter = painterResource(trailingIcon), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = iconTint + ) + } + } +} + +@Composable +private fun ToggleRow( + title: String, + subtitle: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + onInfoClick: () -> Unit, + labelColor: Color, + infoIconTint: Color, + infoIconResOverride: Int? = null +) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .clickable { onCheckedChange(!checked) } + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f) + ) { + Column { + Text( + text = title, + style = MaterialTheme.typography.labelMedium.copy(fontSize = 12.sp, fontWeight = FontWeight.Medium), + color = labelColor + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium.copy(fontSize = 14.sp) + ) + } + IconButton( + onClick = onInfoClick, + modifier = Modifier + .size(32.dp) + .padding(8.dp) + ) { + Icon( + painter = painterResource(infoIconResOverride ?: R.drawable.ic_pref_info), + contentDescription = null, + tint = infoIconTint + ) + } + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange + ) + } +} + +@Preview(showBackground = true, name = "Device config – no location") +@Composable +private fun PreviewDeviceSettingsContentDefault() { + AirMQTheme { + DeviceSettingsContent( + state = DeviceSettingsScreenContract.previewState(), + onEvent = {}, + labelColorOverride = PreviewHeaderColor, + greyColorOverride = PreviewGreyColor, + iconResOverride = AndroidR.drawable.ic_menu_mylocation + ) + } +} + +@Preview(showBackground = true, name = "Device config – with location") +@Composable +private fun PreviewDeviceSettingsContentWithLocation() { + AirMQTheme { + DeviceSettingsContent( + state = DeviceSettingsScreenContract.previewState( + device = DeviceSettingsScreenContract.previewDevice(hasLocation = true, isPublic = false) + ), + onEvent = {}, + labelColorOverride = PreviewHeaderColor, + greyColorOverride = PreviewGreyColor, + iconResOverride = AndroidR.drawable.ic_menu_mylocation + ) + } +} + +@Preview(showBackground = true, name = "Device config – public visibility") +@Composable +private fun PreviewDeviceSettingsContentPublicVisibility() { + AirMQTheme { + DeviceSettingsContent( + state = DeviceSettingsScreenContract.previewState( + device = DeviceSettingsScreenContract.previewDevice(hasLocation = true, isPublic = true) + ), + onEvent = {}, + labelColorOverride = PreviewHeaderColor, + greyColorOverride = PreviewGreyColor, + iconResOverride = AndroidR.drawable.ic_menu_mylocation + ) + } +} + +@Preview(showBackground = true, name = "Device config – config needs update") +@Composable +private fun PreviewDeviceSettingsContentConfigNeedsUpdate() { + AirMQTheme { + DeviceSettingsContent( + state = DeviceSettingsScreenContract.previewState( + configVersionNeedsUpdate = true + ), + onEvent = {}, + labelColorOverride = PreviewHeaderColor, + greyColorOverride = PreviewGreyColor, + iconResOverride = AndroidR.drawable.ic_menu_mylocation + ) + } +} + +@Preview(showBackground = true, name = "Device config – Narodmon/Luftdata on") +@Composable +private fun PreviewDeviceSettingsContentTogglesOn() { + AirMQTheme { + DeviceSettingsContent( + state = DeviceSettingsScreenContract.previewState( + device = DeviceSettingsScreenContract.previewDevice( + isNarodmonOn = true, + isLuftdataOn = true + ) + ), + onEvent = {}, + labelColorOverride = PreviewHeaderColor, + greyColorOverride = PreviewGreyColor, + iconResOverride = AndroidR.drawable.ic_menu_mylocation ) } } diff --git a/app/src/main/kotlin/org/db3/airmq/features/device/DeviceSettingsScreenContract.kt b/app/src/main/kotlin/org/db3/airmq/features/device/DeviceSettingsScreenContract.kt index 8b8c106..5db4f4a 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/device/DeviceSettingsScreenContract.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/device/DeviceSettingsScreenContract.kt @@ -1,14 +1,65 @@ package org.db3.airmq.features.device import org.db3.airmq.sdk.device.domain.Device +import org.db3.airmq.sdk.device.domain.DeviceModel +import org.db3.airmq.sdk.device.domain.OnlineFreshness object DeviceSettingsScreenContract { + fun previewState( + device: Device? = previewDevice(), + isLoading: Boolean = false, + isPendingSync: Boolean = false, + configVersionNeedsUpdate: Boolean = false, + isConnected: Boolean = true, + wifiSsid: String = "Захарова 42" + ): State = State( + device = device, + isLoading = isLoading, + isPendingSync = isPendingSync, + configVersionNeedsUpdate = configVersionNeedsUpdate, + isConnected = isConnected, + wifiSsid = wifiSsid + ) + + fun previewDevice( + id: String = "12312", + name: String = "Захарова 42", + model: DeviceModel = DeviceModel.Mobile, + deviceAddress: String = "127.0.0.1", + configVersion: String = "42", + isNarodmonOn: Boolean = false, + isLuftdataOn: Boolean = false, + hasLocation: Boolean = false, + isPublic: Boolean = false + ): Device = Device( + id = id, + name = name, + model = model, + firmwareVersion = "1.0", + deviceAddress = deviceAddress, + configVersion = configVersion, + isNarodmonOn = isNarodmonOn, + isLuftdataOn = isLuftdataOn, + locationId = null, + latitude = if (hasLocation) 53.9 else null, + longitude = if (hasLocation) 27.5 else null, + isPublic = isPublic, + city = null, + dataSharingEnabled = false, + isOnline = true, + onlineFreshness = OnlineFreshness.Fresh, + ownerId = null + ) + data class State( val device: Device? = null, val isLoading: Boolean = false, val errorMessage: String? = null, - val isPendingSync: Boolean = false + val isPendingSync: Boolean = false, + val configVersionNeedsUpdate: Boolean = false, + val isConnected: Boolean = false, + val wifiSsid: String = "" ) sealed interface Action { @@ -16,15 +67,23 @@ object DeviceSettingsScreenContract { data object ShowSuccess : Action data object OpenLocation : Action data object ShowOnMap : Action + data object OpenInfoUrl : Action } sealed interface Event { data class RenameSubmitted(val name: String) : Event + data object RenameRowClicked : Event + data object ConfigUpdateClicked : Event + data class NarodmonToggled(val enabled: Boolean) : Event + data class LuftdataToggled(val enabled: Boolean) : Event + data object NarodmonInfoClicked : Event + data object LuftdataInfoClicked : Event data class LocationSet(val latitude: Double, val longitude: Double) : Event data object LocationRemoved : Event - data class DataSharingToggled(val enabled: Boolean) : Event data object FirmwareUpdateClicked : Event data object OpenLocationClicked : Event data object ShowOnMapClicked : Event + data object VisibilityPublishClicked : Event + data object VisibilityHideClicked : Event } } diff --git a/app/src/main/kotlin/org/db3/airmq/features/device/DeviceSettingsViewModel.kt b/app/src/main/kotlin/org/db3/airmq/features/device/DeviceSettingsViewModel.kt index c09d157..1a1fde2 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/device/DeviceSettingsViewModel.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/device/DeviceSettingsViewModel.kt @@ -1,9 +1,12 @@ package org.db3.airmq.features.device -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -16,23 +19,32 @@ import kotlinx.coroutines.launch import org.db3.airmq.features.device.usecases.GetDeviceUseCase import org.db3.airmq.features.device.usecases.ObservePendingSyncUseCase import org.db3.airmq.features.device.usecases.RenameDeviceUseCase -import org.db3.airmq.features.device.usecases.SetDataSharingUseCase import org.db3.airmq.features.device.usecases.SetDeviceLocationUseCase +import org.db3.airmq.features.device.usecases.SetDeviceVisibilityUseCase +import org.db3.airmq.features.device.usecases.SetLuftdataUseCase +import org.db3.airmq.features.device.usecases.SetNarodmonUseCase import org.db3.airmq.features.device.usecases.TriggerFirmwareUpdateUseCase -import javax.inject.Inject +import org.db3.airmq.sdk.device.domain.Device +import org.db3.airmq.sdk.device.domain.DeviceModel +import org.db3.airmq.sdk.device.domain.OnlineFreshness -@HiltViewModel -class DeviceSettingsViewModel @Inject constructor( - savedStateHandle: SavedStateHandle, +@HiltViewModel(assistedFactory = DeviceSettingsViewModel.Factory::class) +class DeviceSettingsViewModel @AssistedInject constructor( + @Assisted private val deviceId: String, private val getDeviceUseCase: GetDeviceUseCase, private val renameDeviceUseCase: RenameDeviceUseCase, private val setDeviceLocationUseCase: SetDeviceLocationUseCase, - private val setDataSharingUseCase: SetDataSharingUseCase, + private val setNarodmonUseCase: SetNarodmonUseCase, + private val setLuftdataUseCase: SetLuftdataUseCase, + private val setDeviceVisibilityUseCase: SetDeviceVisibilityUseCase, private val triggerFirmwareUpdateUseCase: TriggerFirmwareUpdateUseCase, private val observePendingSyncUseCase: ObservePendingSyncUseCase ) : ViewModel() { - private val deviceId: String = checkNotNull(savedStateHandle.get("deviceId")) + @AssistedFactory + interface Factory { + fun create(deviceId: String): DeviceSettingsViewModel + } private val _uiState = MutableStateFlow(DeviceSettingsScreenContract.State()) val uiState: StateFlow = _uiState.asStateFlow() @@ -48,22 +60,60 @@ class DeviceSettingsViewModel @Inject constructor( ) { device, hasPending -> device to hasPending }.collect { (device, hasPending) -> + val displayDevice = device ?: stubDevice() + val configVersionNeedsUpdate = deriveConfigVersionNeedsUpdate(displayDevice) + val isConnected = true // Stub: always connected for UI testing _uiState.update { it.copy( - device = device, - isPendingSync = hasPending + device = displayDevice, + isPendingSync = hasPending, + configVersionNeedsUpdate = configVersionNeedsUpdate, + isConnected = isConnected, + wifiSsid = if (device == null) "Захарова 42" else it.wifiSsid.takeIf { s -> s.isNotEmpty() } ?: "—" ) } } } } + private fun stubDevice(): Device = Device( + id = "12312", + name = "Захарова 42", + model = DeviceModel.Mobile, + firmwareVersion = "1.0", + deviceAddress = "127.0.0.1", + configVersion = "42", + isNarodmonOn = false, + isLuftdataOn = false, + locationId = null, + latitude = null, + longitude = null, + isPublic = false, + city = null, + dataSharingEnabled = false, + isOnline = true, + onlineFreshness = OnlineFreshness.Fresh, + ownerId = null + ) + + private fun deriveConfigVersionNeedsUpdate(device: Device): Boolean { + // Stub: compare configVersion to expected (e.g. 42); for testing assume up-to-date + return false + } + fun onEvent(event: DeviceSettingsScreenContract.Event) { when (event) { is DeviceSettingsScreenContract.Event.RenameSubmitted -> renameDevice(event.name) + is DeviceSettingsScreenContract.Event.RenameRowClicked -> { /* UI opens dialog */ } + is DeviceSettingsScreenContract.Event.ConfigUpdateClicked -> configUpdateStub() + is DeviceSettingsScreenContract.Event.NarodmonToggled -> setNarodmon(event.enabled) + is DeviceSettingsScreenContract.Event.LuftdataToggled -> setLuftdata(event.enabled) + is DeviceSettingsScreenContract.Event.NarodmonInfoClicked, + is DeviceSettingsScreenContract.Event.LuftdataInfoClicked -> _actions.tryEmit( + DeviceSettingsScreenContract.Action.OpenInfoUrl + ) is DeviceSettingsScreenContract.Event.LocationSet -> setLocation(event.latitude, event.longitude) is DeviceSettingsScreenContract.Event.LocationRemoved -> removeLocation() - is DeviceSettingsScreenContract.Event.DataSharingToggled -> setDataSharing(event.enabled) is DeviceSettingsScreenContract.Event.FirmwareUpdateClicked -> triggerFirmwareUpdate() is DeviceSettingsScreenContract.Event.OpenLocationClicked -> _actions.tryEmit( DeviceSettingsScreenContract.Action.OpenLocation @@ -71,12 +121,24 @@ class DeviceSettingsViewModel @Inject constructor( is DeviceSettingsScreenContract.Event.ShowOnMapClicked -> _actions.tryEmit( DeviceSettingsScreenContract.Action.ShowOnMap ) + is DeviceSettingsScreenContract.Event.VisibilityPublishClicked -> setVisibility(true) + is DeviceSettingsScreenContract.Event.VisibilityHideClicked -> setVisibility(false) + } + } + + private fun configUpdateStub() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + delay(800) + _uiState.update { it.copy(isLoading = false) } + _actions.tryEmit(DeviceSettingsScreenContract.Action.ShowSuccess) } } private fun renameDevice(name: String) { viewModelScope.launch { _uiState.update { it.copy(isLoading = true, errorMessage = null) } + delay(500) // Stub simulation val result = renameDeviceUseCase(deviceId, name) _uiState.update { it.copy(isLoading = false) } if (result.isSuccess) { @@ -91,6 +153,42 @@ class DeviceSettingsViewModel @Inject constructor( } } + private fun setNarodmon(enabled: Boolean) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + delay(300) + val result = setNarodmonUseCase(deviceId, enabled) + _uiState.update { it.copy(isLoading = false) } + if (result.isSuccess) { + _actions.tryEmit(DeviceSettingsScreenContract.Action.ShowSuccess) + } else { + _actions.tryEmit( + DeviceSettingsScreenContract.Action.ShowError( + result.exceptionOrNull()?.message ?: "Failed to update Narodmon" + ) + ) + } + } + } + + private fun setLuftdata(enabled: Boolean) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + delay(300) + val result = setLuftdataUseCase(deviceId, enabled) + _uiState.update { it.copy(isLoading = false) } + if (result.isSuccess) { + _actions.tryEmit(DeviceSettingsScreenContract.Action.ShowSuccess) + } else { + _actions.tryEmit( + DeviceSettingsScreenContract.Action.ShowError( + result.exceptionOrNull()?.message ?: "Failed to update Sensor.community" + ) + ) + } + } + } + private fun setLocation(latitude: Double, longitude: Double) { viewModelScope.launch { _uiState.update { it.copy(isLoading = true, errorMessage = null) } @@ -125,17 +223,16 @@ class DeviceSettingsViewModel @Inject constructor( } } - private fun setDataSharing(enabled: Boolean) { + private fun setVisibility(isPublic: Boolean) { viewModelScope.launch { _uiState.update { it.copy(isLoading = true, errorMessage = null) } - val result = setDataSharingUseCase(deviceId, enabled) + delay(300) + val result = setDeviceVisibilityUseCase(deviceId, isPublic) _uiState.update { it.copy(isLoading = false) } - if (result.isSuccess) { - _actions.tryEmit(DeviceSettingsScreenContract.Action.ShowSuccess) - } else { + if (result.isFailure) { _actions.tryEmit( DeviceSettingsScreenContract.Action.ShowError( - result.exceptionOrNull()?.message ?: "Failed to update data sharing" + result.exceptionOrNull()?.message ?: "Failed to update visibility" ) ) } diff --git a/app/src/main/kotlin/org/db3/airmq/features/device/usecases/SetDeviceVisibilityUseCase.kt b/app/src/main/kotlin/org/db3/airmq/features/device/usecases/SetDeviceVisibilityUseCase.kt new file mode 100644 index 0000000..fca8219 --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/device/usecases/SetDeviceVisibilityUseCase.kt @@ -0,0 +1,15 @@ +package org.db3.airmq.features.device.usecases + +import org.db3.airmq.sdk.device.domain.DeviceRepository +import javax.inject.Inject + +/** + * Use case to set device visibility (publish/hide location on map). + * Only effective when device has location. + */ +class SetDeviceVisibilityUseCase @Inject constructor( + private val repository: DeviceRepository +) { + suspend operator fun invoke(deviceId: String, isPublic: Boolean): Result = + repository.setVisibility(deviceId, isPublic) +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/device/usecases/SetLuftdataUseCase.kt b/app/src/main/kotlin/org/db3/airmq/features/device/usecases/SetLuftdataUseCase.kt new file mode 100644 index 0000000..e1e1ac8 --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/device/usecases/SetLuftdataUseCase.kt @@ -0,0 +1,14 @@ +package org.db3.airmq.features.device.usecases + +import org.db3.airmq.sdk.device.domain.DeviceRepository +import javax.inject.Inject + +/** + * Use case to enable or disable Sensor.community (Luftdata) data sharing for a device. + */ +class SetLuftdataUseCase @Inject constructor( + private val repository: DeviceRepository +) { + suspend operator fun invoke(deviceId: String, enabled: Boolean): Result = + repository.setLuftdata(deviceId, enabled) +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/device/usecases/SetNarodmonUseCase.kt b/app/src/main/kotlin/org/db3/airmq/features/device/usecases/SetNarodmonUseCase.kt new file mode 100644 index 0000000..ae449b8 --- /dev/null +++ b/app/src/main/kotlin/org/db3/airmq/features/device/usecases/SetNarodmonUseCase.kt @@ -0,0 +1,14 @@ +package org.db3.airmq.features.device.usecases + +import org.db3.airmq.sdk.device.domain.DeviceRepository +import javax.inject.Inject + +/** + * Use case to enable or disable Narodmon.ru data sharing for a device. + */ +class SetNarodmonUseCase @Inject constructor( + private val repository: DeviceRepository +) { + suspend operator fun invoke(deviceId: String, enabled: Boolean): Result = + repository.setNarodmon(deviceId, enabled) +} 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 index a378c28..1095c6c 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/manage/ManageViewModel.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageViewModel.kt @@ -108,7 +108,7 @@ class ManageViewModel @Inject constructor( return DeviceItem( id = id, name = name, - extra = model.ifEmpty { "mobile" }, + extra = model.displayName, status = statusText, hasLocation = hasLocation() ) diff --git a/app/src/main/kotlin/org/db3/airmq/ui/theme/Color.kt b/app/src/main/kotlin/org/db3/airmq/ui/theme/Color.kt index ad7ab11..aa64181 100644 --- a/app/src/main/kotlin/org/db3/airmq/ui/theme/Color.kt +++ b/app/src/main/kotlin/org/db3/airmq/ui/theme/Color.kt @@ -13,6 +13,8 @@ val LegacyOnBackground = Color(0xFF000000) val LegacyOnSurface = Color(0xFF000000) val LegacyOutline = Color(0x61000000) val LegacyOutlineLight = Color(0x3B000000) +val LegacyBlack12 = Color(0x1F000000) +val LegacyBlack38 = Color(0x61000000) val LegacyNavSelected = Color(0xFFFFFFFF) val LegacyNavUnselected = Color(0x8AFFFFFF) val LegacyNavContainer = Color(0xFF295989) diff --git a/app/src/main/res/drawable/circle_indicator.xml b/app/src/main/res/drawable/circle_indicator.xml new file mode 100644 index 0000000..6ab4124 --- /dev/null +++ b/app/src/main/res/drawable/circle_indicator.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_device_basic_active_10.xml b/app/src/main/res/drawable/ic_device_basic_active_10.xml new file mode 100644 index 0000000..dbac620 --- /dev/null +++ b/app/src/main/res/drawable/ic_device_basic_active_10.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_device_basic_inactive_10.xml b/app/src/main/res/drawable/ic_device_basic_inactive_10.xml new file mode 100644 index 0000000..47d2cd3 --- /dev/null +++ b/app/src/main/res/drawable/ic_device_basic_inactive_10.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_device_custom_active_10.xml b/app/src/main/res/drawable/ic_device_custom_active_10.xml new file mode 100644 index 0000000..307c2fe --- /dev/null +++ b/app/src/main/res/drawable/ic_device_custom_active_10.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_device_custom_inactive_10.xml b/app/src/main/res/drawable/ic_device_custom_inactive_10.xml new file mode 100644 index 0000000..fe71f14 --- /dev/null +++ b/app/src/main/res/drawable/ic_device_custom_inactive_10.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_device_gas_active_10.xml b/app/src/main/res/drawable/ic_device_gas_active_10.xml new file mode 100644 index 0000000..26b2afa --- /dev/null +++ b/app/src/main/res/drawable/ic_device_gas_active_10.xml @@ -0,0 +1,23 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_device_gas_inactive_10.xml b/app/src/main/res/drawable/ic_device_gas_inactive_10.xml new file mode 100644 index 0000000..44efa81 --- /dev/null +++ b/app/src/main/res/drawable/ic_device_gas_inactive_10.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/ic_device_mobile_active_10.xml b/app/src/main/res/drawable/ic_device_mobile_active_10.xml new file mode 100644 index 0000000..501ee07 --- /dev/null +++ b/app/src/main/res/drawable/ic_device_mobile_active_10.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_device_mobile_inactive_10.xml b/app/src/main/res/drawable/ic_device_mobile_inactive_10.xml new file mode 100644 index 0000000..68c8927 --- /dev/null +++ b/app/src/main/res/drawable/ic_device_mobile_inactive_10.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_device_radiation_active_10.xml b/app/src/main/res/drawable/ic_device_radiation_active_10.xml new file mode 100644 index 0000000..daa9b43 --- /dev/null +++ b/app/src/main/res/drawable/ic_device_radiation_active_10.xml @@ -0,0 +1,23 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_device_radiation_inactive_10.xml b/app/src/main/res/drawable/ic_device_radiation_inactive_10.xml new file mode 100644 index 0000000..2c062b4 --- /dev/null +++ b/app/src/main/res/drawable/ic_device_radiation_inactive_10.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_device_solar_active_10.xml b/app/src/main/res/drawable/ic_device_solar_active_10.xml new file mode 100644 index 0000000..2e817f3 --- /dev/null +++ b/app/src/main/res/drawable/ic_device_solar_active_10.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_device_solar_inactive_10.xml b/app/src/main/res/drawable/ic_device_solar_inactive_10.xml new file mode 100644 index 0000000..b2f3fb8 --- /dev/null +++ b/app/src/main/res/drawable/ic_device_solar_inactive_10.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_edit.xml b/app/src/main/res/drawable/ic_edit.xml new file mode 100644 index 0000000..1599eed --- /dev/null +++ b/app/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_upload.xml b/app/src/main/res/drawable/ic_upload.xml new file mode 100644 index 0000000..da294df --- /dev/null +++ b/app/src/main/res/drawable/ic_upload.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values-be-rBY/strings.xml b/app/src/main/res/values-be-rBY/strings.xml index 66437c3..560bf79 100644 --- a/app/src/main/res/values-be-rBY/strings.xml +++ b/app/src/main/res/values-be-rBY/strings.xml @@ -101,6 +101,7 @@ Версія прашыўкі Месцазнаходжанне Прылада не зарэгістравана + падлучана Бачнасць Апублікавана Схавана diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 3e3e4d0..04a86eb 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -101,6 +101,7 @@ Версия прошивки Местоположение Устройство не зарегистрировано + подключено Видимость Опубликовано Скрыто diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 7fde1f2..e05ac3e 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -11,9 +11,13 @@ #FF00C853 #FFFFD54F #FFFF9800 + #FF00C853 + #FFFF6F00 #FFF44336 #FFEC407A #FF8E24AA #61000000 + #8A000000 + #1F5DA5 #FAFAFA \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bec4bc0..89e49b3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -158,6 +158,7 @@ Location Device is not registered %s, %s + connected Visibility Public Private diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/DeviceRepositoryImpl.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/DeviceRepositoryImpl.kt index a2bdc20..eb409b3 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/DeviceRepositoryImpl.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/DeviceRepositoryImpl.kt @@ -112,6 +112,87 @@ class DeviceRepositoryImpl @Inject constructor( return Result.success(Unit) } + override suspend fun setNarodmon(deviceId: String, enabled: Boolean): Result { + val current = localDataSource.getDevice(deviceId) ?: return Result.failure(NoSuchElementException("Device not found: $deviceId")) + val previous = current.isNarodmonOn + + localDataSource.updateNarodmon(deviceId, enabled) + val mutationId = UUID.randomUUID().toString() + localDataSource.enqueuePendingMutation( + PendingMutation( + id = mutationId, + type = PendingMutationType.NARODMON, + deviceId = deviceId, + payload = """{"enabled":$enabled}""", + createdAt = System.currentTimeMillis() + ) + ) + + val result = remoteDataSource.setNarodmon(deviceId, enabled) + if (result.isSuccess) { + localDataSource.removePendingMutationsForDevice(deviceId, PendingMutationType.NARODMON) + } else { + localDataSource.updateNarodmon(deviceId, previous) + localDataSource.removePendingMutationsForDevice(deviceId, PendingMutationType.NARODMON) + return result + } + return Result.success(Unit) + } + + override suspend fun setLuftdata(deviceId: String, enabled: Boolean): Result { + val current = localDataSource.getDevice(deviceId) ?: return Result.failure(NoSuchElementException("Device not found: $deviceId")) + val previous = current.isLuftdataOn + + localDataSource.updateLuftdata(deviceId, enabled) + val mutationId = UUID.randomUUID().toString() + localDataSource.enqueuePendingMutation( + PendingMutation( + id = mutationId, + type = PendingMutationType.LUFTDATA, + deviceId = deviceId, + payload = """{"enabled":$enabled}""", + createdAt = System.currentTimeMillis() + ) + ) + + val result = remoteDataSource.setLuftdata(deviceId, enabled) + if (result.isSuccess) { + localDataSource.removePendingMutationsForDevice(deviceId, PendingMutationType.LUFTDATA) + } else { + localDataSource.updateLuftdata(deviceId, previous) + localDataSource.removePendingMutationsForDevice(deviceId, PendingMutationType.LUFTDATA) + return result + } + return Result.success(Unit) + } + + override suspend fun setVisibility(deviceId: String, isPublic: Boolean): Result { + val current = localDataSource.getDevice(deviceId) ?: return Result.failure(NoSuchElementException("Device not found: $deviceId")) + val previous = current.isPublic + + localDataSource.updateIsPublic(deviceId, isPublic) + val mutationId = UUID.randomUUID().toString() + localDataSource.enqueuePendingMutation( + PendingMutation( + id = mutationId, + type = PendingMutationType.VISIBILITY, + deviceId = deviceId, + payload = """{"isPublic":$isPublic}""", + createdAt = System.currentTimeMillis() + ) + ) + + val result = remoteDataSource.setVisibility(deviceId, isPublic) + if (result.isSuccess) { + localDataSource.removePendingMutationsForDevice(deviceId, PendingMutationType.VISIBILITY) + } else { + localDataSource.updateIsPublic(deviceId, previous) + localDataSource.removePendingMutationsForDevice(deviceId, PendingMutationType.VISIBILITY) + return result + } + return Result.success(Unit) + } + override suspend fun triggerFirmwareUpdate(deviceId: String): Result = remoteDataSource.triggerFirmwareUpdate(deviceId) diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceDao.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceDao.kt index f2010a1..6e7090b 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceDao.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceDao.kt @@ -30,6 +30,15 @@ interface DeviceDao { @Query("UPDATE device SET dataSharingEnabled = :enabled WHERE id = :deviceId") suspend fun updateDataSharing(deviceId: String, enabled: Boolean) + @Query("UPDATE device SET isNarodmonOn = :enabled WHERE id = :deviceId") + suspend fun updateNarodmon(deviceId: String, enabled: Boolean) + + @Query("UPDATE device SET isLuftdataOn = :enabled WHERE id = :deviceId") + suspend fun updateLuftdata(deviceId: String, enabled: Boolean) + + @Query("UPDATE device SET isPublic = :isPublic WHERE id = :deviceId") + suspend fun updateIsPublic(deviceId: String, isPublic: Boolean) + @Query("UPDATE device SET isOnline = :isOnline, isOnlineUpdatedAt = :updatedAt WHERE id = :deviceId") suspend fun updateOnlineStatus(deviceId: String, isOnline: Boolean, updatedAt: Long) diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceDatabase.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceDatabase.kt index a113e90..68986cc 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceDatabase.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceDatabase.kt @@ -2,10 +2,27 @@ package org.db3.airmq.sdk.device.data.local import androidx.room.Database import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val DEVICE_DB_MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE device ADD COLUMN deviceAddress TEXT NOT NULL DEFAULT ''") + db.execSQL("ALTER TABLE device ADD COLUMN configVersion TEXT NOT NULL DEFAULT ''") + db.execSQL("ALTER TABLE device ADD COLUMN isNarodmonOn INTEGER NOT NULL DEFAULT 0") + db.execSQL("ALTER TABLE device ADD COLUMN isLuftdataOn INTEGER NOT NULL DEFAULT 0") + } +} + +val DEVICE_DB_MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE device ADD COLUMN isPublic INTEGER NOT NULL DEFAULT 0") + } +} @Database( entities = [DeviceEntity::class, PendingMutationEntity::class], - version = 1, + version = 3, exportSchema = false ) abstract class DeviceDatabase : RoomDatabase() { diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceEntity.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceEntity.kt index 552006d..234b78f 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceEntity.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceEntity.kt @@ -18,9 +18,14 @@ data class DeviceEntity( val name: String, val model: String, val firmwareVersion: String, + val deviceAddress: String, + val configVersion: String, + val isNarodmonOn: Boolean, + val isLuftdataOn: Boolean, val locationId: String? = null, val latitude: Double? = null, val longitude: Double? = null, + val isPublic: Boolean = false, val dataSharingEnabled: Boolean = false, val isOnline: Boolean = false, val isOnlineUpdatedAt: Long? = null, diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceLocalDataSource.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceLocalDataSource.kt index 571a293..a9c0585 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceLocalDataSource.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/local/DeviceLocalDataSource.kt @@ -3,6 +3,7 @@ package org.db3.airmq.sdk.device.data.local import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import org.db3.airmq.sdk.device.domain.Device +import org.db3.airmq.sdk.device.domain.DeviceModel import org.db3.airmq.sdk.device.domain.OnlineFreshness import org.db3.airmq.sdk.device.domain.PendingMutation import org.db3.airmq.sdk.device.domain.PendingMutationType @@ -47,6 +48,18 @@ class DeviceLocalDataSource @Inject constructor( deviceDao.updateDataSharing(deviceId, enabled) } + suspend fun updateNarodmon(deviceId: String, enabled: Boolean) { + deviceDao.updateNarodmon(deviceId, enabled) + } + + suspend fun updateLuftdata(deviceId: String, enabled: Boolean) { + deviceDao.updateLuftdata(deviceId, enabled) + } + + suspend fun updateIsPublic(deviceId: String, isPublic: Boolean) { + deviceDao.updateIsPublic(deviceId, isPublic) + } + suspend fun updateOnlineStatus(deviceId: String, isOnline: Boolean, updatedAt: Long) { deviceDao.updateOnlineStatus(deviceId, isOnline, updatedAt) } @@ -91,11 +104,16 @@ class DeviceLocalDataSource @Inject constructor( return Device( id = id, name = name, - model = model, + model = DeviceModel.fromString(model), firmwareVersion = firmwareVersion, + deviceAddress = deviceAddress, + configVersion = configVersion, + isNarodmonOn = isNarodmonOn, + isLuftdataOn = isLuftdataOn, locationId = locationId, latitude = latitude, longitude = longitude, + isPublic = isPublic, city = city, dataSharingEnabled = dataSharingEnabled, isOnline = isOnline, @@ -107,11 +125,16 @@ class DeviceLocalDataSource @Inject constructor( private fun Device.toEntity(): DeviceEntity = DeviceEntity( id = id, name = name, - model = model, + model = model.toStorageString(), firmwareVersion = firmwareVersion, + deviceAddress = deviceAddress, + configVersion = configVersion, + isNarodmonOn = isNarodmonOn, + isLuftdataOn = isLuftdataOn, locationId = locationId, latitude = latitude, longitude = longitude, + isPublic = isPublic, dataSharingEnabled = dataSharingEnabled, isOnline = isOnline, isOnlineUpdatedAt = if (onlineFreshness == OnlineFreshness.Fresh) System.currentTimeMillis() else null, diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/remote/DeviceRemoteDataSource.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/remote/DeviceRemoteDataSource.kt index 25aafc3..57740cf 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/remote/DeviceRemoteDataSource.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/data/remote/DeviceRemoteDataSource.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import org.db3.airmq.sdk.device.domain.Device +import org.db3.airmq.sdk.device.domain.DeviceModel import org.db3.airmq.sdk.device.domain.OnlineFreshness import javax.inject.Inject @@ -39,6 +40,21 @@ interface DeviceRemoteDataSource { */ suspend fun setDataSharing(deviceId: String, enabled: Boolean): Result + /** + * Execute Narodmon.ru toggle. Phase 1: No-op. Phase 2: Apollo mutation. + */ + suspend fun setNarodmon(deviceId: String, enabled: Boolean): Result + + /** + * Execute Sensor.community (Luftdata) toggle. Phase 1: No-op. Phase 2: Apollo mutation. + */ + suspend fun setLuftdata(deviceId: String, enabled: Boolean): Result + + /** + * Execute set visibility (publish/hide) mutation. Phase 1: No-op. Phase 2: Apollo mutation. + */ + suspend fun setVisibility(deviceId: String, isPublic: Boolean): Result + /** * Execute firmware update. Phase 1: No-op. Phase 2: Apollo mutation. */ @@ -56,11 +72,16 @@ class MockDeviceRemoteDataSource @Inject constructor() : DeviceRemoteDataSource Device( id = "device-1", name = "AirMQ #42", - model = "mobile", + model = DeviceModel.Mobile, firmwareVersion = "1.0", + deviceAddress = "192.168.1.100", + configVersion = "42", + isNarodmonOn = true, + isLuftdataOn = false, locationId = "loc-1", latitude = 53.9, longitude = 27.5, + isPublic = false, city = "Minsk", dataSharingEnabled = true, isOnline = true, @@ -70,11 +91,16 @@ class MockDeviceRemoteDataSource @Inject constructor() : DeviceRemoteDataSource Device( id = "device-2", name = "AirMQ #17", - model = "mobile", + model = DeviceModel.Mobile, firmwareVersion = "1.0", + deviceAddress = "192.168.1.101", + configVersion = "41", + isNarodmonOn = false, + isLuftdataOn = false, locationId = null, latitude = null, longitude = null, + isPublic = false, city = null, dataSharingEnabled = false, isOnline = false, @@ -102,6 +128,15 @@ class MockDeviceRemoteDataSource @Inject constructor() : DeviceRemoteDataSource override suspend fun setDataSharing(deviceId: String, enabled: Boolean): Result = Result.success(Unit) + override suspend fun setNarodmon(deviceId: String, enabled: Boolean): Result = + Result.success(Unit) + + override suspend fun setLuftdata(deviceId: String, enabled: Boolean): Result = + Result.success(Unit) + + override suspend fun setVisibility(deviceId: String, isPublic: Boolean): Result = + Result.success(Unit) + override suspend fun triggerFirmwareUpdate(deviceId: String): Result = Result.success(Unit) } diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/di/DeviceModule.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/di/DeviceModule.kt index b48c6a9..a0d4259 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/di/DeviceModule.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/di/DeviceModule.kt @@ -10,6 +10,8 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton import org.db3.airmq.sdk.device.data.DeviceRepositoryImpl +import org.db3.airmq.sdk.device.data.local.DEVICE_DB_MIGRATION_1_2 +import org.db3.airmq.sdk.device.data.local.DEVICE_DB_MIGRATION_2_3 import org.db3.airmq.sdk.device.data.local.DeviceDao import org.db3.airmq.sdk.device.data.local.DeviceDatabase import org.db3.airmq.sdk.device.data.local.PendingMutationDao @@ -28,7 +30,7 @@ object DeviceDatabaseModule { context, DeviceDatabase::class.java, "airmq_device_db" - ).build() + ).addMigrations(DEVICE_DB_MIGRATION_1_2, DEVICE_DB_MIGRATION_2_3).build() @Provides @Singleton diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/Device.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/Device.kt index c32cd75..74cd876 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/Device.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/Device.kt @@ -26,11 +26,16 @@ enum class OnlineFreshness { * * @param id Unique device identifier * @param name Display name - * @param model Device model identifier + * @param model Device model type * @param firmwareVersion Firmware version string + * @param deviceAddress Device IP address + * @param configVersion Config version string + * @param isNarodmonOn Whether Narodmon.ru data sharing is enabled + * @param isLuftdataOn Whether Sensor.community data sharing is enabled * @param locationId Optional location identifier * @param latitude Optional latitude * @param longitude Optional longitude + * @param isPublic Whether device location is published (visible to others) * @param city Optional city name for the location * @param dataSharingEnabled Whether data sharing is enabled * @param isOnline Whether the device is currently online @@ -40,11 +45,16 @@ enum class OnlineFreshness { data class Device( val id: String, val name: String, - val model: String, + val model: DeviceModel, val firmwareVersion: String, + val deviceAddress: String, + val configVersion: String, + val isNarodmonOn: Boolean, + val isLuftdataOn: Boolean, val locationId: String? = null, val latitude: Double? = null, val longitude: Double? = null, + val isPublic: Boolean = false, val city: String? = null, val dataSharingEnabled: Boolean = false, val isOnline: Boolean = false, diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/DeviceModel.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/DeviceModel.kt new file mode 100644 index 0000000..aefe8b5 --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/DeviceModel.kt @@ -0,0 +1,24 @@ +package org.db3.airmq.sdk.device.domain + +/** + * Device model type. Maps to legacy numeric codes and string identifiers from API/DB. + */ +enum class DeviceModel(val displayName: String, private val storageValue: String) { + Basic("Basic", "0"), + Mobile("Mobile", "1"), + Solar("Solar", "2"), + Radiation("Radiation", "4"), + Custom("Custom", "-1"); + + fun toStorageString(): String = storageValue + + companion object { + fun fromString(value: String?): DeviceModel = when (value?.lowercase()) { + "0", "basic" -> Basic + "1", "mobile" -> Mobile + "2", "solar" -> Solar + "4", "radiation" -> Radiation + else -> Custom + } + } +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/DeviceRepository.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/DeviceRepository.kt index db883f4..0aeb741 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/DeviceRepository.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/DeviceRepository.kt @@ -47,6 +47,22 @@ interface DeviceRepository { */ suspend fun setDataSharing(deviceId: String, enabled: Boolean): Result + /** + * Enable or disable Narodmon.ru data sharing. + */ + suspend fun setNarodmon(deviceId: String, enabled: Boolean): Result + + /** + * Enable or disable Sensor.community (Luftdata) data sharing. + */ + suspend fun setLuftdata(deviceId: String, enabled: Boolean): Result + + /** + * Set device visibility (publish/hide location on map). + * Only effective when device has location. + */ + suspend fun setVisibility(deviceId: String, isPublic: Boolean): Result + /** * Trigger firmware update. Requires connectivity. * diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/PendingMutation.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/PendingMutation.kt index 708c4d5..d365301 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/PendingMutation.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/device/domain/PendingMutation.kt @@ -6,7 +6,10 @@ package org.db3.airmq.sdk.device.domain enum class PendingMutationType { RENAME, LOCATION, - DATA_SHARING + DATA_SHARING, + NARODMON, + LUFTDATA, + VISIBILITY } /** diff --git a/temp.md b/temp.md new file mode 100644 index 0000000..e12568d --- /dev/null +++ b/temp.md @@ -0,0 +1,5 @@ +# Temporary & Implicit Implementations + +Items implemented implicitly or as obvious placeholders are listed here with important details for later refinement or replacement. + +---