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
|
@Composable
|
||||||
fun ManageScreen(
|
fun ManageScreen(
|
||||||
onOpenDevice: () -> Unit,
|
onOpenDevice: (String) -> Unit,
|
||||||
onOpenSetup: () -> Unit,
|
onOpenSetup: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
onOpenLogin: () -> Unit,
|
onOpenLogin: () -> Unit,
|
||||||
@@ -68,7 +68,7 @@ fun ManageScreen(
|
|||||||
Action.OpenLogin -> onOpenLogin()
|
Action.OpenLogin -> onOpenLogin()
|
||||||
Action.OpenSettings -> onOpenSettings()
|
Action.OpenSettings -> onOpenSettings()
|
||||||
Action.OpenSetup -> onOpenSetup()
|
Action.OpenSetup -> onOpenSetup()
|
||||||
is Action.OpenDevice -> onOpenDevice()
|
is Action.OpenDevice -> onOpenDevice(action.deviceId)
|
||||||
is Action.OpenLocation -> onOpenLocation()
|
is Action.OpenLocation -> onOpenLocation()
|
||||||
is Action.OpenAddLocation -> onOpenAddLocation()
|
is Action.OpenAddLocation -> onOpenAddLocation()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,25 +6,31 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.db3.airmq.R
|
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.Action
|
||||||
import org.db3.airmq.features.manage.ManageScreenContract.DeviceItem
|
import org.db3.airmq.features.manage.ManageScreenContract.DeviceItem
|
||||||
import org.db3.airmq.features.manage.ManageScreenContract.Event
|
import org.db3.airmq.features.manage.ManageScreenContract.Event
|
||||||
import org.db3.airmq.features.manage.ManageScreenContract.State
|
import org.db3.airmq.features.manage.ManageScreenContract.State
|
||||||
import org.db3.airmq.sdk.auth.AuthService
|
import org.db3.airmq.sdk.auth.AuthService
|
||||||
import org.db3.airmq.sdk.auth.model.User
|
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
|
@HiltViewModel
|
||||||
class ManageViewModel @Inject constructor(
|
class ManageViewModel @Inject constructor(
|
||||||
@ApplicationContext private val appContext: Context,
|
@ApplicationContext private val appContext: Context,
|
||||||
private val authService: AuthService
|
private val authService: AuthService,
|
||||||
|
private val getMyDevicesUseCase: GetMyDevicesUseCase,
|
||||||
|
private val subscriptionManager: DeviceSubscriptionManager
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(initialState())
|
private val _uiState = MutableStateFlow(initialState())
|
||||||
@@ -35,6 +41,21 @@ class ManageViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refreshAuthState()
|
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) {
|
fun onEvent(event: Event) {
|
||||||
@@ -53,10 +74,12 @@ class ManageViewModel @Inject constructor(
|
|||||||
private fun refreshAuthState() {
|
private fun refreshAuthState() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val session = authService.getUser()
|
val session = authService.getUser()
|
||||||
_uiState.value = if (session?.isAuthenticated == true) {
|
if (session?.isAuthenticated == true) {
|
||||||
authorizedState(session)
|
subscriptionManager.start()
|
||||||
|
_uiState.value = authorizedState(session)
|
||||||
} else {
|
} 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),
|
userName = user.displayName ?: appContext.getString(R.string.text_anonymous_user),
|
||||||
userEmail = user.email ?: "",
|
userEmail = user.email ?: "",
|
||||||
devicesLabel = "",
|
devicesLabel = "",
|
||||||
devices = listOf(
|
devices = emptyList()
|
||||||
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
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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.constructor.WidgetConstructorScreen
|
||||||
import org.db3.airmq.features.dashboard.DashboardScreen
|
import org.db3.airmq.features.dashboard.DashboardScreen
|
||||||
import org.db3.airmq.features.debug.DebugScreen
|
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.SplashScreen
|
||||||
import org.db3.airmq.features.entry.WizardScreen
|
import org.db3.airmq.features.entry.WizardScreen
|
||||||
import org.db3.airmq.features.location.LocationScreen
|
import org.db3.airmq.features.location.LocationScreen
|
||||||
@@ -147,7 +147,9 @@ fun AirMQNavGraph(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
composable(AirMqRoutes.MANAGE) {
|
composable(AirMqRoutes.MANAGE) {
|
||||||
ManageScreen(
|
ManageScreen(
|
||||||
onOpenDevice = { navController.navigate(AirMqRoutes.device()) },
|
onOpenDevice = { deviceId ->
|
||||||
|
navController.navigate(AirMqRoutes.device(deviceId))
|
||||||
|
},
|
||||||
onOpenSetup = { navController.navigate(AirMqRoutes.SETUP) },
|
onOpenSetup = { navController.navigate(AirMqRoutes.SETUP) },
|
||||||
onOpenSettings = { navController.navigate(AirMqRoutes.SETTINGS) },
|
onOpenSettings = { navController.navigate(AirMqRoutes.SETTINGS) },
|
||||||
onOpenLogin = { navController.navigate(AirMqRoutes.LOGIN) },
|
onOpenLogin = { navController.navigate(AirMqRoutes.LOGIN) },
|
||||||
@@ -164,10 +166,11 @@ fun AirMQNavGraph(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
) { backStackEntry ->
|
) { backStackEntry ->
|
||||||
DeviceScreen(
|
DeviceSettingsScreen(
|
||||||
deviceId = backStackEntry.arguments?.getString("deviceId") ?: "mock-device-id",
|
deviceId = backStackEntry.arguments?.getString("deviceId") ?: "mock-device-id",
|
||||||
onOpenLocation = { navController.navigate(AirMqRoutes.LOCATION) },
|
onOpenLocation = { navController.navigate(AirMqRoutes.LOCATION) },
|
||||||
onShowOnMap = { navController.navigate(AirMqRoutes.MAP) }
|
onShowOnMap = { navController.navigate(AirMqRoutes.MAP) },
|
||||||
|
onNavigateBack = { navController.popBackStack() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(AirMqRoutes.CITY) {
|
composable(AirMqRoutes.CITY) {
|
||||||
|
|||||||
@@ -162,6 +162,9 @@
|
|||||||
<string name="text_device_visibility_visible">Public</string>
|
<string name="text_device_visibility_visible">Public</string>
|
||||||
<string name="text_device_visibility_private">Private</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_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_rename">Rename</string>
|
||||||
<string name="button_update">Update</string>
|
<string name="button_update">Update</string>
|
||||||
<string name="button_unregister">Unregister</string>
|
<string name="button_unregister">Unregister</string>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ androidxCredentials = "1.5.0"
|
|||||||
googleid = "1.1.1"
|
googleid = "1.1.1"
|
||||||
kotlinxCoroutinesTest = "1.9.0"
|
kotlinxCoroutinesTest = "1.9.0"
|
||||||
mockk = "1.13.13"
|
mockk = "1.13.13"
|
||||||
|
room = "2.7.0-alpha11"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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" }
|
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" }
|
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" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ apollo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
// Room
|
||||||
|
implementation(libs.androidx.room.runtime)
|
||||||
|
implementation(libs.androidx.room.ktx)
|
||||||
|
ksp(libs.androidx.room.compiler)
|
||||||
|
|
||||||
// Apollo GraphQL
|
// Apollo GraphQL
|
||||||
implementation(libs.apollo.runtime)
|
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