feat(device): add visibility/luftdata/narodmon settings, device model icons, SDK refactor

- Add SetDeviceVisibilityUseCase, SetLuftdataUseCase, SetNarodmonUseCase

- Introduce DeviceModel enum (Basic, Mobile, Solar, Radiation, Custom)

- Add device-type drawables (active/inactive icons for each model)

- Refactor Device SDK: repository, DAO, database, local/remote data sources

- Update DeviceSettingsScreen, ManageViewModel, theme colors

- Add/update string resources (default, ru, be)

Made-with: Cursor
This commit is contained in:
2026-03-06 20:01:24 +01:00
parent 7815f151f1
commit 0519936531
41 changed files with 1251 additions and 138 deletions

View File

@@ -112,6 +112,87 @@ class DeviceRepositoryImpl @Inject constructor(
return Result.success(Unit)
}
override suspend fun setNarodmon(deviceId: String, enabled: Boolean): Result<Unit> {
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<Unit> {
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<Unit> {
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<Unit> =
remoteDataSource.triggerFirmwareUpdate(deviceId)

View File

@@ -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)

View File

@@ -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() {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<Unit>
/**
* Execute Narodmon.ru toggle. Phase 1: No-op. Phase 2: Apollo mutation.
*/
suspend fun setNarodmon(deviceId: String, enabled: Boolean): Result<Unit>
/**
* Execute Sensor.community (Luftdata) toggle. Phase 1: No-op. Phase 2: Apollo mutation.
*/
suspend fun setLuftdata(deviceId: String, enabled: Boolean): Result<Unit>
/**
* Execute set visibility (publish/hide) mutation. Phase 1: No-op. Phase 2: Apollo mutation.
*/
suspend fun setVisibility(deviceId: String, isPublic: Boolean): Result<Unit>
/**
* 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<Unit> =
Result.success(Unit)
override suspend fun setNarodmon(deviceId: String, enabled: Boolean): Result<Unit> =
Result.success(Unit)
override suspend fun setLuftdata(deviceId: String, enabled: Boolean): Result<Unit> =
Result.success(Unit)
override suspend fun setVisibility(deviceId: String, isPublic: Boolean): Result<Unit> =
Result.success(Unit)
override suspend fun triggerFirmwareUpdate(deviceId: String): Result<Unit> =
Result.success(Unit)
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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
}
}
}

View File

@@ -47,6 +47,22 @@ interface DeviceRepository {
*/
suspend fun setDataSharing(deviceId: String, enabled: Boolean): Result<Unit>
/**
* Enable or disable Narodmon.ru data sharing.
*/
suspend fun setNarodmon(deviceId: String, enabled: Boolean): Result<Unit>
/**
* Enable or disable Sensor.community (Luftdata) data sharing.
*/
suspend fun setLuftdata(deviceId: String, enabled: Boolean): Result<Unit>
/**
* Set device visibility (publish/hide location on map).
* Only effective when device has location.
*/
suspend fun setVisibility(deviceId: String, isPublic: Boolean): Result<Unit>
/**
* Trigger firmware update. Requires connectivity.
*

View File

@@ -6,7 +6,10 @@ package org.db3.airmq.sdk.device.domain
enum class PendingMutationType {
RENAME,
LOCATION,
DATA_SHARING
DATA_SHARING,
NARODMON,
LUFTDATA,
VISIBILITY
}
/**