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.
+
+---