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>
|
||||
|
||||
Reference in New Issue
Block a user