feat(device): implement Device feature with SSOT, offline support, and settings screen
- Add domain layer: Device, PendingMutation, DeviceRepository - Add Room DB: DeviceEntity, PendingMutationEntity, DAOs, DeviceLocalDataSource - Add mock DeviceRemoteDataSource and DeviceSubscriptionManager - Implement DeviceRepositoryImpl with optimistic updates and mutation queue - Add UseCases: GetMyDevices, Rename, SetLocation, SetDataSharing, TriggerFirmware - Implement DeviceSettingsScreen with rename, location, data sharing, firmware - Wire ManageScreen to GetMyDevicesUseCase and DeviceSubscriptionManager - Update navigation to pass deviceId and show DeviceSettingsScreen - Add Room 2.7.0-alpha11 and Room dependencies to SDK Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
package org.db3.airmq.features.device
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import org.db3.airmq.R
|
||||
import org.db3.airmq.features.common.AirMQButton
|
||||
import org.db3.airmq.features.common.AirMQButtonStyle
|
||||
import org.db3.airmq.sdk.device.domain.OnlineStatus
|
||||
import org.db3.airmq.ui.theme.LegacyBackground
|
||||
|
||||
@Composable
|
||||
fun DeviceSettingsScreen(
|
||||
deviceId: String,
|
||||
onOpenLocation: () -> Unit,
|
||||
onShowOnMap: () -> Unit,
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: DeviceSettingsViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val toastDoneText = stringResource(R.string.toast_done)
|
||||
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.actions.collectLatest { action ->
|
||||
when (action) {
|
||||
is DeviceSettingsScreenContract.Action.ShowError -> {
|
||||
snackbarHostState.showSnackbar(action.message)
|
||||
}
|
||||
is DeviceSettingsScreenContract.Action.ShowSuccess -> {
|
||||
snackbarHostState.showSnackbar(toastDoneText)
|
||||
}
|
||||
is DeviceSettingsScreenContract.Action.OpenLocation -> onOpenLocation()
|
||||
is DeviceSettingsScreenContract.Action.ShowOnMap -> onShowOnMap()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(LegacyBackground)
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_arrow_back),
|
||||
contentDescription = stringResource(R.string.content_back)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.title_device),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(start = 8.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
when {
|
||||
uiState.isLoading -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
uiState.device == null -> {
|
||||
Text(
|
||||
text = stringResource(R.string.text_nothing_to_show),
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(16.dp)
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
DeviceSettingsContent(
|
||||
state = uiState,
|
||||
onEvent = viewModel::onEvent,
|
||||
deviceName = uiState.device?.name ?: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceSettingsContent(
|
||||
state: DeviceSettingsScreenContract.State,
|
||||
onEvent: (DeviceSettingsScreenContract.Event) -> Unit,
|
||||
deviceName: String
|
||||
) {
|
||||
val device = state.device!!
|
||||
var showRenameDialog by remember { mutableStateOf(false) }
|
||||
var renameText by remember(deviceName) { mutableStateOf(deviceName) }
|
||||
|
||||
if (showRenameDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showRenameDialog = false },
|
||||
title = { Text(stringResource(R.string.dialog_rename_title)) },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.hint_new_name),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
BasicTextField(
|
||||
value = renameText,
|
||||
onValueChange = { renameText = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
androidx.compose.material3.TextButton(
|
||||
onClick = {
|
||||
if (renameText.trim().length >= 3) {
|
||||
onEvent(DeviceSettingsScreenContract.Event.RenameSubmitted(renameText.trim()))
|
||||
showRenameDialog = false
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.button_rename))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
androidx.compose.material3.TextButton(onClick = { showRenameDialog = false }) {
|
||||
Text(stringResource(R.string.button_cancel))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
val scrollState = rememberScrollState()
|
||||
val statusIcon = when (device.toOnlineStatus()) {
|
||||
OnlineStatus.Online -> R.drawable.device_chip_online
|
||||
else -> R.drawable.device_chip_offline
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(scrollState)
|
||||
.background(LegacyBackground)
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_chip),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = device.name,
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
Text(
|
||||
text = device.model,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
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)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
AirMQButton(
|
||||
text = stringResource(R.string.button_rename),
|
||||
onClick = { showRenameDialog = true },
|
||||
style = AirMQButtonStyle.Outlined,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
if (device.hasLocation()) {
|
||||
Text(
|
||||
text = 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)
|
||||
)
|
||||
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()
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
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)) }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
AirMQButton(
|
||||
text = stringResource(R.string.button_firmware_update),
|
||||
onClick = { onEvent(DeviceSettingsScreenContract.Event.FirmwareUpdateClicked) },
|
||||
style = AirMQButtonStyle.Gradient,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.db3.airmq.features.device
|
||||
|
||||
import org.db3.airmq.sdk.device.domain.Device
|
||||
|
||||
object DeviceSettingsScreenContract {
|
||||
|
||||
data class State(
|
||||
val device: Device? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val isPendingSync: Boolean = false
|
||||
)
|
||||
|
||||
sealed interface Action {
|
||||
data class ShowError(val message: String) : Action
|
||||
data object ShowSuccess : Action
|
||||
data object OpenLocation : Action
|
||||
data object ShowOnMap : Action
|
||||
}
|
||||
|
||||
sealed interface Event {
|
||||
data class RenameSubmitted(val name: String) : 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package org.db3.airmq.features.device
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.update
|
||||
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.TriggerFirmwareUpdateUseCase
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class DeviceSettingsViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val getDeviceUseCase: GetDeviceUseCase,
|
||||
private val renameDeviceUseCase: RenameDeviceUseCase,
|
||||
private val setDeviceLocationUseCase: SetDeviceLocationUseCase,
|
||||
private val setDataSharingUseCase: SetDataSharingUseCase,
|
||||
private val triggerFirmwareUpdateUseCase: TriggerFirmwareUpdateUseCase,
|
||||
private val observePendingSyncUseCase: ObservePendingSyncUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
private val deviceId: String = checkNotNull(savedStateHandle.get<String>("deviceId"))
|
||||
|
||||
private val _uiState = MutableStateFlow(DeviceSettingsScreenContract.State())
|
||||
val uiState: StateFlow<DeviceSettingsScreenContract.State> = _uiState.asStateFlow()
|
||||
|
||||
private val _actions = MutableSharedFlow<DeviceSettingsScreenContract.Action>(extraBufferCapacity = 1)
|
||||
val actions: SharedFlow<DeviceSettingsScreenContract.Action> = _actions.asSharedFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
combine(
|
||||
getDeviceUseCase(deviceId),
|
||||
observePendingSyncUseCase(deviceId)
|
||||
) { device, hasPending ->
|
||||
device to hasPending
|
||||
}.collect { (device, hasPending) ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
device = device,
|
||||
isPendingSync = hasPending
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onEvent(event: DeviceSettingsScreenContract.Event) {
|
||||
when (event) {
|
||||
is DeviceSettingsScreenContract.Event.RenameSubmitted -> renameDevice(event.name)
|
||||
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
|
||||
)
|
||||
is DeviceSettingsScreenContract.Event.ShowOnMapClicked -> _actions.tryEmit(
|
||||
DeviceSettingsScreenContract.Action.ShowOnMap
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun renameDevice(name: String) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
val result = renameDeviceUseCase(deviceId, name)
|
||||
_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 rename"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setLocation(latitude: Double, longitude: Double) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
val result = setDeviceLocationUseCase.setLocation(deviceId, latitude, longitude)
|
||||
_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 set location"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeLocation() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
val result = setDeviceLocationUseCase.removeLocation(deviceId)
|
||||
_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 remove location"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setDataSharing(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
val result = setDataSharingUseCase(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 data sharing"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun triggerFirmwareUpdate() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
val result = triggerFirmwareUpdateUseCase(deviceId)
|
||||
_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 trigger firmware update"
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.db3.airmq.features.device.usecases
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.db3.airmq.sdk.device.domain.Device
|
||||
import org.db3.airmq.sdk.device.domain.DeviceRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Use case to observe a single device by ID.
|
||||
*/
|
||||
class GetDeviceUseCase @Inject constructor(
|
||||
private val repository: DeviceRepository
|
||||
) {
|
||||
operator fun invoke(deviceId: String): Flow<Device?> = repository.observeDevice(deviceId)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.db3.airmq.features.device.usecases
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.db3.airmq.sdk.device.domain.Device
|
||||
import org.db3.airmq.sdk.device.domain.DeviceRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Use case to observe the current user's devices from local database.
|
||||
*/
|
||||
class GetMyDevicesUseCase @Inject constructor(
|
||||
private val repository: DeviceRepository
|
||||
) {
|
||||
operator fun invoke(): Flow<List<Device>> = repository.observeDevices()
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.db3.airmq.features.device.usecases
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.db3.airmq.sdk.device.domain.DeviceRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Use case to observe whether a device has pending mutations (offline changes to sync).
|
||||
*/
|
||||
class ObservePendingSyncUseCase @Inject constructor(
|
||||
private val repository: DeviceRepository
|
||||
) {
|
||||
operator fun invoke(deviceId: String): Flow<Boolean> =
|
||||
repository.observeHasPendingMutations(deviceId)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package org.db3.airmq.features.device.usecases
|
||||
|
||||
import org.db3.airmq.sdk.device.domain.DeviceRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Use case to rename a device.
|
||||
* Validates input and delegates to repository.
|
||||
*/
|
||||
class RenameDeviceUseCase @Inject constructor(
|
||||
private val repository: DeviceRepository
|
||||
) {
|
||||
suspend operator fun invoke(deviceId: String, newName: String): Result<Unit> {
|
||||
val trimmed = newName.trim()
|
||||
if (trimmed.length < 3) {
|
||||
return Result.failure(IllegalArgumentException("Name must be at least 3 characters"))
|
||||
}
|
||||
return repository.renameDevice(deviceId, trimmed)
|
||||
}
|
||||
}
|
||||
@@ -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 data sharing for a device.
|
||||
*/
|
||||
class SetDataSharingUseCase @Inject constructor(
|
||||
private val repository: DeviceRepository
|
||||
) {
|
||||
suspend operator fun invoke(deviceId: String, enabled: Boolean): Result<Unit> =
|
||||
repository.setDataSharing(deviceId, enabled)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.db3.airmq.features.device.usecases
|
||||
|
||||
import org.db3.airmq.sdk.device.domain.DeviceRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Use case to set or remove device location.
|
||||
*/
|
||||
class SetDeviceLocationUseCase @Inject constructor(
|
||||
private val repository: DeviceRepository
|
||||
) {
|
||||
suspend fun setLocation(deviceId: String, latitude: Double, longitude: Double): Result<Unit> =
|
||||
repository.setLocation(deviceId, latitude, longitude)
|
||||
|
||||
suspend fun removeLocation(deviceId: String): Result<Unit> =
|
||||
repository.removeLocation(deviceId)
|
||||
}
|
||||
@@ -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 trigger firmware update for a device.
|
||||
* Requires connectivity - blocks when offline.
|
||||
*/
|
||||
class TriggerFirmwareUpdateUseCase @Inject constructor(
|
||||
private val repository: DeviceRepository
|
||||
) {
|
||||
suspend operator fun invoke(deviceId: String): Result<Unit> =
|
||||
repository.triggerFirmwareUpdate(deviceId)
|
||||
}
|
||||
@@ -53,7 +53,7 @@ import org.db3.airmq.ui.theme.LegacyNavGradientStart
|
||||
|
||||
@Composable
|
||||
fun ManageScreen(
|
||||
onOpenDevice: () -> Unit,
|
||||
onOpenDevice: (String) -> Unit,
|
||||
onOpenSetup: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenLogin: () -> Unit,
|
||||
@@ -68,7 +68,7 @@ fun ManageScreen(
|
||||
Action.OpenLogin -> onOpenLogin()
|
||||
Action.OpenSettings -> onOpenSettings()
|
||||
Action.OpenSetup -> onOpenSetup()
|
||||
is Action.OpenDevice -> onOpenDevice()
|
||||
is Action.OpenDevice -> onOpenDevice(action.deviceId)
|
||||
is Action.OpenLocation -> onOpenLocation()
|
||||
is Action.OpenAddLocation -> onOpenAddLocation()
|
||||
}
|
||||
|
||||
@@ -6,25 +6,31 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.db3.airmq.R
|
||||
import org.db3.airmq.features.device.usecases.GetMyDevicesUseCase
|
||||
import org.db3.airmq.features.manage.ManageScreenContract.Action
|
||||
import org.db3.airmq.features.manage.ManageScreenContract.DeviceItem
|
||||
import org.db3.airmq.features.manage.ManageScreenContract.Event
|
||||
import org.db3.airmq.features.manage.ManageScreenContract.State
|
||||
import org.db3.airmq.sdk.auth.AuthService
|
||||
import org.db3.airmq.sdk.auth.model.User
|
||||
import org.db3.airmq.sdk.device.data.remote.DeviceSubscriptionManager
|
||||
import org.db3.airmq.sdk.device.domain.OnlineStatus
|
||||
|
||||
@HiltViewModel
|
||||
class ManageViewModel @Inject constructor(
|
||||
@ApplicationContext private val appContext: Context,
|
||||
private val authService: AuthService
|
||||
private val authService: AuthService,
|
||||
private val getMyDevicesUseCase: GetMyDevicesUseCase,
|
||||
private val subscriptionManager: DeviceSubscriptionManager
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(initialState())
|
||||
@@ -35,6 +41,21 @@ class ManageViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
refreshAuthState()
|
||||
viewModelScope.launch {
|
||||
getMyDevicesUseCase().collect { devices ->
|
||||
val session = authService.getUser()
|
||||
if (session?.isAuthenticated == true) {
|
||||
val user = session
|
||||
_uiState.update { state ->
|
||||
if (state.isAuthorized) {
|
||||
state.copy(
|
||||
devices = devices.map { device -> device.toDeviceItem(appContext) }
|
||||
)
|
||||
} else state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onEvent(event: Event) {
|
||||
@@ -53,10 +74,12 @@ class ManageViewModel @Inject constructor(
|
||||
private fun refreshAuthState() {
|
||||
viewModelScope.launch {
|
||||
val session = authService.getUser()
|
||||
_uiState.value = if (session?.isAuthenticated == true) {
|
||||
authorizedState(session)
|
||||
if (session?.isAuthenticated == true) {
|
||||
subscriptionManager.start()
|
||||
_uiState.value = authorizedState(session)
|
||||
} else {
|
||||
anonymousState()
|
||||
subscriptionManager.stop()
|
||||
_uiState.value = anonymousState()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,21 +96,21 @@ class ManageViewModel @Inject constructor(
|
||||
userName = user.displayName ?: appContext.getString(R.string.text_anonymous_user),
|
||||
userEmail = user.email ?: "",
|
||||
devicesLabel = "",
|
||||
devices = listOf(
|
||||
DeviceItem(
|
||||
id = "device-1",
|
||||
name = appContext.getString(R.string.mock_device_name_42),
|
||||
extra = "mobile",
|
||||
status = appContext.getString(R.string.map_status_online),
|
||||
hasLocation = true
|
||||
),
|
||||
DeviceItem(
|
||||
id = "device-2",
|
||||
name = appContext.getString(R.string.mock_device_name_17),
|
||||
extra = "mobile",
|
||||
status = appContext.getString(R.string.map_status_offline),
|
||||
hasLocation = false
|
||||
)
|
||||
)
|
||||
devices = emptyList()
|
||||
)
|
||||
|
||||
private fun org.db3.airmq.sdk.device.domain.Device.toDeviceItem(context: Context): DeviceItem {
|
||||
val statusText = when (toOnlineStatus()) {
|
||||
OnlineStatus.Online -> context.getString(R.string.map_status_online)
|
||||
OnlineStatus.Offline -> context.getString(R.string.map_status_offline)
|
||||
OnlineStatus.Unknown, OnlineStatus.Stale -> context.getString(R.string.map_status_offline)
|
||||
}
|
||||
return DeviceItem(
|
||||
id = id,
|
||||
name = name,
|
||||
extra = model.ifEmpty { "mobile" },
|
||||
status = statusText,
|
||||
hasLocation = hasLocation()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ import org.db3.airmq.features.constructor.SelectMapWidgetLocationScreen
|
||||
import org.db3.airmq.features.constructor.WidgetConstructorScreen
|
||||
import org.db3.airmq.features.dashboard.DashboardScreen
|
||||
import org.db3.airmq.features.debug.DebugScreen
|
||||
import org.db3.airmq.features.device.DeviceScreen
|
||||
import org.db3.airmq.features.device.DeviceSettingsScreen
|
||||
import org.db3.airmq.features.entry.SplashScreen
|
||||
import org.db3.airmq.features.entry.WizardScreen
|
||||
import org.db3.airmq.features.location.LocationScreen
|
||||
@@ -147,7 +147,9 @@ fun AirMQNavGraph(modifier: Modifier = Modifier) {
|
||||
}
|
||||
composable(AirMqRoutes.MANAGE) {
|
||||
ManageScreen(
|
||||
onOpenDevice = { navController.navigate(AirMqRoutes.device()) },
|
||||
onOpenDevice = { deviceId ->
|
||||
navController.navigate(AirMqRoutes.device(deviceId))
|
||||
},
|
||||
onOpenSetup = { navController.navigate(AirMqRoutes.SETUP) },
|
||||
onOpenSettings = { navController.navigate(AirMqRoutes.SETTINGS) },
|
||||
onOpenLogin = { navController.navigate(AirMqRoutes.LOGIN) },
|
||||
@@ -164,10 +166,11 @@ fun AirMQNavGraph(modifier: Modifier = Modifier) {
|
||||
}
|
||||
)
|
||||
) { backStackEntry ->
|
||||
DeviceScreen(
|
||||
DeviceSettingsScreen(
|
||||
deviceId = backStackEntry.arguments?.getString("deviceId") ?: "mock-device-id",
|
||||
onOpenLocation = { navController.navigate(AirMqRoutes.LOCATION) },
|
||||
onShowOnMap = { navController.navigate(AirMqRoutes.MAP) }
|
||||
onShowOnMap = { navController.navigate(AirMqRoutes.MAP) },
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
composable(AirMqRoutes.CITY) {
|
||||
|
||||
@@ -162,6 +162,9 @@
|
||||
<string name="text_device_visibility_visible">Public</string>
|
||||
<string name="text_device_visibility_private">Private</string>
|
||||
<string name="text_device_visibility_not_registered">Register device to change visibility</string>
|
||||
<string name="text_pending_sync">Changes will sync when online</string>
|
||||
<string name="text_data_sharing">Data sharing</string>
|
||||
<string name="button_firmware_update">Update firmware</string>
|
||||
<string name="button_rename">Rename</string>
|
||||
<string name="button_update">Update</string>
|
||||
<string name="button_unregister">Unregister</string>
|
||||
|
||||
@@ -24,6 +24,7 @@ androidxCredentials = "1.5.0"
|
||||
googleid = "1.1.1"
|
||||
kotlinxCoroutinesTest = "1.9.0"
|
||||
mockk = "1.13.13"
|
||||
room = "2.7.0-alpha11"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -59,6 +60,9 @@ androidx-credentials-play-services-auth = { group = "androidx.credentials", name
|
||||
googleid = { group = "com.google.android.libraries.identity.googleid", name = "googleid", version.ref = "googleid" }
|
||||
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }
|
||||
mockk-jvm = { group = "io.mockk", name = "mockk-jvm", version.ref = "mockk" }
|
||||
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
@@ -47,6 +47,11 @@ apollo {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Room
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
|
||||
// Apollo GraphQL
|
||||
implementation(libs.apollo.runtime)
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package org.db3.airmq.sdk.device.data
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.db3.airmq.sdk.device.data.local.DeviceLocalDataSource
|
||||
import org.db3.airmq.sdk.device.data.remote.DeviceRemoteDataSource
|
||||
import org.db3.airmq.sdk.device.domain.Device
|
||||
import org.db3.airmq.sdk.device.domain.DeviceRepository
|
||||
import org.db3.airmq.sdk.device.domain.OnlineFreshness
|
||||
import org.db3.airmq.sdk.device.domain.PendingMutation
|
||||
import org.db3.airmq.sdk.device.domain.PendingMutationType
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Implementation of DeviceRepository.
|
||||
* Local DB is SSOT; subscription updates DB; mutations use optimistic update + queue.
|
||||
*/
|
||||
class DeviceRepositoryImpl @Inject constructor(
|
||||
private val localDataSource: DeviceLocalDataSource,
|
||||
private val remoteDataSource: DeviceRemoteDataSource
|
||||
) : DeviceRepository {
|
||||
|
||||
override fun observeDevices(): Flow<List<Device>> =
|
||||
localDataSource.observeDevices()
|
||||
|
||||
override fun observeDevice(deviceId: String): Flow<Device?> =
|
||||
localDataSource.observeDevice(deviceId)
|
||||
|
||||
override suspend fun renameDevice(deviceId: String, newName: String): Result<Unit> {
|
||||
val current = localDataSource.getDevice(deviceId) ?: return Result.failure(NoSuchElementException("Device not found: $deviceId"))
|
||||
val previousName = current.name
|
||||
|
||||
localDataSource.updateName(deviceId, newName)
|
||||
val mutationId = UUID.randomUUID().toString()
|
||||
localDataSource.enqueuePendingMutation(
|
||||
PendingMutation(
|
||||
id = mutationId,
|
||||
type = PendingMutationType.RENAME,
|
||||
deviceId = deviceId,
|
||||
payload = """{"name":"$newName"}""",
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
|
||||
val result = remoteDataSource.renameDevice(deviceId, newName)
|
||||
if (result.isSuccess) {
|
||||
localDataSource.removePendingMutationsForDevice(deviceId, PendingMutationType.RENAME)
|
||||
} else {
|
||||
localDataSource.updateName(deviceId, previousName)
|
||||
localDataSource.removePendingMutationsForDevice(deviceId, PendingMutationType.RENAME)
|
||||
return result
|
||||
}
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun setLocation(deviceId: String, latitude: Double, longitude: Double): Result<Unit> {
|
||||
localDataSource.updateLocation(deviceId, latitude, longitude, null)
|
||||
val mutationId = UUID.randomUUID().toString()
|
||||
localDataSource.enqueuePendingMutation(
|
||||
PendingMutation(
|
||||
id = mutationId,
|
||||
type = PendingMutationType.LOCATION,
|
||||
deviceId = deviceId,
|
||||
payload = """{"latitude":$latitude,"longitude":$longitude}""",
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
|
||||
val result = remoteDataSource.setLocation(deviceId, latitude, longitude)
|
||||
if (result.isSuccess) {
|
||||
localDataSource.removePendingMutationsForDevice(deviceId, PendingMutationType.LOCATION)
|
||||
} else {
|
||||
localDataSource.updateLocation(deviceId, null, null, null)
|
||||
localDataSource.removePendingMutationsForDevice(deviceId, PendingMutationType.LOCATION)
|
||||
return result
|
||||
}
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun removeLocation(deviceId: String): Result<Unit> {
|
||||
localDataSource.updateLocation(deviceId, null, null, null)
|
||||
val result = remoteDataSource.removeLocation(deviceId)
|
||||
localDataSource.removePendingMutationsForDevice(deviceId, PendingMutationType.LOCATION)
|
||||
return result
|
||||
}
|
||||
|
||||
override suspend fun setDataSharing(deviceId: String, enabled: Boolean): Result<Unit> {
|
||||
val current = localDataSource.getDevice(deviceId) ?: return Result.failure(NoSuchElementException("Device not found: $deviceId"))
|
||||
val previous = current.dataSharingEnabled
|
||||
|
||||
localDataSource.updateDataSharing(deviceId, enabled)
|
||||
val mutationId = UUID.randomUUID().toString()
|
||||
localDataSource.enqueuePendingMutation(
|
||||
PendingMutation(
|
||||
id = mutationId,
|
||||
type = PendingMutationType.DATA_SHARING,
|
||||
deviceId = deviceId,
|
||||
payload = """{"enabled":$enabled}""",
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
)
|
||||
|
||||
val result = remoteDataSource.setDataSharing(deviceId, enabled)
|
||||
if (result.isSuccess) {
|
||||
localDataSource.removePendingMutationsForDevice(deviceId, PendingMutationType.DATA_SHARING)
|
||||
} else {
|
||||
localDataSource.updateDataSharing(deviceId, previous)
|
||||
localDataSource.removePendingMutationsForDevice(deviceId, PendingMutationType.DATA_SHARING)
|
||||
return result
|
||||
}
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun triggerFirmwareUpdate(deviceId: String): Result<Unit> =
|
||||
remoteDataSource.triggerFirmwareUpdate(deviceId)
|
||||
|
||||
override fun observeHasPendingMutations(deviceId: String): Flow<Boolean> =
|
||||
localDataSource.observeHasPendingMutations(deviceId)
|
||||
|
||||
override suspend fun markOnlineStatusStale() {
|
||||
localDataSource.markAllOnlineStatusStale()
|
||||
}
|
||||
|
||||
override suspend fun refreshFromSubscription(devices: List<Device>) {
|
||||
val withFreshness = devices.map { it.copy(onlineFreshness = OnlineFreshness.Fresh) }
|
||||
localDataSource.upsertDevices(withFreshness)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.db3.airmq.sdk.device.data.local
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface DeviceDao {
|
||||
|
||||
@Query("SELECT * FROM device ORDER BY name ASC")
|
||||
fun observeDevices(): Flow<List<DeviceEntity>>
|
||||
|
||||
@Query("SELECT * FROM device WHERE id = :deviceId")
|
||||
fun observeDevice(deviceId: String): Flow<DeviceEntity?>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertDevices(devices: List<DeviceEntity>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertDevice(device: DeviceEntity)
|
||||
|
||||
@Query("UPDATE device SET name = :newName WHERE id = :deviceId")
|
||||
suspend fun updateName(deviceId: String, newName: String)
|
||||
|
||||
@Query("UPDATE device SET latitude = :latitude, longitude = :longitude, city = :city WHERE id = :deviceId")
|
||||
suspend fun updateLocation(deviceId: String, latitude: Double?, longitude: Double?, city: String?)
|
||||
|
||||
@Query("UPDATE device SET dataSharingEnabled = :enabled WHERE id = :deviceId")
|
||||
suspend fun updateDataSharing(deviceId: String, enabled: Boolean)
|
||||
|
||||
@Query("UPDATE device SET isOnline = :isOnline, isOnlineUpdatedAt = :updatedAt WHERE id = :deviceId")
|
||||
suspend fun updateOnlineStatus(deviceId: String, isOnline: Boolean, updatedAt: Long)
|
||||
|
||||
@Query("UPDATE device SET isOnlineUpdatedAt = NULL")
|
||||
suspend fun markAllOnlineStatusStale()
|
||||
|
||||
@Query("SELECT * FROM device WHERE id = :deviceId")
|
||||
suspend fun getDevice(deviceId: String): DeviceEntity?
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface PendingMutationDao {
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(mutation: PendingMutationEntity)
|
||||
|
||||
@Query("DELETE FROM pending_mutation WHERE id = :id")
|
||||
suspend fun deleteById(id: String)
|
||||
|
||||
@Query("SELECT * FROM pending_mutation WHERE deviceId = :deviceId ORDER BY createdAt ASC")
|
||||
fun observePendingMutationsForDevice(deviceId: String): Flow<List<PendingMutationEntity>>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM pending_mutation WHERE deviceId = :deviceId")
|
||||
fun observePendingMutationCount(deviceId: String): Flow<Int>
|
||||
|
||||
@Query("SELECT * FROM pending_mutation ORDER BY createdAt ASC")
|
||||
suspend fun getAllPending(): List<PendingMutationEntity>
|
||||
|
||||
@Query("DELETE FROM pending_mutation WHERE deviceId = :deviceId AND type = :type")
|
||||
suspend fun deleteByDeviceAndType(deviceId: String, type: String)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.db3.airmq.sdk.device.data.local
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
@Database(
|
||||
entities = [DeviceEntity::class, PendingMutationEntity::class],
|
||||
version = 1,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class DeviceDatabase : RoomDatabase() {
|
||||
abstract fun deviceDao(): DeviceDao
|
||||
abstract fun pendingMutationDao(): PendingMutationDao
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.db3.airmq.sdk.device.data.local
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Room entity for device storage.
|
||||
* isOnline is stored with isOnlineUpdatedAt for freshness tracking.
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "device",
|
||||
indices = [Index(value = ["ownerId"])]
|
||||
)
|
||||
data class DeviceEntity(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val name: String,
|
||||
val model: String,
|
||||
val firmwareVersion: String,
|
||||
val locationId: String? = null,
|
||||
val latitude: Double? = null,
|
||||
val longitude: Double? = null,
|
||||
val dataSharingEnabled: Boolean = false,
|
||||
val isOnline: Boolean = false,
|
||||
val isOnlineUpdatedAt: Long? = null,
|
||||
val ownerId: String? = null,
|
||||
val city: String? = null
|
||||
)
|
||||
@@ -0,0 +1,129 @@
|
||||
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.OnlineFreshness
|
||||
import org.db3.airmq.sdk.device.domain.PendingMutation
|
||||
import org.db3.airmq.sdk.device.domain.PendingMutationType
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Local data source for devices and pending mutations.
|
||||
* Maps between Room entities and domain models.
|
||||
*/
|
||||
class DeviceLocalDataSource @Inject constructor(
|
||||
private val deviceDao: DeviceDao,
|
||||
private val pendingMutationDao: PendingMutationDao
|
||||
) {
|
||||
private val onlineStatusTtlMs = 5 * 60 * 1000L // 5 minutes
|
||||
|
||||
fun observeDevices(): Flow<List<Device>> =
|
||||
deviceDao.observeDevices().map { entities ->
|
||||
entities.map { it.toDomain() }
|
||||
}
|
||||
|
||||
fun observeDevice(deviceId: String): Flow<Device?> =
|
||||
deviceDao.observeDevice(deviceId).map { it?.toDomain() }
|
||||
|
||||
suspend fun upsertDevices(devices: List<Device>) {
|
||||
val entities = devices.map { it.toEntity() }
|
||||
deviceDao.upsertDevices(entities)
|
||||
}
|
||||
|
||||
suspend fun upsertDevice(device: Device) {
|
||||
deviceDao.upsertDevice(device.toEntity())
|
||||
}
|
||||
|
||||
suspend fun updateName(deviceId: String, newName: String) {
|
||||
deviceDao.updateName(deviceId, newName)
|
||||
}
|
||||
|
||||
suspend fun updateLocation(deviceId: String, latitude: Double?, longitude: Double?, city: String?) {
|
||||
deviceDao.updateLocation(deviceId, latitude, longitude, city)
|
||||
}
|
||||
|
||||
suspend fun updateDataSharing(deviceId: String, enabled: Boolean) {
|
||||
deviceDao.updateDataSharing(deviceId, enabled)
|
||||
}
|
||||
|
||||
suspend fun updateOnlineStatus(deviceId: String, isOnline: Boolean, updatedAt: Long) {
|
||||
deviceDao.updateOnlineStatus(deviceId, isOnline, updatedAt)
|
||||
}
|
||||
|
||||
suspend fun markAllOnlineStatusStale() {
|
||||
deviceDao.markAllOnlineStatusStale()
|
||||
}
|
||||
|
||||
suspend fun getDevice(deviceId: String): Device? = deviceDao.getDevice(deviceId)?.toDomain()
|
||||
|
||||
suspend fun enqueuePendingMutation(mutation: PendingMutation) {
|
||||
val entity = PendingMutationEntity(
|
||||
id = mutation.id,
|
||||
type = mutation.type.name,
|
||||
deviceId = mutation.deviceId,
|
||||
payload = mutation.payload,
|
||||
createdAt = mutation.createdAt
|
||||
)
|
||||
pendingMutationDao.insert(entity)
|
||||
}
|
||||
|
||||
fun observeHasPendingMutations(deviceId: String): Flow<Boolean> =
|
||||
pendingMutationDao.observePendingMutationCount(deviceId).map { it > 0 }
|
||||
|
||||
suspend fun getPendingMutations(): List<PendingMutation> =
|
||||
pendingMutationDao.getAllPending().map { it.toDomain() }
|
||||
|
||||
suspend fun removePendingMutation(id: String) {
|
||||
pendingMutationDao.deleteById(id)
|
||||
}
|
||||
|
||||
suspend fun removePendingMutationsForDevice(deviceId: String, type: PendingMutationType) {
|
||||
pendingMutationDao.deleteByDeviceAndType(deviceId, type.name)
|
||||
}
|
||||
|
||||
private fun DeviceEntity.toDomain(): Device {
|
||||
val freshness = when {
|
||||
isOnlineUpdatedAt == null -> OnlineFreshness.Unknown
|
||||
System.currentTimeMillis() - isOnlineUpdatedAt > onlineStatusTtlMs -> OnlineFreshness.Stale
|
||||
else -> OnlineFreshness.Fresh
|
||||
}
|
||||
return Device(
|
||||
id = id,
|
||||
name = name,
|
||||
model = model,
|
||||
firmwareVersion = firmwareVersion,
|
||||
locationId = locationId,
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
city = city,
|
||||
dataSharingEnabled = dataSharingEnabled,
|
||||
isOnline = isOnline,
|
||||
onlineFreshness = freshness,
|
||||
ownerId = ownerId
|
||||
)
|
||||
}
|
||||
|
||||
private fun Device.toEntity(): DeviceEntity = DeviceEntity(
|
||||
id = id,
|
||||
name = name,
|
||||
model = model,
|
||||
firmwareVersion = firmwareVersion,
|
||||
locationId = locationId,
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
dataSharingEnabled = dataSharingEnabled,
|
||||
isOnline = isOnline,
|
||||
isOnlineUpdatedAt = if (onlineFreshness == OnlineFreshness.Fresh) System.currentTimeMillis() else null,
|
||||
ownerId = ownerId,
|
||||
city = city
|
||||
)
|
||||
|
||||
private fun PendingMutationEntity.toDomain(): PendingMutation = PendingMutation(
|
||||
id = id,
|
||||
type = PendingMutationType.valueOf(type),
|
||||
deviceId = deviceId,
|
||||
payload = payload,
|
||||
createdAt = createdAt
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.db3.airmq.sdk.device.data.local
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Room entity for pending mutations in the offline queue.
|
||||
*/
|
||||
@Entity(
|
||||
tableName = "pending_mutation",
|
||||
indices = [Index(value = ["deviceId"]), Index(value = ["createdAt"])]
|
||||
)
|
||||
data class PendingMutationEntity(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val type: String,
|
||||
val deviceId: String,
|
||||
val payload: String,
|
||||
val createdAt: Long
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
package org.db3.airmq.sdk.device.data.remote
|
||||
|
||||
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.OnlineFreshness
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Remote data source for devices.
|
||||
* Phase 1: Returns mock data. Phase 2: Apollo subscription + mutations.
|
||||
*/
|
||||
interface DeviceRemoteDataSource {
|
||||
|
||||
/**
|
||||
* Observe device list from GraphQL subscription.
|
||||
* Phase 1: Mock flow. Phase 2: Apollo subscription.
|
||||
*/
|
||||
fun observeDevicesSubscription(): Flow<List<Device>>
|
||||
|
||||
/**
|
||||
* Execute rename mutation. Phase 1: No-op. Phase 2: Apollo mutation.
|
||||
*/
|
||||
suspend fun renameDevice(deviceId: String, newName: String): Result<Unit>
|
||||
|
||||
/**
|
||||
* Execute set location mutation. Phase 1: No-op. Phase 2: Apollo mutation.
|
||||
*/
|
||||
suspend fun setLocation(deviceId: String, latitude: Double, longitude: Double): Result<Unit>
|
||||
|
||||
/**
|
||||
* Execute remove location mutation. Phase 1: No-op. Phase 2: Apollo mutation.
|
||||
*/
|
||||
suspend fun removeLocation(deviceId: String): Result<Unit>
|
||||
|
||||
/**
|
||||
* Execute data sharing mutation. Phase 1: No-op. Phase 2: Apollo mutation.
|
||||
*/
|
||||
suspend fun setDataSharing(deviceId: String, enabled: Boolean): Result<Unit>
|
||||
|
||||
/**
|
||||
* Execute firmware update. Phase 1: No-op. Phase 2: Apollo mutation.
|
||||
*/
|
||||
suspend fun triggerFirmwareUpdate(deviceId: String): Result<Unit>
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock implementation for Phase 1.
|
||||
*/
|
||||
class MockDeviceRemoteDataSource @Inject constructor() : DeviceRemoteDataSource {
|
||||
|
||||
override fun observeDevicesSubscription(): Flow<List<Device>> = flow {
|
||||
// Emit mock device list
|
||||
val mockDevices = listOf(
|
||||
Device(
|
||||
id = "device-1",
|
||||
name = "AirMQ #42",
|
||||
model = "mobile",
|
||||
firmwareVersion = "1.0",
|
||||
locationId = "loc-1",
|
||||
latitude = 53.9,
|
||||
longitude = 27.5,
|
||||
city = "Minsk",
|
||||
dataSharingEnabled = true,
|
||||
isOnline = true,
|
||||
onlineFreshness = OnlineFreshness.Fresh,
|
||||
ownerId = "user-1"
|
||||
),
|
||||
Device(
|
||||
id = "device-2",
|
||||
name = "AirMQ #17",
|
||||
model = "mobile",
|
||||
firmwareVersion = "1.0",
|
||||
locationId = null,
|
||||
latitude = null,
|
||||
longitude = null,
|
||||
city = null,
|
||||
dataSharingEnabled = false,
|
||||
isOnline = false,
|
||||
onlineFreshness = OnlineFreshness.Fresh,
|
||||
ownerId = "user-1"
|
||||
)
|
||||
)
|
||||
emit(mockDevices)
|
||||
// Keep flow active and re-emit periodically to simulate subscription updates
|
||||
while (true) {
|
||||
delay(30_000)
|
||||
emit(mockDevices)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun renameDevice(deviceId: String, newName: String): Result<Unit> =
|
||||
Result.success(Unit)
|
||||
|
||||
override suspend fun setLocation(deviceId: String, latitude: Double, longitude: Double): Result<Unit> =
|
||||
Result.success(Unit)
|
||||
|
||||
override suspend fun removeLocation(deviceId: String): Result<Unit> =
|
||||
Result.success(Unit)
|
||||
|
||||
override suspend fun setDataSharing(deviceId: String, enabled: Boolean): Result<Unit> =
|
||||
Result.success(Unit)
|
||||
|
||||
override suspend fun triggerFirmwareUpdate(deviceId: String): Result<Unit> =
|
||||
Result.success(Unit)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package org.db3.airmq.sdk.device.data.remote
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import org.db3.airmq.sdk.device.domain.DeviceRepository
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Manages the GraphQL subscription lifecycle.
|
||||
* Subscribes when user is authenticated and network available.
|
||||
* On each payload, upserts devices into local DB via repository.
|
||||
*/
|
||||
@Singleton
|
||||
class DeviceSubscriptionManager @Inject constructor(
|
||||
private val remoteDataSource: DeviceRemoteDataSource,
|
||||
private val repository: DeviceRepository
|
||||
) {
|
||||
private var scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var isSubscribed = false
|
||||
|
||||
/**
|
||||
* Start the subscription. Call when user is authenticated.
|
||||
*/
|
||||
fun start() {
|
||||
if (isSubscribed) return
|
||||
isSubscribed = true
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
remoteDataSource.observeDevicesSubscription()
|
||||
.catch {
|
||||
scope.launch {
|
||||
repository.markOnlineStatusStale()
|
||||
}
|
||||
}
|
||||
.onEach { devices ->
|
||||
repository.refreshFromSubscription(devices)
|
||||
}
|
||||
.launchIn(scope)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the subscription. Call on logout or when going offline.
|
||||
*/
|
||||
fun stop() {
|
||||
isSubscribed = false
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when subscription disconnects. Marks isOnline as stale.
|
||||
*/
|
||||
fun onDisconnected() {
|
||||
scope.launch {
|
||||
repository.markOnlineStatusStale()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package org.db3.airmq.sdk.device.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
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.DeviceDao
|
||||
import org.db3.airmq.sdk.device.data.local.DeviceDatabase
|
||||
import org.db3.airmq.sdk.device.data.local.PendingMutationDao
|
||||
import org.db3.airmq.sdk.device.data.remote.DeviceRemoteDataSource
|
||||
import org.db3.airmq.sdk.device.data.remote.MockDeviceRemoteDataSource
|
||||
import org.db3.airmq.sdk.device.domain.DeviceRepository
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DeviceDatabaseModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDeviceDatabase(@ApplicationContext context: Context): DeviceDatabase =
|
||||
Room.databaseBuilder(
|
||||
context,
|
||||
DeviceDatabase::class.java,
|
||||
"airmq_device_db"
|
||||
).build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDeviceDao(database: DeviceDatabase): DeviceDao = database.deviceDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePendingMutationDao(database: DeviceDatabase): PendingMutationDao =
|
||||
database.pendingMutationDao()
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class DeviceBindModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindDeviceRepository(impl: DeviceRepositoryImpl): DeviceRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindDeviceRemoteDataSource(impl: MockDeviceRemoteDataSource): DeviceRemoteDataSource
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package org.db3.airmq.sdk.device.domain
|
||||
|
||||
/**
|
||||
* Online status representation for UI display.
|
||||
* Distinguishes between known, unknown, and stale states.
|
||||
*/
|
||||
sealed class OnlineStatus {
|
||||
data object Online : OnlineStatus()
|
||||
data object Offline : OnlineStatus()
|
||||
data object Unknown : OnlineStatus()
|
||||
data object Stale : OnlineStatus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Freshness of the isOnline value.
|
||||
* Used to determine when to mark ephemeral state as stale.
|
||||
*/
|
||||
enum class OnlineFreshness {
|
||||
Fresh,
|
||||
Stale,
|
||||
Unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain model for an AirMQ device.
|
||||
*
|
||||
* @param id Unique device identifier
|
||||
* @param name Display name
|
||||
* @param model Device model identifier
|
||||
* @param firmwareVersion Firmware version string
|
||||
* @param locationId Optional location identifier
|
||||
* @param latitude Optional latitude
|
||||
* @param longitude Optional longitude
|
||||
* @param city Optional city name for the location
|
||||
* @param dataSharingEnabled Whether data sharing is enabled
|
||||
* @param isOnline Whether the device is currently online
|
||||
* @param onlineFreshness Freshness of the isOnline value (Fresh/Stale/Unknown)
|
||||
* @param ownerId Optional owner user ID
|
||||
*/
|
||||
data class Device(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val model: String,
|
||||
val firmwareVersion: String,
|
||||
val locationId: String? = null,
|
||||
val latitude: Double? = null,
|
||||
val longitude: Double? = null,
|
||||
val city: String? = null,
|
||||
val dataSharingEnabled: Boolean = false,
|
||||
val isOnline: Boolean = false,
|
||||
val onlineFreshness: OnlineFreshness = OnlineFreshness.Unknown,
|
||||
val ownerId: String? = null
|
||||
) {
|
||||
fun hasLocation(): Boolean =
|
||||
latitude != null && longitude != null && latitude != 0.0 && longitude != 0.0
|
||||
|
||||
fun toOnlineStatus(): OnlineStatus = when (onlineFreshness) {
|
||||
OnlineFreshness.Fresh -> if (isOnline) OnlineStatus.Online else OnlineStatus.Offline
|
||||
OnlineFreshness.Stale -> OnlineStatus.Stale
|
||||
OnlineFreshness.Unknown -> OnlineStatus.Unknown
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.db3.airmq.sdk.device.domain
|
||||
|
||||
/**
|
||||
* Domain model for a device's geographic location.
|
||||
*/
|
||||
data class DeviceLocation(
|
||||
val deviceId: String,
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val city: String? = null
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.db3.airmq.sdk.device.domain
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Repository interface for device data.
|
||||
* Local database is the single source of truth; UI reads only from this.
|
||||
*/
|
||||
interface DeviceRepository {
|
||||
|
||||
/**
|
||||
* Observe the current user's devices from local database.
|
||||
* Never reads directly from network.
|
||||
*/
|
||||
fun observeDevices(): Flow<List<Device>>
|
||||
|
||||
/**
|
||||
* Observe a single device by ID.
|
||||
*/
|
||||
fun observeDevice(deviceId: String): Flow<Device?>
|
||||
|
||||
/**
|
||||
* Rename a device. Optimistic update + enqueue for sync when online.
|
||||
*
|
||||
* @return Result success or failure
|
||||
*/
|
||||
suspend fun renameDevice(deviceId: String, newName: String): Result<Unit>
|
||||
|
||||
/**
|
||||
* Set or update device location.
|
||||
*
|
||||
* @return Result success or failure
|
||||
*/
|
||||
suspend fun setLocation(deviceId: String, latitude: Double, longitude: Double): Result<Unit>
|
||||
|
||||
/**
|
||||
* Remove device location.
|
||||
*
|
||||
* @return Result success or failure
|
||||
*/
|
||||
suspend fun removeLocation(deviceId: String): Result<Unit>
|
||||
|
||||
/**
|
||||
* Enable or disable data sharing.
|
||||
*
|
||||
* @return Result success or failure
|
||||
*/
|
||||
suspend fun setDataSharing(deviceId: String, enabled: Boolean): Result<Unit>
|
||||
|
||||
/**
|
||||
* Trigger firmware update. Requires connectivity.
|
||||
*
|
||||
* @return Result success or failure
|
||||
*/
|
||||
suspend fun triggerFirmwareUpdate(deviceId: String): Result<Unit>
|
||||
|
||||
/**
|
||||
* Check if there are pending mutations for a device.
|
||||
*/
|
||||
fun observeHasPendingMutations(deviceId: String): Flow<Boolean>
|
||||
|
||||
/**
|
||||
* Mark isOnline as stale for all devices (e.g. on subscription disconnect).
|
||||
*/
|
||||
suspend fun markOnlineStatusStale(): Unit
|
||||
|
||||
/**
|
||||
* Refresh devices from subscription payload. Internal use by DeviceSubscriptionManager.
|
||||
*/
|
||||
suspend fun refreshFromSubscription(devices: List<Device>): Unit
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.db3.airmq.sdk.device.domain
|
||||
|
||||
/**
|
||||
* Type of pending device mutation for offline queue.
|
||||
*/
|
||||
enum class PendingMutationType {
|
||||
RENAME,
|
||||
LOCATION,
|
||||
DATA_SHARING
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain model for a pending mutation in the offline queue.
|
||||
* Stored locally and synced when connectivity is restored.
|
||||
*
|
||||
* @param id Unique ID for the pending mutation
|
||||
* @param type Type of mutation
|
||||
* @param deviceId Target device ID
|
||||
* @param payload JSON payload for the mutation
|
||||
* @param createdAt Timestamp when enqueued
|
||||
*/
|
||||
data class PendingMutation(
|
||||
val id: String,
|
||||
val type: PendingMutationType,
|
||||
val deviceId: String,
|
||||
val payload: String,
|
||||
val createdAt: Long
|
||||
)
|
||||
Reference in New Issue
Block a user