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:
2026-03-04 19:35:07 +01:00
parent ca5cf8c439
commit e59e5aa060
29 changed files with 1451 additions and 27 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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