diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/ScannerDestination.kt b/app/src/main/java/no/nordicsemi/android/nrftoolbox/ScannerDestination.kt index e997f13db..ddfa3a710 100644 --- a/app/src/main/java/no/nordicsemi/android/nrftoolbox/ScannerDestination.kt +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/ScannerDestination.kt @@ -1,6 +1,6 @@ package no.nordicsemi.android.nrftoolbox -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import no.nordicsemi.android.common.navigation.createDestination import no.nordicsemi.android.common.navigation.defineDestination import no.nordicsemi.android.common.navigation.viewmodel.SimpleNavigationViewModel diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/FeatureButton.kt b/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/FeatureButton.kt index 6a74774a9..3bdaa383c 100644 --- a/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/FeatureButton.kt +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/FeatureButton.kt @@ -1,142 +1,129 @@ package no.nordicsemi.android.nrftoolbox.view -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row 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.layout.wrapContentHeight +import androidx.compose.material3.Badge +import androidx.compose.material3.ElevatedAssistChip +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import no.nordicsemi.android.common.theme.NordicTheme import no.nordicsemi.android.nrftoolbox.R +import kotlin.math.absoluteValue @Composable internal fun FeatureButton( - @DrawableRes iconId: Int, - @StringRes description: Int, - profileNames: List = listOf(stringResource(description)), + icon: Painter, + description: String, + profileNames: List = listOf(description), deviceName: String?, deviceAddress: String, onClick: () -> Unit ) { - OutlinedCard(onClick = onClick, modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier - .padding(16.dp) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Image( - painter = painterResource(iconId), - contentDescription = stringResource(id = description), - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), - modifier = Modifier - .size(40.dp) - ) - - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - modifier = Modifier.fillMaxWidth(), - text = deviceName ?: stringResource(R.string.unknown_device), - style = MaterialTheme.typography.titleMedium, - ) - Text( - modifier = Modifier.fillMaxWidth(), - text = profileNames.joinToString(", "), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - - Text( - modifier = Modifier.fillMaxWidth(), - text = deviceAddress, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + OutlinedCard( + onClick = onClick, + ) { + ListItem( + headlineContent = { Text(deviceName ?: stringResource(R.string.unknown_device)) }, + supportingContent = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(deviceAddress) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + maxLines = 1, + ) { + profileNames.forEach { + Badge( + containerColor = vibrantColorFromString(it), + contentColor = MaterialTheme.colorScheme.onPrimary, + ) { + Text( + text = it, + modifier = Modifier.padding(1.dp), + ) + } + } + } + } + }, + leadingContent = { + Icon( + painter = icon, + contentDescription = description, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp), ) } - } + ) } } -@Preview +@Preview(heightDp = 100) @Composable private fun FeatureButtonPreview() { - FeatureButton( - R.drawable.ic_csc, - R.string.csc_module_full, - listOf("Cycling Speed and Cadence", "Cycling Speed Sensor"), - "Testing peripheral", - deviceAddress = "AA:BB:CC:DD:EE:FF", - ) { } + NordicTheme { + FeatureButton( + icon = painterResource(R.drawable.ic_csc), + description = stringResource(R.string.csc_module_full), + profileNames = listOf("Cycling Speed and Cadence", "Battery", "Heart Rate", "Blood Pressure"), + deviceName = "Testing peripheral", + deviceAddress = "AA:BB:CC:DD:EE:FF", + onClick = {} + ) + } } -@Composable -internal fun FeatureButton( - iconId: ImageVector, - @StringRes description: Int, - profileNames: List = listOf(stringResource(description)), - deviceName: String?, - deviceAddress: String, - onClick: () -> Unit -) { - OutlinedCard(onClick = onClick, modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier - .padding(16.dp) - .fillMaxWidth(), - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Image( - imageVector = iconId, - contentDescription = deviceAddress, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), - modifier = Modifier - .size(40.dp) - ) +private fun vibrantColorFromString(input: String): Color { + // Hash → 0..360 for hue + val hue = (input.hashCode().absoluteValue % 360).toFloat() - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - modifier = Modifier.fillMaxWidth(), - text = deviceName ?: stringResource(R.string.unknown_device), - style = MaterialTheme.typography.titleMedium, - ) + val saturation = 0.65f // vibrant + val lightness = 0.45f // not too dark/light - Text( - modifier = Modifier.fillMaxWidth(), - text = profileNames.joinToString(", "), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + return hslToColor(hue, saturation, lightness) +} - Text( - modifier = Modifier.fillMaxWidth(), - text = deviceAddress, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } +/** + * HSL → Color conversion for Jetpack Compose. + */ +private fun hslToColor(h: Float, s: Float, l: Float): Color { + val c = (1 - kotlin.math.abs(2 * l - 1)) * s + val x = c * (1 - kotlin.math.abs((h / 60) % 2 - 1)) + val m = l - c / 2 + + val (r1, g1, b1) = when { + h < 60 -> Triple(c, x, 0f) + h < 120 -> Triple(x, c, 0f) + h < 180 -> Triple(0f, c, x) + h < 240 -> Triple(0f, x, c) + h < 300 -> Triple(x, 0f, c) + else -> Triple(c, 0f, x) } + + return Color( + red = r1 + m, + green = g1 + m, + blue = b1 + m + ) } diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/HomeView.kt b/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/HomeView.kt index be960cbe8..1b9a90a5c 100644 --- a/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/HomeView.kt +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/HomeView.kt @@ -4,19 +4,16 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.displayCutout +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.union import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Lightbulb +import androidx.compose.material.icons.filled.MyLocation import androidx.compose.material.icons.filled.SocialDistance import androidx.compose.material.icons.filled.SyncAlt import androidx.compose.material3.ExperimentalMaterial3Api @@ -28,11 +25,15 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import no.nordicsemi.android.common.analytics.view.AnalyticsPermissionButton +import no.nordicsemi.android.common.ui.view.NordicAppBar import no.nordicsemi.android.nrftoolbox.R import no.nordicsemi.android.nrftoolbox.viewmodel.HomeViewModel import no.nordicsemi.android.nrftoolbox.viewmodel.UiEvent @@ -46,14 +47,19 @@ internal fun HomeView() { val onEvent: (UiEvent) -> Unit = { viewModel.onClickEvent(it) } Scaffold( - topBar = { TitleAppBar(stringResource(id = R.string.app_name)) }, - contentWindowInsets = WindowInsets.displayCutout - .only(WindowInsetsSides.Horizontal) - .union(WindowInsets.navigationBars), + topBar = { + NordicAppBar( + title = { + Text(stringResource(id = R.string.app_name)) + }, + actions = { + AnalyticsPermissionButton() + } + ) + }, floatingActionButton = { ExtendedFloatingActionButton( onClick = { onEvent(UiEvent.OnConnectDeviceClick) }, - modifier = Modifier.padding(16.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -61,7 +67,7 @@ internal fun HomeView() { ) { Icon( imageVector = Icons.Default.Add, - contentDescription = "Add device from scanner" + contentDescription = "Connect to device", ) Text(text = stringResource(R.string.connect_device)) } @@ -71,23 +77,29 @@ internal fun HomeView() { LazyColumn( modifier = Modifier .fillMaxSize() - .padding(paddingValues) - .padding(horizontal = 16.dp), - contentPadding = PaddingValues(bottom = 16.dp), + .padding( + top = paddingValues.calculateTopPadding(), + start = paddingValues.calculateStartPadding(LayoutDirection.Ltr), + end = paddingValues.calculateEndPadding(LayoutDirection.Ltr), + ) + .padding(horizontal = 16.dp) + .consumeWindowInsets(paddingValues), + contentPadding = PaddingValues( + top = 16.dp, + bottom = paddingValues.calculateBottomPadding() + 16.dp + ), verticalArrangement = Arrangement.spacedBy(16.dp), ) { item { // Show the title at the top - Text( - text = stringResource(R.string.connected_devices), - modifier = Modifier - .alpha(0.5f) - .padding(start = 16.dp, end = 16.dp, top = 16.dp), + SectionTitle( + title = stringResource(R.string.connected_devices) ) + } + item { if (state.connectedDevices.isNotEmpty()) { Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxWidth() + verticalArrangement = Arrangement.spacedBy(8.dp), ) { state.connectedDevices.keys.forEach { device -> state.connectedDevices[device]?.let { deviceData -> @@ -97,8 +109,8 @@ internal fun HomeView() { // Case 1: If only one service, show it directly like battery service if (deviceData.services.size == 1 && deviceData.services.first().profile == Profile.BATTERY) { FeatureButton( - iconId = R.drawable.ic_battery, - description = R.string.battery_module_full, + icon = painterResource(R.drawable.ic_battery), + description = stringResource(R.string.battery_module_full), deviceName = deviceData.peripheral.name, deviceAddress = deviceData.peripheral.address, onClick = { @@ -117,222 +129,139 @@ internal fun HomeView() { ?.let { serviceManager -> val peripheral = deviceData.peripheral val services = deviceData.services + val onClick = { + onEvent( + UiEvent.OnDeviceClick( + peripheral.address, + serviceManager.profile + ) + ) + } when (serviceManager.profile) { Profile.HRS -> FeatureButton( - iconId = R.drawable.ic_hrs, - description = R.string.hrs_module_full, + icon = painterResource(R.drawable.ic_hrs), + description = stringResource(R.string.hrs_module_full), deviceName = peripheral.name, profileNames = services.map { it.profile.toString() }, deviceAddress = peripheral.address, - onClick = { - onEvent( - UiEvent.OnDeviceClick( - peripheral.address, - serviceManager.profile - ) - ) - }, + onClick = onClick, ) Profile.HTS -> FeatureButton( - iconId = R.drawable.ic_hts, - description = R.string.hts_module_full, + icon = painterResource(R.drawable.ic_hts), + description = stringResource(R.string.hts_module_full), deviceName = peripheral.name, deviceAddress = peripheral.address, profileNames = services.map { it.profile.toString() }, - onClick = { - onEvent( - UiEvent.OnDeviceClick( - peripheral.address, - serviceManager.profile - ) - ) - }, + onClick = onClick, ) Profile.BPS -> FeatureButton( - iconId = R.drawable.ic_bps, - description = R.string.bps_module_full, + icon = painterResource(R.drawable.ic_bps), + description = stringResource(R.string.bps_module_full), deviceName = peripheral.name, deviceAddress = peripheral.address, profileNames = services.map { it.profile.toString() }, - onClick = { - onEvent( - UiEvent.OnDeviceClick( - peripheral.address, - serviceManager.profile - ) - ) - }, + onClick = onClick, ) Profile.GLS -> FeatureButton( - iconId = R.drawable.ic_gls, - description = R.string.gls_module_full, + icon = painterResource(R.drawable.ic_gls), + description = stringResource(R.string.gls_module_full), deviceName = peripheral.name, deviceAddress = peripheral.address, profileNames = services.map { it.profile.toString() }, - onClick = { - onEvent( - UiEvent.OnDeviceClick( - peripheral.address, - serviceManager.profile - ) - ) - }, + onClick = onClick, ) Profile.CGM -> FeatureButton( - iconId = R.drawable.ic_cgm, - description = R.string.cgm_module_full, + icon = painterResource(R.drawable.ic_cgm), + description = stringResource(R.string.cgm_module_full), deviceName = peripheral.name, deviceAddress = peripheral.address, profileNames = services.map { it.profile.toString() }, - onClick = { - onEvent( - UiEvent.OnDeviceClick( - peripheral.address, - serviceManager.profile - ) - ) - }, + onClick = onClick, ) Profile.RSCS -> FeatureButton( - iconId = R.drawable.ic_rscs, - description = R.string.rscs_module_full, + icon = painterResource(R.drawable.ic_rscs), + description = stringResource(R.string.rscs_module_full), deviceName = peripheral.name, deviceAddress = peripheral.address, profileNames = services.map { it.profile.toString() }, - onClick = { - onEvent( - UiEvent.OnDeviceClick( - peripheral.address, - serviceManager.profile - ) - ) - }, + onClick = onClick, ) Profile.DFS -> FeatureButton( - iconId = R.drawable.ic_distance, - description = R.string.direction_module_full, + icon = rememberVectorPainter(Icons.Default.MyLocation), + description = stringResource(R.string.direction_module_full), deviceName = peripheral.name, deviceAddress = peripheral.address, profileNames = services.map { it.profile.toString() }, - onClick = { - onEvent( - UiEvent.OnDeviceClick( - peripheral.address, - serviceManager.profile - ) - ) - }, + onClick = onClick, ) Profile.CSC -> FeatureButton( - iconId = R.drawable.ic_csc, - description = R.string.csc_module_full, + icon = painterResource(R.drawable.ic_csc), + description = stringResource(R.string.csc_module_full), deviceName = peripheral.name, deviceAddress = peripheral.address, profileNames = services.map { it.profile.toString() }, - onClick = { - onEvent( - UiEvent.OnDeviceClick( - peripheral.address, - serviceManager.profile - ) - ) - }, + onClick = onClick, ) Profile.THROUGHPUT -> { FeatureButton( - iconId = Icons.Default.SyncAlt, - description = R.string.throughput_module, + icon = rememberVectorPainter(Icons.Default.SyncAlt), + description = stringResource(R.string.throughput_module), deviceName = peripheral.name, deviceAddress = peripheral.address, profileNames = services.map { it.profile.toString() }, - onClick = { - onEvent( - UiEvent.OnDeviceClick( - peripheral.address, - serviceManager.profile - ) - ) - }, + onClick = onClick, ) } Profile.UART -> { FeatureButton( - iconId = R.drawable.ic_uart, - description = R.string.uart_module_full, + icon = painterResource(R.drawable.ic_uart), + description = stringResource(R.string.uart_module_full), deviceName = peripheral.name, deviceAddress = peripheral.address, profileNames = services.map { it.profile.toString() }, - onClick = { - onEvent( - UiEvent.OnDeviceClick( - peripheral.address, - serviceManager.profile - ) - ) - }, + onClick = onClick, ) } Profile.CHANNEL_SOUNDING -> { FeatureButton( - iconId = Icons.Default.SocialDistance, - description = R.string.channel_sounding_module, + icon = rememberVectorPainter(Icons.Default.SocialDistance), + description = stringResource(R.string.channel_sounding_module_full), deviceName = peripheral.name, deviceAddress = peripheral.address, profileNames = services.map { it.profile.toString() }, - onClick = { - onEvent( - UiEvent.OnDeviceClick( - peripheral.address, - serviceManager.profile - ) - ) - }, + onClick = onClick, ) } Profile.LBS -> { FeatureButton( - iconId = Icons.Default.Lightbulb, - description = R.string.lbs_blinky_module, + icon = rememberVectorPainter(Icons.Default.Lightbulb), + description = stringResource(R.string.lbs_blinky_module_full), deviceName = peripheral.name, deviceAddress = peripheral.address, profileNames = services.map { it.profile.toString() }, - onClick = { - onEvent( - UiEvent.OnDeviceClick( - peripheral.address, - serviceManager.profile - ) - ) - }, + onClick = onClick, ) } Profile.DFU -> { FeatureButton( - iconId = R.drawable.ic_dfu, - description = R.string.dfu_module_full, + icon = painterResource(R.drawable.ic_dfu), + description = stringResource(R.string.dfu_module_full), deviceName = peripheral.name, deviceAddress = peripheral.address, profileNames = services.map { it.profile.toString() }, - onClick = { - onEvent( - UiEvent.OnDeviceClick( - peripheral.address, - serviceManager.profile - ) - ) - }, + onClick = onClick, ) } @@ -350,6 +279,11 @@ internal fun HomeView() { NoConnectedDeviceView() } } + item { + SectionTitle( + title = stringResource(R.string.links) + ) + } item { Links { onEvent(it) } } diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/Links.kt b/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/Links.kt index 5053d3844..97c627934 100644 --- a/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/Links.kt +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/Links.kt @@ -1,28 +1,22 @@ package no.nordicsemi.android.nrftoolbox.view -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CornerSize import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.Code import androidx.compose.material.icons.filled.Language -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -31,71 +25,62 @@ import no.nordicsemi.android.nrftoolbox.viewmodel.UiEvent @Composable internal fun Links(onEvent: (UiEvent) -> Unit) { - Column { - Text( - text = stringResource(R.string.links), - modifier = Modifier - .alpha(0.5f) - .padding(start = 16.dp), - ) + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { OutlinedCard( - modifier = Modifier.fillMaxWidth() + shape = MaterialTheme.shapes.medium.copy(bottomStart = CornerSize(4.dp), bottomEnd = CornerSize(4.dp)), ) { - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)) - .clickable { onEvent(UiEvent.OnGitHubClick) } - .background(MaterialTheme.colorScheme.surface) - .padding(16.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - imageVector = Icons.Default.Code, - contentDescription = stringResource(R.string.github_repo), - modifier = Modifier.size(40.dp), - tint = MaterialTheme.colorScheme.primary - ) - Text( - text = stringResource(R.string.github_repo), - style = MaterialTheme.typography.bodyLarge, - ) - } - } - - HorizontalDivider() + Link( + icon = Icons.Default.Code, + title = stringResource(R.string.github_repo), + subtitle = stringResource(R.string.github_repo_subtitle), + onClick = { onEvent(UiEvent.OnGitHubClick) }, + ) + } - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp)) - .clickable { onEvent(UiEvent.OnNordicDevZoneClick) } - .background(MaterialTheme.colorScheme.surface) - .padding(16.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - imageVector = Icons.Default.Language, - contentDescription = stringResource(R.string.nordic_dev_zone), - modifier = Modifier.size(40.dp), - tint = MaterialTheme.colorScheme.primary - ) - Text( - text = stringResource(R.string.nordic_dev_zone), - style = MaterialTheme.typography.bodyLarge, - ) - } - } + OutlinedCard( + shape = MaterialTheme.shapes.medium.copy(topStart = CornerSize(4.dp), topEnd = CornerSize(4.dp)), + ) { + Link( + icon = Icons.Default.Language, + title = stringResource(R.string.nordic_dev_zone), + subtitle = stringResource(R.string.nordic_dev_zone_subtitle), + onClick = { onEvent(UiEvent.OnNordicDevZoneClick) }, + ) } } } +@Composable +private fun Link( + icon: ImageVector, + title: String, + modifier: Modifier = Modifier, + subtitle: String? = null, + onClick: () -> Unit, +) { + ListItem( + modifier = modifier.clickable(onClick = onClick), + headlineContent = { Text(title) }, + supportingContent = { if (!subtitle.isNullOrEmpty()) Text(subtitle) }, + leadingContent = { + Icon( + imageVector = icon, + contentDescription = title, + tint = MaterialTheme.colorScheme.primary, + ) + }, + trailingContent = { + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = null, + modifier = Modifier.alpha(0.6f), + ) + } + ) +} + @Preview @Composable private fun LinksPreview() { diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/NoConnectedDeviceView.kt b/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/NoConnectedDeviceView.kt index dcfc808c0..6b031da8a 100644 --- a/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/NoConnectedDeviceView.kt +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/NoConnectedDeviceView.kt @@ -67,8 +67,8 @@ internal fun NoConnectedDeviceView() { ) Text( text = stringResource(R.string.device_not_connected_message), - textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, ) } } diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/SectionTitle.kt b/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/SectionTitle.kt new file mode 100644 index 000000000..766eea94b --- /dev/null +++ b/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/SectionTitle.kt @@ -0,0 +1,20 @@ +package no.nordicsemi.android.nrftoolbox.view + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle + +@Composable +fun SectionTitle( + modifier: Modifier = Modifier, + title: String, + style: TextStyle = MaterialTheme.typography.labelLarge +) { + Text( + modifier = modifier, + text = title, + style = style + ) +} \ No newline at end of file diff --git a/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/TitleAppBar.kt b/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/TitleAppBar.kt deleted file mode 100644 index 7a697f55e..000000000 --- a/app/src/main/java/no/nordicsemi/android/nrftoolbox/view/TitleAppBar.kt +++ /dev/null @@ -1,40 +0,0 @@ -package no.nordicsemi.android.nrftoolbox.view - -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.displayCutout -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.union -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.colorResource -import no.nordicsemi.android.common.analytics.view.AnalyticsPermissionButton -import no.nordicsemi.android.nrftoolbox.R - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun TitleAppBar(text: String) { - TopAppBar( - title = { Text(text, maxLines = 2) }, - colors = TopAppBarDefaults.topAppBarColors( - scrolledContainerColor = MaterialTheme.colorScheme.primary, - containerColor = colorResource(id = R.color.appBarColor), - titleContentColor = MaterialTheme.colorScheme.onPrimary, - actionIconContentColor = MaterialTheme.colorScheme.onPrimary, - navigationIconContentColor = MaterialTheme.colorScheme.onPrimary, - ), - windowInsets = WindowInsets.displayCutout - .union(WindowInsets.statusBars) - .union(WindowInsets.navigationBars) - .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top), - actions = { - AnalyticsPermissionButton() - } - ) -} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c5cf71ec6..0cbcded25 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -67,7 +67,7 @@ Open nRF Logger application. DF - Direction Finder + Distance Measurement ViewModel profiles Service profiles @@ -78,12 +78,14 @@ BATTERY Battery - CHANNEL SOUNDING + CHANNEL SOUNDING + Channel Sounding Throughput Unknown Device - LBS/Blinky + LBS + LED Button NO DEVICES CONNECTED Tap Connect to begin. @@ -91,8 +93,10 @@ Connect Connected devices - Source code (GitHub) - Help (Nordic DevZone) + Source code + GitHub + Help + Nordic DevZone Links diff --git a/gradle.properties b/gradle.properties index ed8e6c24a..dab96e20a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -52,3 +52,8 @@ kotlin.code.style=official android.nonTransitiveRClass=false # https://github.com/google/ksp/issues/1942#issuecomment-2157733096 ksp.useKSP2=true + +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.configureondemand=true +org.gradle.configuration-cache=true \ No newline at end of file diff --git a/lib_ui/src/main/java/no/nordicsemi/android/ui/view/AnimationTransitionState.kt b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/AnimationTransitionState.kt index d2043f11b..2cf186f2f 100644 --- a/lib_ui/src/main/java/no/nordicsemi/android/ui/view/AnimationTransitionState.kt +++ b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/AnimationTransitionState.kt @@ -31,11 +31,11 @@ fun createCircleTransition( circleColor = transition.animateColor( label = "Circle Color", transitionSpec = { tween(duration, easing = LinearOutSlowInEasing) } - ) { if (it) MaterialTheme.colorScheme.tertiaryContainer else MaterialTheme.colorScheme.primaryContainer }, + ) { if (it) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.surfaceVariant }, dotColor = transition.animateColor( label = "Dot Color", transitionSpec = { tween(duration, easing = LinearOutSlowInEasing) } - ) { if (it) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.primary } + ) { if (it) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary } ) } diff --git a/lib_ui/src/main/java/no/nordicsemi/android/ui/view/BackButton.kt b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/BackButton.kt deleted file mode 100644 index cb62fa7a7..000000000 --- a/lib_ui/src/main/java/no/nordicsemi/android/ui/view/BackButton.kt +++ /dev/null @@ -1,20 +0,0 @@ -package no.nordicsemi.android.ui.view - -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import no.nordicsemi.android.ui.R - -@Composable -fun NavigateUpButton(navigateUp: () -> Unit) { - Button( - onClick = { navigateUp() }, - modifier = Modifier.padding(top = 16.dp) - ) { - Text(text = stringResource(id = R.string.go_up)) - } -} diff --git a/lib_ui/src/main/java/no/nordicsemi/android/ui/view/DropdownView.kt b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/DropdownView.kt index d6788d296..f66cc6600 100644 --- a/lib_ui/src/main/java/no/nordicsemi/android/ui/view/DropdownView.kt +++ b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/DropdownView.kt @@ -9,11 +9,11 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Error import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -52,7 +52,7 @@ inline fun DropdownView( trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, modifier = Modifier .fillMaxWidth() - .menuAnchor(MenuAnchorType.PrimaryNotEditable), + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable), placeholder = { Text(text = placeholder) }, label = { Text(text = label) }, isError = isError, @@ -99,7 +99,7 @@ inline fun DropdownView( } } -@Preview +@Preview(showBackground = true) @Composable private fun DropdownViewPreview() { val items = listOf("Item 1", "Item 2", "Item 3") diff --git a/lib_ui/src/main/java/no/nordicsemi/android/ui/view/FeatureColumn.kt b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/FeatureColumn.kt new file mode 100644 index 000000000..c36415e72 --- /dev/null +++ b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/FeatureColumn.kt @@ -0,0 +1,65 @@ +package no.nordicsemi.android.ui.view + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +fun FeaturesColumn( + content: @Composable FeatureColumnScope.() -> Unit +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + FeatureColumnScopeInstance.content() + } +} + +private object FeatureColumnScopeInstance : FeatureColumnScope + +interface FeatureColumnScope { + + @Composable + fun FeatureRow( + text: String, + supported: Boolean, + ) { + if (supported) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + +} + +@Preview(showBackground = true) +@Composable +private fun FeatureRowPreview() { + FeaturesColumn { + FeatureRow( + text = "Instantaneous stride length measurement supported", + supported = true + ) + } +} diff --git a/lib_ui/src/main/java/no/nordicsemi/android/ui/view/FeatureSupportedRow.kt b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/FeatureSupportedRow.kt deleted file mode 100644 index 4c12e6bf5..000000000 --- a/lib_ui/src/main/java/no/nordicsemi/android/ui/view/FeatureSupportedRow.kt +++ /dev/null @@ -1,53 +0,0 @@ -package no.nordicsemi.android.ui.view - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp - -@Composable -fun FeatureSupported( - text: String, -) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Box( - modifier = Modifier.background( - color = MaterialTheme.colorScheme.primary, - shape = RectangleShape - ) - ) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(20.dp) - ) - } - Text( - text = text, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - } -} - -@Preview(showBackground = true) -@Composable -private fun FeatureSupportedPreview() { - FeatureSupported("Instantaneous stride length measurement supported") -} diff --git a/lib_ui/src/main/java/no/nordicsemi/android/ui/view/KeyValueColumn.kt b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/KeyValueColumn.kt index 4ab0c7d55..9a783e053 100644 --- a/lib_ui/src/main/java/no/nordicsemi/android/ui/view/KeyValueColumn.kt +++ b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/KeyValueColumn.kt @@ -1,9 +1,7 @@ package no.nordicsemi.android.ui.view import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -17,136 +15,91 @@ import androidx.compose.ui.unit.dp @Composable fun KeyValueColumn( - value: String, key: String, modifier: Modifier = Modifier, - verticalSpacing: Dp = 8.dp, - keyStyle: TextStyle?= null + verticalSpacing: Dp = 4.dp, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + value: @Composable () -> Unit ) { - Box( - modifier = Modifier - .padding(end = 8.dp) + Column( + verticalArrangement = Arrangement.spacedBy(verticalSpacing), + horizontalAlignment = horizontalAlignment, + modifier = modifier, ) { - Column( - verticalArrangement = Arrangement.spacedBy(verticalSpacing), - horizontalAlignment = Alignment.Start, - modifier = modifier - ) { - Text( - text = value, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onBackground, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - Text( - text = key, - color = MaterialTheme.colorScheme.onBackground, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = keyStyle ?: MaterialTheme.typography.bodyLarge - ) - } + Text( + text = key, + style = MaterialTheme.typography.labelMedium, + overflow = TextOverflow.Ellipsis, + ) + value() } } -@Preview(showBackground = true) -@Composable -private fun KeyValueColumnPreview() { - KeyValueColumn( - value = "Sample Value", - key = "Sample Key", -// keyStyle = MaterialTheme.typography.labelLarge - ) -} - @Composable fun KeyValueColumn( + key: String, value: String, modifier: Modifier = Modifier, - key: @Composable (() -> Unit) + verticalSpacing: Dp = 4.dp, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + valueStyle: TextStyle = MaterialTheme.typography.bodyLarge ) { - Box( - modifier = Modifier - .padding(end = 8.dp) - ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.Start, - modifier = modifier - ) { + KeyValueColumn( + key = key, + modifier = modifier, + verticalSpacing = verticalSpacing, + horizontalAlignment = horizontalAlignment, + value = { Text( text = value, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onBackground, - maxLines = 2, - overflow = TextOverflow.Ellipsis + style = valueStyle, + overflow = TextOverflow.Ellipsis, ) - key() } - } + ) } @Composable fun KeyValueColumnReverse( - value: String, key: String, + value: String, modifier: Modifier = Modifier, - verticalSpacing: Dp = 8.dp, - keyStyle: TextStyle? = null, + verticalSpacing: Dp = 4.dp, + horizontalAlignment: Alignment.Horizontal = Alignment.End, + valueStyle: TextStyle = MaterialTheme.typography.bodyLarge ) { - Box( - modifier = Modifier - .padding(start = 8.dp), - contentAlignment = Alignment.TopEnd, - ) { - Column( - verticalArrangement = Arrangement.spacedBy(verticalSpacing), - horizontalAlignment = Alignment.End, - modifier = modifier - ) { - Text( - text = value, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onBackground, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - Text( - text = key, - color = MaterialTheme.colorScheme.onBackground, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - style = keyStyle ?: MaterialTheme.typography.bodyLarge - ) - } - } + KeyValueColumn( + key = key, + value = value, + modifier = modifier, + verticalSpacing = verticalSpacing, + horizontalAlignment = horizontalAlignment, + valueStyle = valueStyle, + ) } @Composable fun KeyValueColumnReverse( - value: String, + key: String, modifier: Modifier = Modifier, - key: @Composable (() -> Unit) + verticalSpacing: Dp = 4.dp, + horizontalAlignment: Alignment.Horizontal = Alignment.End, + value: @Composable () -> Unit ) { - Box( - modifier = Modifier - .padding(start = 8.dp), - contentAlignment = Alignment.TopEnd, - ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - horizontalAlignment = Alignment.End, - modifier = modifier - ) { - Text( - text = value, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onBackground, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - key() - } - } + KeyValueColumn( + key = key, + modifier = modifier, + verticalSpacing = verticalSpacing, + horizontalAlignment = horizontalAlignment, + value = value + ) +} + +@Preview(showBackground = true) +@Composable +private fun KeyValueColumnPreview() { + KeyValueColumn( + value = "Sample Value", + key = "Sample Key", + ) } \ No newline at end of file diff --git a/lib_ui/src/main/java/no/nordicsemi/android/ui/view/KeyValueField.kt b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/KeyValueField.kt index bb3a9a8a7..50095e067 100644 --- a/lib_ui/src/main/java/no/nordicsemi/android/ui/view/KeyValueField.kt +++ b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/KeyValueField.kt @@ -46,9 +46,6 @@ fun KeyValueField(key: String, value: String) { horizontalArrangement = Arrangement.SpaceBetween ) { Text(text = key) - Text( - color = MaterialTheme.colorScheme.onBackground, - text = value - ) + Text(text = value) } } diff --git a/lib_ui/src/main/java/no/nordicsemi/android/ui/view/ScreenSection.kt b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/ScreenSection.kt index 93110125d..e043a924a 100644 --- a/lib_ui/src/main/java/no/nordicsemi/android/ui/view/ScreenSection.kt +++ b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/ScreenSection.kt @@ -2,21 +2,28 @@ package no.nordicsemi.android.ui.view import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CardDefaults import androidx.compose.material3.OutlinedCard import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.dp @Composable fun ScreenSection( - modifier: Modifier = Modifier.padding(16.dp), - content: @Composable () -> Unit + modifier: Modifier = Modifier, + shape: Shape = CardDefaults.outlinedShape, + content: @Composable ColumnScope.() -> Unit ) { - OutlinedCard { + OutlinedCard( + modifier = modifier, + shape = shape, + ) { Column( verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = modifier + modifier = Modifier.padding(16.dp), ) { content() } diff --git a/lib_ui/src/main/java/no/nordicsemi/android/ui/view/SectionRow.kt b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/SectionRow.kt index ac97c1147..6b50b4db0 100644 --- a/lib_ui/src/main/java/no/nordicsemi/android/ui/view/SectionRow.kt +++ b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/SectionRow.kt @@ -10,11 +10,13 @@ import androidx.compose.ui.Modifier @Composable fun SectionRow( + modifier: Modifier = Modifier, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, content: @Composable RowScope.() -> Unit ) { Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, + modifier = modifier.fillMaxWidth(), + verticalAlignment = verticalAlignment, horizontalArrangement = Arrangement.SpaceBetween ) { content() diff --git a/lib_ui/src/main/java/no/nordicsemi/android/ui/view/SectionTitle.kt b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/SectionTitle.kt deleted file mode 100644 index 0cce2d916..000000000 --- a/lib_ui/src/main/java/no/nordicsemi/android/ui/view/SectionTitle.kt +++ /dev/null @@ -1,204 +0,0 @@ -/* - * Copyright (c) 2022, Nordic Semiconductor - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without modification, are - * permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, this list of - * conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright notice, this list - * of conditions and the following disclaimer in the documentation and/or other materials - * provided with the distribution. - * - * 3. Neither the name of the copyright holder nor the names of its contributors may be - * used to endorse or promote products derived from this software without specific prior - * written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS - * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED - * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A - * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT - * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, - * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, - * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY - * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, - * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ - -package no.nordicsemi.android.ui.view - -import android.annotation.SuppressLint -import androidx.annotation.DrawableRes -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import no.nordicsemi.android.ui.R - -@Composable -fun SectionTitle( - @DrawableRes resId: Int, - title: String, - modifier: Modifier = Modifier.fillMaxWidth(), - menu: @Composable (() -> Unit)? = null -) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Image( - painter = painterResource(id = resId), - contentDescription = null, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.secondary), - modifier = Modifier - .size(28.dp) - ) - Spacer(modifier = Modifier.size(8.dp)) - Text( - text = title, - textAlign = TextAlign.Start, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.weight(1f) - ) - menu?.invoke() - } -} - -@Composable -fun SectionTitle( - icon: ImageVector, - title: String, - modifier: Modifier = Modifier.fillMaxWidth(), - menu: @Composable (() -> Unit)? = null -) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Image( - imageVector = icon, - contentDescription = null, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.secondary), - modifier = Modifier - .size(28.dp) - ) - Spacer(modifier = Modifier.size(8.dp)) - Text( - text = title, - textAlign = TextAlign.Start, - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.weight(1f) - ) - menu?.invoke() - } -} - -@Composable -@SuppressLint("ModifierParameter") -fun SectionTitle( - @DrawableRes resId: Int, - title: String, - modifier: Modifier = Modifier.fillMaxWidth(), - rotateArrow: Float? = null, - iconBackground: Color = MaterialTheme.colorScheme.secondary -) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Image( - painter = painterResource(id = resId), - contentDescription = null, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.secondary), - modifier = Modifier - .size(28.dp) - - ) - Spacer(modifier = Modifier.padding(8.dp)) - Text( - text = title, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleMedium, - ) - rotateArrow?.let { - Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { - Image( - Icons.Default.ArrowDropDown, - contentDescription = null, - modifier = Modifier - .padding(8.dp) - .rotate(it) - ) - } - } - } -} - -@Preview(showBackground = true) -@Composable -private fun SectionTitle_Preview() { - SectionTitle( - resId = R.drawable.ic_records, - title = stringResource(id = R.string.back_screen), - modifier = Modifier - .fillMaxWidth() - .clickable { }, - rotateArrow = 0f - ) -} - -@Composable -fun SectionTitle( - icon: ImageVector, - title: String, - modifier: Modifier = Modifier.fillMaxWidth() -) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.secondary, - modifier = Modifier - .size(28.dp) - ) - Spacer(modifier = Modifier.size(8.dp)) - Text( - text = title, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleMedium, - ) - } -} diff --git a/lib_ui/src/main/java/no/nordicsemi/android/ui/view/StatusColumn.kt b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/StatusColumn.kt new file mode 100644 index 000000000..414824668 --- /dev/null +++ b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/StatusColumn.kt @@ -0,0 +1,65 @@ +package no.nordicsemi.android.ui.view + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Warning +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp + +@Composable +fun StatusColumn( + content: @Composable StatusColumnScope.() -> Unit +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + StatusColumnScopeInstance.content() + } +} + +private object StatusColumnScopeInstance : StatusColumnScope + +interface StatusColumnScope { + + @Composable + fun StatusRow( + text: String, + isPresent: Boolean, + ) { + if (isPresent) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + Icon( + imageVector = Icons.Rounded.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + +} + +@Preview(showBackground = true) +@Composable +private fun StatusRowPreview() { + StatusColumn { + StatusRow( + text = "Malfunction detected", + isPresent = true + ) + } +} diff --git a/lib_ui/src/main/java/no/nordicsemi/android/ui/view/SubsectionTitle.kt b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/SubsectionTitle.kt new file mode 100644 index 000000000..098af923f --- /dev/null +++ b/lib_ui/src/main/java/no/nordicsemi/android/ui/view/SubsectionTitle.kt @@ -0,0 +1,30 @@ +package no.nordicsemi.android.ui.view + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun SubsectionTitle( + text: String, +) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + HorizontalDivider() + + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + ) + } +} \ No newline at end of file diff --git a/lib_ui/src/main/res/drawable/ic_battery.xml b/lib_ui/src/main/res/drawable/ic_battery.xml index a9f2b1e08..771d057d2 100644 --- a/lib_ui/src/main/res/drawable/ic_battery.xml +++ b/lib_ui/src/main/res/drawable/ic_battery.xml @@ -1,22 +1,21 @@ - - - + + + + + diff --git a/lib_ui/src/main/res/drawable/ic_bps.xml b/lib_ui/src/main/res/drawable/ic_bps.xml index 7fda7a08b..5c03c6519 100644 --- a/lib_ui/src/main/res/drawable/ic_bps.xml +++ b/lib_ui/src/main/res/drawable/ic_bps.xml @@ -30,17 +30,17 @@ --> diff --git a/lib_ui/src/main/res/drawable/ic_cgm.xml b/lib_ui/src/main/res/drawable/ic_cgm.xml index b700e95d2..deb13d1a0 100644 --- a/lib_ui/src/main/res/drawable/ic_cgm.xml +++ b/lib_ui/src/main/res/drawable/ic_cgm.xml @@ -35,21 +35,21 @@ android:viewportWidth="1024" android:viewportHeight="1024"> diff --git a/lib_ui/src/main/res/drawable/ic_chart_line.xml b/lib_ui/src/main/res/drawable/ic_chart_line.xml index a68fe9477..9cfd696f8 100644 --- a/lib_ui/src/main/res/drawable/ic_chart_line.xml +++ b/lib_ui/src/main/res/drawable/ic_chart_line.xml @@ -35,6 +35,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/lib_ui/src/main/res/drawable/ic_csc.xml b/lib_ui/src/main/res/drawable/ic_csc.xml index 88997479f..123cdc526 100644 --- a/lib_ui/src/main/res/drawable/ic_csc.xml +++ b/lib_ui/src/main/res/drawable/ic_csc.xml @@ -30,11 +30,11 @@ --> diff --git a/lib_ui/src/main/res/drawable/ic_gls.xml b/lib_ui/src/main/res/drawable/ic_gls.xml index 80393e399..ede79847b 100644 --- a/lib_ui/src/main/res/drawable/ic_gls.xml +++ b/lib_ui/src/main/res/drawable/ic_gls.xml @@ -35,10 +35,10 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/lib_ui/src/main/res/drawable/ic_hrs.xml b/lib_ui/src/main/res/drawable/ic_hrs.xml index 75462ab43..5aab9225a 100644 --- a/lib_ui/src/main/res/drawable/ic_hrs.xml +++ b/lib_ui/src/main/res/drawable/ic_hrs.xml @@ -30,11 +30,11 @@ --> diff --git a/lib_ui/src/main/res/drawable/ic_hts.xml b/lib_ui/src/main/res/drawable/ic_hts.xml index 3e884cc9e..65261f514 100644 --- a/lib_ui/src/main/res/drawable/ic_hts.xml +++ b/lib_ui/src/main/res/drawable/ic_hts.xml @@ -30,23 +30,23 @@ --> diff --git a/lib_ui/src/main/res/drawable/ic_prx.xml b/lib_ui/src/main/res/drawable/ic_prx.xml index 786fae901..ac5e47b54 100644 --- a/lib_ui/src/main/res/drawable/ic_prx.xml +++ b/lib_ui/src/main/res/drawable/ic_prx.xml @@ -30,26 +30,26 @@ --> diff --git a/lib_ui/src/main/res/drawable/ic_records.xml b/lib_ui/src/main/res/drawable/ic_records.xml index 5053c9f7a..74729be9e 100644 --- a/lib_ui/src/main/res/drawable/ic_records.xml +++ b/lib_ui/src/main/res/drawable/ic_records.xml @@ -35,6 +35,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/lib_ui/src/main/res/drawable/ic_rscs.xml b/lib_ui/src/main/res/drawable/ic_rscs.xml index 2ce614363..f8b216793 100644 --- a/lib_ui/src/main/res/drawable/ic_rscs.xml +++ b/lib_ui/src/main/res/drawable/ic_rscs.xml @@ -30,20 +30,20 @@ --> diff --git a/lib_ui/src/main/res/drawable/ic_running_indicator.xml b/lib_ui/src/main/res/drawable/ic_running_indicator.xml index 0bf33c678..5141ec4b3 100644 --- a/lib_ui/src/main/res/drawable/ic_running_indicator.xml +++ b/lib_ui/src/main/res/drawable/ic_running_indicator.xml @@ -30,11 +30,11 @@ --> + android:fillColor="#000" + android:pathData="M12,12m-5,0a5,5 0,1 1,10 0a5,5 0,1 1,-10 0" /> diff --git a/lib_ui/src/main/res/drawable/ic_thermometer.xml b/lib_ui/src/main/res/drawable/ic_thermometer.xml index 84428cd7a..ca7edd40d 100644 --- a/lib_ui/src/main/res/drawable/ic_thermometer.xml +++ b/lib_ui/src/main/res/drawable/ic_thermometer.xml @@ -35,6 +35,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/lib_ui/src/main/res/drawable/ic_uart.xml b/lib_ui/src/main/res/drawable/ic_uart.xml index cb1d29cdf..9f68fe603 100644 --- a/lib_ui/src/main/res/drawable/ic_uart.xml +++ b/lib_ui/src/main/res/drawable/ic_uart.xml @@ -30,53 +30,53 @@ --> diff --git a/lib_ui/src/main/res/values/strings.xml b/lib_ui/src/main/res/values/strings.xml index b5b9c4a35..ebab6c164 100644 --- a/lib_ui/src/main/res/values/strings.xml +++ b/lib_ui/src/main/res/values/strings.xml @@ -42,7 +42,7 @@ Open logger application. Disconnect - Battery + Battery level Discovering services Please wait diff --git a/lib_utils/src/main/java/no/nordicsemi/android/toolbox/lib/utils/Profile.kt b/lib_utils/src/main/java/no/nordicsemi/android/toolbox/lib/utils/Profile.kt index c45eb3e63..6ec262fce 100644 --- a/lib_utils/src/main/java/no/nordicsemi/android/toolbox/lib/utils/Profile.kt +++ b/lib_utils/src/main/java/no/nordicsemi/android/toolbox/lib/utils/Profile.kt @@ -13,7 +13,7 @@ enum class Profile { LBS, RSCS, - // PRX, TODO: PRX is not implemented yet, it will be added in the future. + // PRX, TODO: Proximity is not implemented yet, it will be added in the future. BATTERY, THROUGHPUT, UART; @@ -21,18 +21,18 @@ enum class Profile { override fun toString(): String = when (this) { BPS -> "Blood Pressure" - CGM -> "Continuous Glucose Monitoring" + CGM -> "Continuous Glucose" CHANNEL_SOUNDING -> "Channel Sounding" CSC -> "Cycling Speed and Cadence" - DFS -> "Direction Finder Service" + DFS -> "Distance Measurement" GLS -> "Glucose" - HRS -> "Heart Rate Sensor" + HRS -> "Heart Rate" HTS -> "Health Thermometer" - LBS -> "Blinky/LED Button Service" - RSCS -> "Running Speed and Cadence Sensor" - BATTERY -> "Battery Service" + LBS -> "LED Button" + RSCS -> "Running Speed and Cadence" + BATTERY -> "Battery" THROUGHPUT -> "Throughput Service" - UART -> "UART Service" + UART -> "UART" DFU -> "Device Firmware Update" } diff --git a/lib_utils/src/main/res/drawable/ic_device_manager.xml b/lib_utils/src/main/res/drawable/ic_device_manager.xml index 854e204e0..07abb4ad9 100644 --- a/lib_utils/src/main/res/drawable/ic_device_manager.xml +++ b/lib_utils/src/main/res/drawable/ic_device_manager.xml @@ -1,6 +1,6 @@ + android:fillColor="#000"/> + android:fillColor="#000"/> + android:strokeColor="#000"/> diff --git a/lib_utils/src/main/res/drawable/ic_dfu.xml b/lib_utils/src/main/res/drawable/ic_dfu.xml index 2605c70eb..eecdb9b8d 100644 --- a/lib_utils/src/main/res/drawable/ic_dfu.xml +++ b/lib_utils/src/main/res/drawable/ic_dfu.xml @@ -30,23 +30,23 @@ --> diff --git a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/bps/BPMStatus.kt b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/bps/BPMStatus.kt index 04afcd122..260d21400 100644 --- a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/bps/BPMStatus.kt +++ b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/bps/BPMStatus.kt @@ -4,7 +4,6 @@ class BPMStatus( val bodyMovementDetected: Boolean, val cuffTooLose: Boolean, val irregularPulseDetected: Boolean, - val pulseRateInRange: Boolean, val pulseRateExceedsUpperLimit: Boolean, val pulseRateIsLessThenLowerLimit: Boolean, val improperMeasurementPosition: Boolean @@ -13,7 +12,6 @@ class BPMStatus( bodyMovementDetected = value and 0x01 != 0, cuffTooLose = value and 0x02 != 0, irregularPulseDetected = value and 0x04 != 0, - pulseRateInRange = value and 0x18 shr 3 == 0, pulseRateExceedsUpperLimit = value and 0x18 shr 3 == 1, pulseRateIsLessThenLowerLimit = value and 0x18 shr 3 == 2, improperMeasurementPosition = value and 0x20 != 0 diff --git a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/bps/BPSData.kt b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/bps/BPSData.kt index 34b0fc8c4..820702e1a 100644 --- a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/bps/BPSData.kt +++ b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/bps/BPSData.kt @@ -1,7 +1,5 @@ package no.nordicsemi.android.toolbox.profile.parser.bps -import androidx.annotation.FloatRange -import androidx.annotation.IntRange import java.util.Calendar enum class BloodPressureType(internal val value: Int) { @@ -21,10 +19,10 @@ data class BloodPressureMeasurementData( ) data class IntermediateCuffPressureData( - @param:FloatRange(from = 0.0) val cuffPressure: Float, + val cuffPressure: Float, val unit: BloodPressureType, - @param:FloatRange(from = 0.0) val pulseRate: Float? = null, - @param:IntRange(from = 0, to = 255) val userID: Int? = null, + val pulseRate: Float? = null, + val userID: Int? = null, val status: BPMStatus? = null, val calendar: Calendar? = null ) diff --git a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/bps/BloodPressureFeatureData.kt b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/bps/BloodPressureFeatureData.kt index 97d0e308d..2cf2a8bb5 100644 --- a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/bps/BloodPressureFeatureData.kt +++ b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/bps/BloodPressureFeatureData.kt @@ -7,11 +7,19 @@ package no.nordicsemi.android.toolbox.profile.parser.bps * @param irregularPulseDetection Indicates if irregular pulse detection is supported. * @param pulseRateRangeDetection Indicates if pulse rate range detection is supported. * @param measurementPositionDetection Indicates if measurement position detection is supported. + * @param multipleBonds Indicates if multiple bonds are supported. + * @param e2eCrc Indicates if E2E CRC is supported. + * @param userData Indicates if user data is supported. + * @param userFacingTime Indicates if user facing time is supported. */ data class BloodPressureFeatureData( val bodyMovementDetection: Boolean, val cuffFitDetection: Boolean, val irregularPulseDetection: Boolean, val pulseRateRangeDetection: Boolean, - val measurementPositionDetection: Boolean + val measurementPositionDetection: Boolean, + val multipleBonds: Boolean, + val e2eCrc: Boolean, + val userData: Boolean, + val userFacingTime: Boolean, ) diff --git a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/bps/BloodPressureFeatureParser.kt b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/bps/BloodPressureFeatureParser.kt index 1c26c9062..162de2dc6 100644 --- a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/bps/BloodPressureFeatureParser.kt +++ b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/bps/BloodPressureFeatureParser.kt @@ -2,6 +2,7 @@ package no.nordicsemi.android.toolbox.profile.parser.bps /** * Blood Pressure Feature data parser. + * * This parser is used to parse the Blood Pressure Feature data from the Bluetooth GATT characteristic. * The data is a 2-byte value that contains flags indicating the supported features of the Blood Pressure service. */ @@ -15,13 +16,21 @@ object BloodPressureFeatureParser { val irregularPulseDetection = flags and 0x0004 != 0 val pulseRateRangeDetection = flags and 0x0008 != 0 val measurementPositionDetection = flags and 0x0010 != 0 + val multipleBonds = flags and 0x0020 != 0 + val e2eCrc = flags and 0x0040 != 0 + val userData = flags and 0x0080 != 0 + val userFacingTime = flags and 0x0100 != 0 return BloodPressureFeatureData( bodyMovementDetection = bodyMovementDetection, cuffFitDetection = cuffFitDetection, irregularPulseDetection = irregularPulseDetection, pulseRateRangeDetection = pulseRateRangeDetection, - measurementPositionDetection = measurementPositionDetection + measurementPositionDetection = measurementPositionDetection, + multipleBonds = multipleBonds, + e2eCrc = e2eCrc, + userData = userData, + userFacingTime = userFacingTime ) } } diff --git a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/bps/IntermediateCuffPressureParser.kt b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/bps/IntermediateCuffPressureParser.kt index 556c7910f..b36ceef05 100644 --- a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/bps/IntermediateCuffPressureParser.kt +++ b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/bps/IntermediateCuffPressureParser.kt @@ -38,9 +38,9 @@ object IntermediateCuffPressureParser { return null } - // Following bytes - systolic, diastolic and mean arterial pressure + // Following bytes - current cuff pressure val cuffPressure: Float = data.getFloat(offset, FloatFormat.IEEE_11073_16_BIT, byteOrder) - offset += 6 + offset += 2 + 4 // Skip diastolic and mean arterial pressure, which are set to NaN. // Parse timestamp if present var calendar: Calendar? = null diff --git a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/common/WorkingMode.kt b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/common/WorkingMode.kt index a8794f9ed..13d71d4d5 100644 --- a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/common/WorkingMode.kt +++ b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/common/WorkingMode.kt @@ -1,5 +1,11 @@ package no.nordicsemi.android.toolbox.profile.parser.common enum class WorkingMode { - ALL, LAST, FIRST; + ALL, FIRST, LAST; + + override fun toString(): String = when (this) { + ALL -> "All records" + LAST -> "Last record" + FIRST -> "First record" + } } diff --git a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/csc/CSCData.kt b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/csc/CSCData.kt index 463102733..a961acaea 100644 --- a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/csc/CSCData.kt +++ b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/csc/CSCData.kt @@ -1,7 +1,6 @@ package no.nordicsemi.android.toolbox.profile.parser.csc data class CSCData( - val scanDevices: Boolean = false, val speed: Float = 0f, val cadence: Float = 0f, val distance: Float = 0f, diff --git a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/csc/CSCDataParser.kt b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/csc/CSCDataParser.kt index feabc741b..6d9052dd4 100644 --- a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/csc/CSCDataParser.kt +++ b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/csc/CSCDataParser.kt @@ -6,7 +6,6 @@ import java.nio.ByteOrder import kotlin.experimental.and object CSCDataParser { - internal var previousData: CSCDataSnapshot = CSCDataSnapshot() internal var wheelRevolutions: Long = -1 diff --git a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/csc/SpeedUnit.kt b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/csc/SpeedUnit.kt index 03e1dc7d7..682fe550f 100644 --- a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/csc/SpeedUnit.kt +++ b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/csc/SpeedUnit.kt @@ -1,7 +1,7 @@ package no.nordicsemi.android.toolbox.profile.parser.csc -enum class SpeedUnit(val displayName: String) { - M_S("Meter per second (m/s)"), - KM_H("Kilometer per hour (km/h)"), - MPH("Mile per hour (mph)") +enum class SpeedUnit(val displayName: String, val unit: String) { + M_S("Meter per second", "m/s"), + KM_H("Kilometer per hour", "km/h"), + MPH("Mile per hour", "mph") } \ No newline at end of file diff --git a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/csc/WheelSize.kt b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/csc/WheelSize.kt index 0ffc54d8f..efb87ef3d 100644 --- a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/csc/WheelSize.kt +++ b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/csc/WheelSize.kt @@ -2,61 +2,49 @@ package no.nordicsemi.android.toolbox.profile.parser.csc data class WheelSize( val value: Int, - val name: String -) + val name: String, + val description: String, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as WheelSize + return value == other.value + } + + override fun hashCode(): Int = value +} object WheelSizes { val data = listOf( - WheelSize(2340, "60-622"), - WheelSize(2284, "50-622"), - WheelSize(2268, "47-622"), - WheelSize(2224, "44-622"), - WheelSize(2265, "40-635"), - WheelSize(2224, "40-622"), - WheelSize(2180, "38-622"), - WheelSize(2205, "37-622"), - WheelSize(2168, "35-622"), - WheelSize(2199, "32-630"), - WheelSize(2174, "32-622"), - WheelSize(2155, "32-622"), - WheelSize(2149, "28-622"), - WheelSize(2146, "60-559"), - WheelSize(2136, "28-622"), - WheelSize(2146, "25-622"), - WheelSize(2105, "25-622"), - WheelSize(2133, "23-622"), - WheelSize(2114, "20-622"), - WheelSize(2102, "18-622"), - WheelSize(2169, "35-630"), - WheelSize(2161, "32-630"), - WheelSize(2155, "28-630"), - WheelSize(2133, "57-559"), - WheelSize(2114, "54-559"), - WheelSize(2105, "37-590"), - WheelSize(2097, "23-622"), - WheelSize(2089, "50-559"), - WheelSize(2086, "20-622"), - WheelSize(2114, "54-559"), - WheelSize(2070, "47-559"), - WheelSize(2068, "35-590"), - WheelSize(2105, "37-590"), - WheelSize(2055, "47-559"), - WheelSize(2089, "50-559"), - WheelSize(2051, "44-559"), - WheelSize(2026, "40-559"), - WheelSize(1973, "23-571"), - WheelSize(1954, "20-571"), - WheelSize(1953, "32-559"), - WheelSize(1952, "25-571"), - WheelSize(1948, "34-540"), - WheelSize(1910, "50-507"), - WheelSize(1907, "47-507"), - WheelSize(1618, "28-451"), - WheelSize(1593, "50-406"), - WheelSize(1590, "47-406"), - WheelSize(1325, "28-369"), - WheelSize(1282, "35-349"), - WheelSize(1272, "47-305") + // Those were ChatGPT-generated. If incorrect, please update. + WheelSize(2201, "60-584", "27.5×2.35 / 650B"), + WheelSize(2166, "57-584", "27.5×2.25 / 650B"), + WheelSize(2132, "54-584", "27.5×2.1 / 650B"), + WheelSize(2089, "50-584", "27.5×2.0 / 650B"), + WheelSize(2055, "47-584", "27.5×1.75 / 650B"), + + WheelSize(2071, "37-590", "27×1⅜ (Vintage Road)"), + + WheelSize(2150, "60-559", "26×2.35 (26\" MTB)"), + WheelSize(2123, "57-559", "26×2.25 (26\" MTB)"), + WheelSize(2097, "54-559", "26×2.1 (26\" MTB)"), + WheelSize(2070, "50-559", "26×2.0 (26\" MTB)"), + WheelSize(2051, "47-559", "26×1.75 (26\" MTB)"), + + WheelSize(2243, "45-622", "700×45c (Adventure/Gravel)"), + WheelSize(2192, "40-622", "700×40c (Gravel)"), + WheelSize(2169, "38-622", "700×38c (City/Gravel)"), + WheelSize(2139, "35-622", "700×35c (Hybrid/Gravel)"), + WheelSize(2108, "32-622", "700×32c (Commuter/Gravel)"), + WheelSize(2076, "28-622", "700×28c (All-Road)"), + WheelSize(2058, "25-622", "700×25c (Road Endurance)"), + WheelSize(2045, "23-622", "700×23c (Road Racing)"), + + WheelSize(1888, "47-507", "24×1.75 (Kids/Junior MTB)"), + + WheelSize(1634, "57-406", "20×2.125 (BMX/Folding)"), + WheelSize(1571, "47-406", "20×1.75 (BMX/Folding)"), ) val default = data.first() diff --git a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/Mapper.kt b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/Mapper.kt deleted file mode 100644 index 78b50d49b..000000000 --- a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/Mapper.kt +++ /dev/null @@ -1,11 +0,0 @@ -package no.nordicsemi.android.toolbox.profile.parser.directionFinder - -import no.nordicsemi.android.toolbox.profile.parser.directionFinder.controlPoint.ControlPointMode -import no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance.DistanceMode - -fun ControlPointMode.toDistanceMode(): DistanceMode { - return when (this) { - ControlPointMode.RTT -> DistanceMode.RTT - ControlPointMode.MCPD -> DistanceMode.MCPD - } -} \ No newline at end of file diff --git a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/PeripheralBluetoothAddress.kt b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/PeripheralBluetoothAddress.kt index fb87e20dd..4f918e72d 100644 --- a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/PeripheralBluetoothAddress.kt +++ b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/PeripheralBluetoothAddress.kt @@ -4,22 +4,35 @@ data class PeripheralBluetoothAddress( val type: AddressType, val address: String ) { + override fun toString(): String = "$address ($type)" companion object { - val TEST = PeripheralBluetoothAddress(AddressType.PUBLIC, "AA:BB:CC:DD:EE:FF") + // Note: The nRF DM sample sends distance to a device with address AA:BB:CC:DD:EE:FF + // with Azimuth and Elevation. This is a fake device to test the data. + // Don't change the address below. + val TEST = PeripheralBluetoothAddress(AddressType.RANDOM, "AA:BB:CC:DD:EE:FF") } } enum class AddressType(val id: Int) { + /** Public Bluetooth Address. */ PUBLIC(0), + /** Random Bluetooth Address. */ RANDOM(1), + /** Public Bluetooth Address identity, which may be resolved to the real Public Bluetooth Address. */ PUBLIC_ID(2), + /** Random Bluetooth Address identity, which may be resolved to the real Random Bluetooth Address. */ RANDOM_ID(3); + override fun toString(): String = when (this) { + PUBLIC -> "Public" + RANDOM -> "Random" + PUBLIC_ID -> "Public ID" + RANDOM_ID -> "Random ID" + } + companion object { - fun create(id: Int): AddressType { - return entries.find { it.id == id } - ?: throw IllegalArgumentException("Cannot find AddressType for specified id: $id") - } + fun create(id: Int): AddressType = entries.find { it.id == id } + ?: throw IllegalArgumentException("Cannot find AddressType for specified id: $id") } } diff --git a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/distance/QualityIndicator.kt b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/QualityIndicator.kt similarity index 96% rename from profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/distance/QualityIndicator.kt rename to profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/QualityIndicator.kt index 1cda7dc3b..f65cfe82c 100644 --- a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/distance/QualityIndicator.kt +++ b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/QualityIndicator.kt @@ -1,4 +1,4 @@ -package no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance +package no.nordicsemi.android.toolbox.profile.parser.directionFinder enum class QualityIndicator(val id: Int) { GOOD(0), @@ -12,4 +12,4 @@ enum class QualityIndicator(val id: Int) { ?: throw IllegalArgumentException("Cannot find QualityIndicator for specified id: $id") } } -} +} \ No newline at end of file diff --git a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/azimuthal/AzimuthalMeasurementData.kt b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/azimuthal/AzimuthalMeasurementData.kt index db880273d..1fc054299 100644 --- a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/azimuthal/AzimuthalMeasurementData.kt +++ b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/azimuthal/AzimuthalMeasurementData.kt @@ -1,22 +1,28 @@ package no.nordicsemi.android.toolbox.profile.parser.directionFinder.azimuthal import no.nordicsemi.android.toolbox.profile.parser.directionFinder.PeripheralBluetoothAddress -import no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance.QualityIndicator +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.QualityIndicator /** * Azimuth represents the horizontal direction of a signal source relative to a - * receiver or reference point. Represents the horizontal direction (angle on a flat plane) - * Example: AzimuthMeasurementData( - * flags=0, + * receiver or reference point. Represents the horizontal direction (angle on a flat plane). + * + * ### Example: + * ```kotlin + * AzimuthMeasurementData( * quality=GOOD, * address=PeripheralBluetoothAddress(type=RANDOM, address=aa:bb:cc:dd:ee:ff), * azimuth=156 - * ) here azimuth = 156° indicates that the detected device is 156° clockwise from the reference direction (e.g., true north or some defined zero-point). - * The quality=GOOD suggests the measurement is reliable. Azimuthal data in devices like yours is calculated using signal phase differences and other electronic measurements. + * ) + * ``` + * here azimuth = 156° indicates that the detected device is 156° clockwise from the reference + * direction (e.g., true north or some defined zero-point). + * + * The `quality=GOOD` suggests the measurement is reliable. Azimuthal data in devices like yours + * is calculated using signal phase differences and other electronic measurements. */ data class AzimuthMeasurementData( - val flags: Byte = Byte.MAX_VALUE, val quality: QualityIndicator = QualityIndicator.GOOD, val address: PeripheralBluetoothAddress, - val azimuth: Int = 0 + val azimuth: Int ) \ No newline at end of file diff --git a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/azimuthal/AzimuthalMeasurementDataParser.kt b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/azimuthal/AzimuthalMeasurementDataParser.kt index b476ea9ed..e29ad930f 100644 --- a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/azimuthal/AzimuthalMeasurementDataParser.kt +++ b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/azimuthal/AzimuthalMeasurementDataParser.kt @@ -2,7 +2,7 @@ package no.nordicsemi.android.toolbox.profile.parser.directionFinder.azimuthal import no.nordicsemi.android.toolbox.profile.parser.directionFinder.AddressType import no.nordicsemi.android.toolbox.profile.parser.directionFinder.PeripheralBluetoothAddress -import no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance.QualityIndicator +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.QualityIndicator import no.nordicsemi.kotlin.data.IntFormat import no.nordicsemi.kotlin.data.getInt import java.nio.ByteOrder @@ -15,29 +15,28 @@ class AzimuthalMeasurementDataParser { ): AzimuthMeasurementData? { if (data.size < 10) return null - var offset = 0 - val flags = data[offset].also { offset++ } - val qualityIndicator = data.getInt(offset++, IntFormat.UINT8) - - val address = StringBuilder().apply { - for (i in 0..5) { - data.getInt(offset++, IntFormat.UINT8).let { - insert(0, Integer.toHexString(it)) - if (i != 5) insert(0, ":") - } - } - }.toString() - - val addressType = data.getInt(offset++, IntFormat.UINT8) - - val azimuth = data.getInt(offset, IntFormat.UINT16, byteOrder).also { offset += 2 } - - return AzimuthMeasurementData( - flags, - QualityIndicator.create(qualityIndicator), - PeripheralBluetoothAddress(AddressType.create(addressType), address), - azimuth - ) + var offset = 1 // Skip flags + + // Parse quality indicator. + val qualityIndicator = data.getInt(offset, IntFormat.UINT8) + .let { QualityIndicator.create(it) } + .also { offset += 1 } + + // Parse the target Device Address. + val addressValue = data + .sliceArray(offset until offset + 6) + .reversed() + .joinToString(":") { "%02X".format(it.toInt() and 0xFF) } + .also { offset += 6 } + + val addressType = data.getInt(offset, IntFormat.UINT8) + .let { AddressType.create(it) } + .also { offset += 1 } + val address = PeripheralBluetoothAddress(addressType, addressValue) + + val azimuth = data.getInt(offset, IntFormat.UINT16, byteOrder) + + return AzimuthMeasurementData(qualityIndicator, address, azimuth) } } diff --git a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/controlPoint/ControlPointDataParser.kt b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/controlPoint/ControlPointDataParser.kt index 6d354dac4..6064f4f3b 100644 --- a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/controlPoint/ControlPointDataParser.kt +++ b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/controlPoint/ControlPointDataParser.kt @@ -6,7 +6,6 @@ import no.nordicsemi.kotlin.data.getInt class ControlPointDataParser { fun parse(data: ByteArray): ControlPointResult? { - if (data.isEmpty()) return null var offset = 0 @@ -17,18 +16,20 @@ class ControlPointDataParser { data.getInt(offset, IntFormat.UINT8) .let { ControlPointRequestCode.create(it) } ?.let { controlPointRequestCode -> - val result = data.getInt(offset, IntFormat.UINT8) + val result = data.getInt(offset++, IntFormat.UINT8) .let { ControlPointResponseCodeValue.create(it) } if (result == null) { return@let } return when (controlPointRequestCode) { - ControlPointRequestCode.CHANGE_MODE -> onChangeModeResult( - result, - data.getInt(offset, IntFormat.UINT8) - ) + ControlPointRequestCode.CHANGE_MODE -> { + onChangeModeResult(result) + } ControlPointRequestCode.CHECK_MODE -> { - onCheckModeResult(result, data.getInt(offset, IntFormat.UINT8)) + onCheckModeResult( + opCode = result, + modes = data.slice(offset until data.size) + .mapNotNull { ControlPointMode.create(it.toInt()) }) } } } @@ -37,32 +38,20 @@ class ControlPointDataParser { private fun onChangeModeResult( opCode: ControlPointResponseCodeValue, - value: Int - ): ControlPointResult { - return when (opCode) { - ControlPointResponseCodeValue.SUCCESS -> ControlPointChangeModeSuccess( - ControlPointMode.create(value) ?: return ControlPointChangeModeError - ) - ControlPointResponseCodeValue.OP_CODE_NOT_SUPPORTED, - ControlPointResponseCodeValue.INVALID, - ControlPointResponseCodeValue.FAILED -> ControlPointChangeModeError - } + ): ControlPointResult = when (opCode) { + ControlPointResponseCodeValue.SUCCESS -> ControlPointChangeModeSuccess + ControlPointResponseCodeValue.OP_CODE_NOT_SUPPORTED, + ControlPointResponseCodeValue.INVALID, + ControlPointResponseCodeValue.FAILED -> ControlPointChangeModeError } private fun onCheckModeResult( opCode: ControlPointResponseCodeValue, - value: Int - ): ControlPointResult { - return when (opCode) { - ControlPointResponseCodeValue.SUCCESS -> { - ControlPointMode.create(value)?.let { - ControlPointCheckModeSuccess(it) - } ?: ControlPointCheckModeError - } - - ControlPointResponseCodeValue.OP_CODE_NOT_SUPPORTED, - ControlPointResponseCodeValue.INVALID, - ControlPointResponseCodeValue.FAILED -> ControlPointCheckModeError - } + modes: List, + ): ControlPointResult = when (opCode) { + ControlPointResponseCodeValue.SUCCESS -> ControlPointCheckModeSuccess(modes) + ControlPointResponseCodeValue.OP_CODE_NOT_SUPPORTED, + ControlPointResponseCodeValue.INVALID, + ControlPointResponseCodeValue.FAILED -> ControlPointCheckModeError } } diff --git a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/controlPoint/ControlPointResult.kt b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/controlPoint/ControlPointResult.kt index ff822b153..cf9367dec 100644 --- a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/controlPoint/ControlPointResult.kt +++ b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/controlPoint/ControlPointResult.kt @@ -3,13 +3,11 @@ package no.nordicsemi.android.toolbox.profile.parser.directionFinder.controlPoin sealed class ControlPointResult data class ControlPointCheckModeSuccess( - val mode: ControlPointMode + val modes: List ) : ControlPointResult() data object ControlPointCheckModeError : ControlPointResult() -data class ControlPointChangeModeSuccess( - val mode: ControlPointMode -) : ControlPointResult() +data object ControlPointChangeModeSuccess : ControlPointResult() data object ControlPointChangeModeError : ControlPointResult() diff --git a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/distance/DirectionMode.kt b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/distance/DirectionMode.kt deleted file mode 100644 index 04bb31209..000000000 --- a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/distance/DirectionMode.kt +++ /dev/null @@ -1,5 +0,0 @@ -package no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance - -enum class DistanceMode { - MCPD, RTT -} \ No newline at end of file diff --git a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/distance/DistanceMeasurementData.kt b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/distance/DistanceMeasurementData.kt index a255298cf..d1efe0f2b 100644 --- a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/distance/DistanceMeasurementData.kt +++ b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/distance/DistanceMeasurementData.kt @@ -1,26 +1,24 @@ package no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance import no.nordicsemi.android.toolbox.profile.parser.directionFinder.PeripheralBluetoothAddress +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.QualityIndicator -sealed interface DistanceMeasurementData { - val flags: Byte - val quality: QualityIndicator - val address: PeripheralBluetoothAddress -} +sealed class DistanceMeasurementData( + open val quality: QualityIndicator, + open val address: PeripheralBluetoothAddress +) data class McpdMeasurementData( - override val flags: Byte = Byte.MAX_VALUE, - override val quality: QualityIndicator = QualityIndicator.GOOD, + override val quality: QualityIndicator, override val address: PeripheralBluetoothAddress, - val mcpd: MCPDEstimate = MCPDEstimate() -) : DistanceMeasurementData + val mcpd: MCPDEstimate +) : DistanceMeasurementData(quality, address) data class RttMeasurementData( - override val flags: Byte = Byte.MAX_VALUE, - override val quality: QualityIndicator = QualityIndicator.GOOD, + override val quality: QualityIndicator, override val address: PeripheralBluetoothAddress, val rtt: RTTEstimate = RTTEstimate() -) : DistanceMeasurementData +) : DistanceMeasurementData(quality, address) data class MCPDEstimate( val ifft: Int = 0, @@ -28,25 +26,18 @@ data class MCPDEstimate( val rssi: Int = 0, val best: Int = 0 ) { - - operator fun plus(value: Int): MCPDEstimate { - return MCPDEstimate( - ifft + value, - phaseSlope + value, - rssi + value, - best + value - ) - } + operator fun plus(value: Int): MCPDEstimate = MCPDEstimate( + ifft + value, + phaseSlope + value, + rssi + value, + best + value + ) } data class RTTEstimate( val value: Int = 0 ) { - operator fun inc(): RTTEstimate { - return RTTEstimate(value + 1) - } + operator fun inc(): RTTEstimate = RTTEstimate(value + 1) - operator fun plus(value: Int): RTTEstimate { - return RTTEstimate(this.value + value) - } + operator fun plus(value: Int): RTTEstimate = RTTEstimate(this.value + value) } diff --git a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/distance/DistanceMeasurementDataParser.kt b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/distance/DistanceMeasurementDataParser.kt index feffa38d4..6342a7721 100644 --- a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/distance/DistanceMeasurementDataParser.kt +++ b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/distance/DistanceMeasurementDataParser.kt @@ -2,6 +2,7 @@ package no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance import no.nordicsemi.android.toolbox.profile.parser.directionFinder.AddressType import no.nordicsemi.android.toolbox.profile.parser.directionFinder.PeripheralBluetoothAddress +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.QualityIndicator import no.nordicsemi.kotlin.data.IntFormat import no.nordicsemi.kotlin.data.getInt import java.nio.ByteOrder @@ -16,51 +17,45 @@ class DistanceMeasurementDataParser { if (data.size < 10) return null var offset = 0 - val flags = data[offset].also { offset++ } + + val flags = data[offset] + .also { offset += 1 } val isRTTPresent = flags and 0x01 > 0 val isMCPDPresent = flags and 0x02 > 0 - val qualityIndicator = data.getInt(offset++, IntFormat.UINT8) - - val address = StringBuilder().apply { - for (i in 0..5) { - data.getInt(offset++, IntFormat.UINT8).let { - insert(0, Integer.toHexString(it)) - if (i != 5) insert(0, ":") - } - } - }.toString() - - val addressType = data.getInt(offset++, IntFormat.UINT8) - - val rtt = if (isRTTPresent) { - RTTEstimate(data.getInt(offset, IntFormat.UINT16, byteOrder)).also { offset += 2 } - } else null - - val mcpd = if (isMCPDPresent) { - MCPDEstimate( - data.getInt(offset, IntFormat.UINT16, byteOrder).also { offset += 2 }, - data.getInt(offset, IntFormat.UINT16, byteOrder).also { offset += 2 }, - data.getInt(offset, IntFormat.UINT16, byteOrder).also { offset += 2 }, - data.getInt(offset, IntFormat.UINT16, byteOrder).also { offset += 2 }, - ) - } else null + require(isRTTPresent || isMCPDPresent) { + return null + } - val result = if (isRTTPresent) { - RttMeasurementData( - flags, - QualityIndicator.create(qualityIndicator), - PeripheralBluetoothAddress(AddressType.create(addressType), address), - rtt!! - ) - } else { - McpdMeasurementData( - flags, - QualityIndicator.create(qualityIndicator), - PeripheralBluetoothAddress(AddressType.create(addressType), address), - mcpd!!, - ) + // Parse quality indicator. + val qualityIndicator = data.getInt(offset, IntFormat.UINT8) + .let { QualityIndicator.create(it) } + .also { offset += 1 } + + // Parse the target Device Address. + val addressValue = data + .sliceArray(offset until offset + 6) + .reversed() + .joinToString(":") { "%02X".format(it.toInt() and 0xFF) } + .also { offset += 6 } + + val addressType = data.getInt(offset, IntFormat.UINT8) + .let { AddressType.create(it) } + .also { offset += 1 } + val address = PeripheralBluetoothAddress(addressType, addressValue) + + // Return data. It can be either one of these, as the type is a union\: + // https://docs.nordicsemi.com/bundle/nrf-apis-latest/page/structbt_ddfs_distance_measurement.html + if (isRTTPresent) { + val rtt = RTTEstimate(data.getInt(offset, IntFormat.UINT16, byteOrder)) + return RttMeasurementData(qualityIndicator, address, rtt) } - return result + val mcpd = MCPDEstimate( + data.getInt(offset, IntFormat.UINT16, byteOrder).also { offset += 2 }, + data.getInt(offset, IntFormat.UINT16, byteOrder).also { offset += 2 }, + data.getInt(offset, IntFormat.UINT16, byteOrder).also { offset += 2 }, + data.getInt(offset, IntFormat.UINT16, byteOrder).also { offset += 2 }, + ) + return McpdMeasurementData(qualityIndicator, address, mcpd) } } diff --git a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/elevation/ElevationMeasurementData.kt b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/elevation/ElevationMeasurementData.kt index da7704e59..b1e480356 100644 --- a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/elevation/ElevationMeasurementData.kt +++ b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/elevation/ElevationMeasurementData.kt @@ -1,11 +1,10 @@ package no.nordicsemi.android.toolbox.profile.parser.directionFinder.elevation import no.nordicsemi.android.toolbox.profile.parser.directionFinder.PeripheralBluetoothAddress -import no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance.QualityIndicator +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.QualityIndicator data class ElevationMeasurementData( - val flags: Byte = Byte.MAX_VALUE, - val quality: QualityIndicator = QualityIndicator.GOOD, + val quality: QualityIndicator, val address: PeripheralBluetoothAddress, - val elevation: Int = 0 + val elevation: Int ) \ No newline at end of file diff --git a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/elevation/ElevationMeasurementDataParser.kt b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/elevation/ElevationMeasurementDataParser.kt index b7f2ad1fe..a2302e58b 100644 --- a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/elevation/ElevationMeasurementDataParser.kt +++ b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/directionFinder/elevation/ElevationMeasurementDataParser.kt @@ -2,7 +2,7 @@ package no.nordicsemi.android.toolbox.profile.parser.directionFinder.elevation import no.nordicsemi.android.toolbox.profile.parser.directionFinder.AddressType import no.nordicsemi.android.toolbox.profile.parser.directionFinder.PeripheralBluetoothAddress -import no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance.QualityIndicator +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.QualityIndicator import no.nordicsemi.kotlin.data.IntFormat import no.nordicsemi.kotlin.data.getInt @@ -11,29 +11,27 @@ class ElevationMeasurementDataParser { fun parse(data: ByteArray): ElevationMeasurementData? { if (data.size < 10) return null - var offset = 0 - val flags = data[offset].also { offset++ } + var offset = 1 // Skip flags - val qualityIndicator = data.getInt(offset++, IntFormat.UINT8) + // Parse quality indicator. + val qualityIndicator = data.getInt(offset, IntFormat.UINT8) + .let { QualityIndicator.create(it) } + .also { offset += 1 } - val address = StringBuilder().apply { - for (i in 0..5) { - data.getInt(offset++, IntFormat.UINT8).let { - insert(0, Integer.toHexString(it).padStart(2, '0')) - if (i != 5) insert(0, ":") - } - } - }.toString() + // Parse the target Device Address. + val addressValue = data + .sliceArray(offset until offset + 6) + .reversed() + .joinToString(":") { "%02X".format(it.toInt() and 0xFF) } + .also { offset += 6 } - val addressType = data.getInt(offset++, IntFormat.UINT8) + val addressType = data.getInt(offset, IntFormat.UINT8) + .let { AddressType.create(it) } + .also { offset += 1 } + val address = PeripheralBluetoothAddress(addressType, addressValue) - val elevation = data.getInt(offset++, IntFormat.INT8) + val elevation = data.getInt(offset, IntFormat.INT8) - return ElevationMeasurementData( - flags, - QualityIndicator.create(qualityIndicator), - PeripheralBluetoothAddress(AddressType.create(addressType), address), - elevation - ) + return ElevationMeasurementData(qualityIndicator, address, elevation) } } diff --git a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/rscs/RSCSData.kt b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/rscs/RSCSData.kt index e144adaa0..6c31346dc 100644 --- a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/rscs/RSCSData.kt +++ b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/rscs/RSCSData.kt @@ -2,7 +2,7 @@ package no.nordicsemi.android.toolbox.profile.parser.rscs data class RSCSData( val running: Boolean = false, - val instantaneousSpeed: Float = 1.0f, + val instantaneousSpeed: Float = 0f, val instantaneousCadence: Int = 0, val strideLength: Int? = null, val totalDistance: Long? = null diff --git a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/rscs/RSCSDataParser.kt b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/rscs/RSCSDataParser.kt index 28abf6c95..a1fe48f40 100644 --- a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/rscs/RSCSDataParser.kt +++ b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/rscs/RSCSDataParser.kt @@ -22,7 +22,8 @@ object RSCSDataParser { .toFloat() .let { it / 256f // [m/s] - }.also { offset += 2 } + } + .also { offset += 2 } // Cadence val cadence: Int = data.getInt(offset, IntFormat.UINT8) diff --git a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/rscs/RSCSSettingsUnit.kt b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/rscs/RSCSSettingsUnit.kt index 14cc0015e..aefb78d8e 100644 --- a/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/rscs/RSCSSettingsUnit.kt +++ b/profile-parsers/src/main/java/no/nordicsemi/android/toolbox/profile/parser/rscs/RSCSSettingsUnit.kt @@ -1,18 +1,11 @@ package no.nordicsemi.android.toolbox.profile.parser.rscs enum class RSCSSettingsUnit { - UNIT_CM, - UNIT_M, - UNIT_KM, - UNIT_MPH, ; + UNIT_METRIC, + UNIT_IMPERIAL; - override fun toString(): String { - return when (this) { - UNIT_KM -> "Kilometer [km/h]" - UNIT_M -> "Meter [m/s]" - UNIT_MPH -> "Miles [mph]" - UNIT_CM -> "Centimeter [cm/s]" - } + override fun toString(): String = when (this) { + UNIT_METRIC -> "Metric" + UNIT_IMPERIAL -> "Imperial" } - } diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/ProfileScreen.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/ProfileScreen.kt index 761b92503..6440ce21b 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/ProfileScreen.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/ProfileScreen.kt @@ -8,10 +8,8 @@ import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.displayCutout import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.union import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button @@ -24,7 +22,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import no.nordicsemi.android.common.permissions.ble.RequireBluetooth import no.nordicsemi.android.common.permissions.ble.RequireLocation @@ -71,9 +69,7 @@ internal fun ProfileScreen() { Scaffold( contentWindowInsets = WindowInsets.displayCutout - .only(WindowInsetsSides.Horizontal) - .union(WindowInsets.navigationBars), -// .only(NavigationS), + .only(WindowInsetsSides.Horizontal), topBar = { // The device name is derived directly from the current state. val deviceName = (uiState as? ProfileUiState.Connected) @@ -100,6 +96,9 @@ internal fun ProfileScreen() { .fillMaxSize() .padding(paddingValues) .verticalScroll(rememberScrollState()) + .padding(all = 16.dp) + // Additional bottom padding. + .padding(bottom = 16.dp) .imePadding(), horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -115,29 +114,23 @@ internal fun ProfileScreen() { if (state.reason == null) { // This is the initial state before connection attempt // show device connecting view instead. - DeviceConnectingView( - modifier = Modifier.padding(16.dp) - ) + DeviceConnectingView() } else { DeviceDisconnectedView( disconnectedReason = state.reason.displayMessage(), isMissingService = false, - modifier = Modifier.padding(16.dp), ) { Button( modifier = Modifier.padding(16.dp), onClick = { onEvent(ConnectionEvent.OnRetryClicked) }, - - ) { + ) { Text(text = stringResource(id = R.string.reconnect)) } } } } - ProfileUiState.Loading -> DeviceConnectingView( - modifier = Modifier.padding(16.dp) - ) + ProfileUiState.Loading -> DeviceConnectingView() } } } @@ -156,16 +149,12 @@ internal fun DeviceConnectedView( if (state.isMissingServices) { DeviceDisconnectedView( reason = DisconnectReason.MISSING_SERVICE, - modifier = Modifier.padding(16.dp) ) return } Column( verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .padding(16.dp) - .imePadding() ) { // Show service discovery view if services are not yet available. if (state.deviceData.services.isEmpty()) { diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/battery/BatteryLevelView.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/battery/BatteryLevelView.kt index c7051f0c7..e91ac24fa 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/battery/BatteryLevelView.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/battery/BatteryLevelView.kt @@ -31,8 +31,6 @@ package no.nordicsemi.android.toolbox.profile.view.battery -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.BatteryChargingFull import androidx.compose.material.icons.outlined.Battery0Bar @@ -49,83 +47,59 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import no.nordicsemi.android.common.theme.nordicGrass import no.nordicsemi.android.common.theme.nordicGreen +import no.nordicsemi.android.common.theme.nordicSun +import no.nordicsemi.android.common.ui.view.SectionTitle import no.nordicsemi.android.toolbox.profile.viewmodel.BatteryViewModel import no.nordicsemi.android.ui.R import no.nordicsemi.android.ui.view.ScreenSection -import no.nordicsemi.android.ui.view.SectionTitle @Composable internal fun BatteryScreen() { val batteryViewModel = hiltViewModel() val batteryServiceData by batteryViewModel.batteryServiceState.collectAsStateWithLifecycle() + BatteryView(batteryServiceData.batteryLevel) +} + +@Composable +private fun BatteryView(batteryLevel: Int?) { ScreenSection { SectionTitle( icon = Icons.Default.BatteryChargingFull, title = stringResource(id = R.string.field_battery), menu = { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(start = 8.dp) - ) { - batteryServiceData.batteryLevel?.let { batteryLevel -> - DynamicBatteryStatus(batteryLevel) - Text(text = "$batteryLevel %") - } + batteryLevel?.let { batteryLevel -> + Text(text = "$batteryLevel%") + DynamicBatteryStatus(batteryLevel) } } ) } } -@Preview(showBackground = true) @Composable -internal fun DynamicBatteryStatus(batteryLevel: Int = 40) { +private fun DynamicBatteryStatus(batteryLevel: Int) { val (batteryIcon: ImageVector, color: Color) = when { - batteryLevel > 95 -> { - Icons.Outlined.BatteryFull to nordicGreen - } // Full Battery - batteryLevel > 80 -> { - Icons.Outlined.Battery6Bar to nordicGreen - } - - batteryLevel > 70 -> { - Icons.Outlined.Battery5Bar to nordicGreen - } // Moderate Battery - batteryLevel > 55 -> { - Icons.Outlined.Battery4Bar to nordicGreen - } // Moderate Battery - - batteryLevel > 40 -> { - Icons.Outlined.Battery3Bar to nordicGreen - } // Moderate Battery - - batteryLevel > 25 -> { - Icons.Outlined.Battery2Bar to nordicGrass - } // Low Battery - - batteryLevel > 10 -> { - Icons.Outlined.Battery1Bar to MaterialTheme.colorScheme.error - } // Low Battery - - batteryLevel > 5 -> { - Icons.Outlined.Battery0Bar to MaterialTheme.colorScheme.error - } // Low Battery - - else -> { - Icons.Outlined.BatteryAlert to MaterialTheme.colorScheme.error - } // Critically Low Battery + // Full Battery + batteryLevel > 95 -> Icons.Outlined.BatteryFull to nordicGreen + batteryLevel > 80 -> Icons.Outlined.Battery6Bar to nordicGreen + batteryLevel > 70 -> Icons.Outlined.Battery5Bar to nordicGreen + // Moderate Battery + batteryLevel > 55 -> Icons.Outlined.Battery4Bar to nordicSun + batteryLevel > 40 -> Icons.Outlined.Battery3Bar to nordicSun + batteryLevel > 25 -> Icons.Outlined.Battery2Bar to nordicSun + // Low Battery + batteryLevel > 10 -> Icons.Outlined.Battery1Bar to MaterialTheme.colorScheme.error + batteryLevel > 5 -> Icons.Outlined.Battery0Bar to MaterialTheme.colorScheme.error + // Critically Low Battery + else -> Icons.Outlined.BatteryAlert to MaterialTheme.colorScheme.error } Icon( @@ -134,3 +108,33 @@ internal fun DynamicBatteryStatus(batteryLevel: Int = 40) { tint = color, ) } + +@Preview +@Composable +private fun BatteryPreview() { + BatteryView(100) +} + +@Preview +@Composable +private fun BatteryPreview_50() { + BatteryView(50) +} + +@Preview +@Composable +private fun BatteryPreview_20() { + BatteryView(20) +} + +@Preview +@Composable +private fun BatteryPreview_0() { + BatteryView(0) +} + +@Preview +@Composable +private fun BatteryPreview_unknown() { + BatteryView(null) +} \ No newline at end of file diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/bps/BPSScreen.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/bps/BPSScreen.kt index 9e235cc92..cec6e40ca 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/bps/BPSScreen.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/bps/BPSScreen.kt @@ -1,243 +1,350 @@ package no.nordicsemi.android.toolbox.profile.view.bps -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Checklist import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import no.nordicsemi.android.common.ui.view.SectionTitle +import no.nordicsemi.android.toolbox.profile.R +import no.nordicsemi.android.toolbox.profile.parser.bps.BPMStatus import no.nordicsemi.android.toolbox.profile.parser.bps.BloodPressureFeatureData import no.nordicsemi.android.toolbox.profile.parser.bps.BloodPressureMeasurementData +import no.nordicsemi.android.toolbox.profile.parser.bps.BloodPressureMeasurementParser import no.nordicsemi.android.toolbox.profile.parser.bps.BloodPressureType import no.nordicsemi.android.toolbox.profile.parser.bps.IntermediateCuffPressureData -import no.nordicsemi.android.toolbox.profile.R +import no.nordicsemi.android.toolbox.profile.parser.bps.IntermediateCuffPressureParser import no.nordicsemi.android.toolbox.profile.viewmodel.BPSViewModel -import no.nordicsemi.android.ui.view.FeatureSupported +import no.nordicsemi.android.ui.view.FeaturesColumn import no.nordicsemi.android.ui.view.KeyValueColumn import no.nordicsemi.android.ui.view.KeyValueColumnReverse import no.nordicsemi.android.ui.view.ScreenSection import no.nordicsemi.android.ui.view.SectionRow -import no.nordicsemi.android.ui.view.SectionTitle +import no.nordicsemi.android.ui.view.StatusColumn @Composable internal fun BPSScreen() { val bpsViewModel = hiltViewModel() val serviceData by bpsViewModel.bpsServiceState.collectAsStateWithLifecycle() - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxWidth() - ) { - ScreenSection(modifier = Modifier.padding(bottom = 16.dp)) { - SectionTitle( - resId = R.drawable.ic_bps, - title = stringResource(id = R.string.bps_title), - modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp) + serviceData.intermediateCuffPressure?.let { + IntermediateBloodPressureView(it) + + Spacer(modifier = Modifier.height(16.dp)) + } + + BloodPressureView(serviceData.bloodPressureMeasurement) + + serviceData.bloodPressureFeature?.let { + Spacer(modifier = Modifier.height(16.dp)) + + BloodPressureFeatureView(it) + } +} + +@Composable +private fun IntermediateBloodPressureView( + data: IntermediateCuffPressureData +) { + ScreenSection { + SectionTitle( + painter = painterResource(R.drawable.ic_bps), + title = stringResource(id = R.string.bps_intermediate_cuff_title), + ) + SectionRow { + KeyValueColumn( + key = stringResource(id = R.string.bps_intermediate_cuff_pressure), + value = data.displayCuffPressure(), ) - serviceData.bloodPressureMeasurement?.let { - BloodPressureView(it) - } - serviceData.intermediateCuffPressure?.displayHeartRate()?.let { - HorizontalDivider() - SectionRow { - KeyValueColumn( - stringResource(id = R.string.bps_pulse), - it, - verticalSpacing = 4.dp, - modifier = Modifier.padding(start = 16.dp) - ) - } - } + KeyValueColumnReverse( + key = stringResource(id = R.string.bps_pulse), + value = data.displayHeartRate(), + ) + } - serviceData.bloodPressureFeature?.let { - HorizontalDivider() - Text( - "Blood pressure features", - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.secondary - ) - BloodPressureFeatureView(it) - } + data.status?.let { status -> + HorizontalDivider() - if (serviceData.intermediateCuffPressure == null && - serviceData.bloodPressureMeasurement == null && - serviceData.bloodPressureFeature == null - ) { - WaitingForMeasurementView() - } + MeasurementStatusView(status) } } } @Composable -internal fun WaitingForMeasurementView() { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - ) { - Text( - text = stringResource(id = R.string.no_data_info_title), - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center - ) - Text( - text = stringResource(id = R.string.no_data_info), - style = MaterialTheme.typography.bodyMedium, - textAlign = TextAlign.Center, +private fun BloodPressureView( + data: BloodPressureMeasurementData? +) { + ScreenSection { + SectionTitle( + painter = painterResource(R.drawable.ic_bps), + title = stringResource(id = R.string.bps_title), ) + if (data != null) { + BloodPressureView(data) + + data.status?.let { status -> + HorizontalDivider() + + MeasurementStatusView(status) + } + } else { + WaitingForMeasurementView() + } } } -@Preview(showBackground = true) @Composable -private fun WaitingForMeasurementViewPreview() { - WaitingForMeasurementView() +internal fun ColumnScope.BloodPressureView(state: BloodPressureMeasurementData) { + SectionRow { + KeyValueColumn( + key = stringResource(id = R.string.bps_systolic), + value = state.displaySystolic() + ) + KeyValueColumnReverse( + key = stringResource(id = R.string.bps_diastolic), + value = state.displayDiastolic() + ) + } + SectionRow { + KeyValueColumn( + key = stringResource(id = R.string.bps_mean), + value = state.displayMeanArterialPressure() + ) + state.pulseRate?.let { + KeyValueColumnReverse( + key = stringResource(id = R.string.bps_pulse), + value = state.displayPulseRate() + ) + } + } + state.calendar?.let { + stringResource(R.string.bps_timestamp, it) + }?.let { + KeyValueColumn( + key = "Date & Time", + value = it + ) + } } @Composable -internal fun BloodPressureFeatureView(it: BloodPressureFeatureData) { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.padding(start = 16.dp, end = 16.dp) - ) { - if (it.bodyMovementDetection) { - FeatureSupported(stringResource(id = R.string.body_movement_detected)) - } - if (it.cuffFitDetection) { - FeatureSupported(stringResource(id = R.string.cuff_fit_detected)) - } - if (it.irregularPulseDetection) { - FeatureSupported(stringResource(id = R.string.irregular_heart_rate_detected)) - } - if (it.pulseRateRangeDetection) { - FeatureSupported(stringResource(id = R.string.pulse_rate_detected)) - } - if (it.measurementPositionDetection) { - FeatureSupported(stringResource(id = R.string.measurement_position_detected)) - } +private fun ColumnScope.MeasurementStatusView(status: BPMStatus) { + Text( + text = "Measurement status", + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.secondary + ) + StatusColumn { + StatusRow( + text = stringResource(id = R.string.bps_status_body_movement_detected), + isPresent = status.bodyMovementDetected + ) + StatusRow( + text = stringResource(id = R.string.bps_status_cuff_too_lose), + isPresent = status.cuffTooLose + ) + StatusRow( + text = stringResource(id = R.string.bps_status_irregular_heart_rate_detected), + isPresent = status.irregularPulseDetected + ) + StatusRow( + text = stringResource(id = R.string.bps_status_pulse_rate_higher_limit), + isPresent = status.pulseRateExceedsUpperLimit + ) + StatusRow( + text = stringResource(id = R.string.bps_status_pulse_rate_lower_limit), + isPresent = status.pulseRateIsLessThenLowerLimit + ) + StatusRow( + text = stringResource(id = R.string.bps_status_improper_measurement_position), + isPresent = status.improperMeasurementPosition + ) } } @Composable -internal fun BloodPressureView(state: BloodPressureMeasurementData) { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.padding(start = 16.dp, end = 16.dp) - ) { - SectionRow { - KeyValueColumn( - stringResource(id = R.string.bps_systolic), - state.displaySystolic() +internal fun BloodPressureFeatureView(features: BloodPressureFeatureData) { + ScreenSection { + SectionTitle( + painter = rememberVectorPainter(Icons.Default.Checklist), + title = stringResource(id = R.string.bps_features_title), + ) + FeaturesColumn { + FeatureRow( + text = stringResource(R.string.bps_feature_body_movement_detection), + supported = features.bodyMovementDetection ) - KeyValueColumnReverse( - stringResource(id = R.string.bps_diastolic), - state.displayDiastolic() + FeatureRow( + text = stringResource(R.string.bps_feature_cuff_fit_detection), + supported = features.cuffFitDetection ) - } - SectionRow { - KeyValueColumn( - stringResource(id = R.string.bps_mean), - state.displayMeanArterialPressure() + FeatureRow( + text = stringResource(R.string.bps_feature_irregular_heart_rate_detection), + supported = features.irregularPulseDetection + ) + FeatureRow( + text = stringResource(R.string.bps_feature_pulse_rate_range_detection), + supported = features.pulseRateRangeDetection + ) + FeatureRow( + text = stringResource(R.string.bps_feature_measurement_position_detection), + supported = features.measurementPositionDetection + ) + FeatureRow( + text = stringResource(R.string.bps_feature_multiple_bonds), + supported = features.multipleBonds + ) + FeatureRow( + text = stringResource(R.string.bps_feature_e2e_crc), + supported = features.e2eCrc + ) + FeatureRow( + text = stringResource(R.string.bps_feature_user_data), + supported = features.userData + ) + FeatureRow( + text = stringResource(R.string.bps_feature_user_facing_time), + supported = features.userFacingTime ) - state.pulseRate?.let { - KeyValueColumnReverse( - "Heart rate", state.displayPulseRate() - ) - } - } - SectionRow { - state.calendar?.let { - stringResource(R.string.bps_timestamp, it) - }?.let { - KeyValueColumn( - "Date & Time", - it - ) - } } } - state.status?.let { - HorizontalDivider() +} + +@Composable +private fun WaitingForMeasurementView() { + Column { + Text(text = stringResource(id = R.string.no_data_info_title)) Text( - "BPM status", - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.secondary + text = stringResource(id = R.string.no_data_info), + style = MaterialTheme.typography.bodySmall, + color = LocalContentColor.current.copy(alpha = 0.6f), ) - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.padding(start = 16.dp, end = 16.dp) - ) { - if (it.bodyMovementDetected) { - FeatureSupported(stringResource(id = R.string.body_movement_detected)) + } +} - } - if (it.irregularPulseDetected) { - FeatureSupported(stringResource(id = R.string.irregular_heart_rate_detected)) - } +@Preview +@Composable +private fun BloodPressureViewPreview_empty() { + BloodPressureView(data = null) +} - if (it.cuffTooLose) { - FeatureSupported("Cuff Too Lose") - } - if (it.pulseRateExceedsUpperLimit) { - FeatureSupported(stringResource(id = R.string.pulse_rate_higher_limit)) - } - if (it.pulseRateInRange) { - FeatureSupported(stringResource(id = R.string.pulse_rate_detected)) - } - if (it.improperMeasurementPosition) { - FeatureSupported(stringResource(id = R.string.improper_measurement_position)) - } +@Preview +@Composable +private fun BloodPressureViewPreview() { + val data = byteArrayOf( + 0x1f.toByte(), // Flags: All fields present + 0x79.toByte(), 0x00.toByte(), // Systolic: 121 + 0x51.toByte(), 0x00.toByte(), // Diastolic: 81 + 0x6a.toByte(), 0x00.toByte(), // Mean Arterial Pressure: 106 + 0xE4.toByte(), // Year LSB (2020) + 0x07.toByte(), // Year MSB (2020) + 0x05.toByte(), // Month: May + 0x15.toByte(), // Day: 21 + 0x0A.toByte(), // Hour: 10 + 0x1E.toByte(), // Minute: 30 + 0x2D.toByte(), // Second: 45 + 0x48.toByte(), 0x00.toByte(), // Pulse Rate: 72.0 bpm + 0x01.toByte(), // User ID: 1 + 0x06.toByte(), 0x00.toByte() // Measurement Status: Irregular pulse detected + ) - if (it.pulseRateIsLessThenLowerLimit) { - FeatureSupported(stringResource(id = R.string.pulse_rate_lower_limit)) - } - } - } + BloodPressureView( + data = BloodPressureMeasurementParser.parse(data)!!, + ) } +@Preview @Composable -fun BloodPressureMeasurementData.displaySystolic(): String = +private fun IntermediatePressureViewPreview() { + val data = byteArrayOf( + 0x1F.toByte(), // Flags: All features enabled + 0x51.toByte(), 0x00.toByte(), // Cuff pressure (81.0 mmHg) + 0x00.toByte(), 0x00.toByte(), // following bytes - Diastolic and MAP are unused + 0x00.toByte(), 0x00.toByte(), + 0xE4.toByte(), // Year LSB (2020) + 0x07.toByte(), // Year MSB (2020) + 0x05.toByte(), // Month: May + 0x15.toByte(), // Day: 21 + 0x0A.toByte(), // Hour: 10 + 0x1E.toByte(), // Minute: 30 + 0x2D.toByte(), // Second: 45 + 0x64.toByte(), 0x00.toByte(), // Pulse rate (100 bpm) + 0x01.toByte(), // User ID (1) + 0xFF.toByte(), 0x01.toByte() // Measurement status + ) + + IntermediateBloodPressureView( + data = IntermediateCuffPressureParser.parse(data)!!, + ) +} + +@Preview +@Composable +private fun BloodPressureFeatureViewPreview() { + BloodPressureFeatureView( + features = BloodPressureFeatureData( + bodyMovementDetection = false, + cuffFitDetection = true, + irregularPulseDetection = false, + pulseRateRangeDetection = true, + measurementPositionDetection = false, + multipleBonds = true, + e2eCrc = true, + userData = true, + userFacingTime = false, + ) + ) +} + +@Composable +private fun BloodPressureMeasurementData.displaySystolic(): String = stringResource( id = R.string.bps_blood_pressure, - systolic, displayUnit() + systolic, unit.displayUnit() ) @Composable -fun BloodPressureMeasurementData.displayDiastolic(): String = +private fun BloodPressureMeasurementData.displayDiastolic(): String = stringResource( id = R.string.bps_blood_pressure, - diastolic, displayUnit() + diastolic, unit.displayUnit() ) @Composable -fun BloodPressureMeasurementData.displayMeanArterialPressure(): String = +private fun BloodPressureMeasurementData.displayMeanArterialPressure(): String = stringResource( id = R.string.bps_blood_pressure, - meanArterialPressure, displayUnit() + meanArterialPressure, unit.displayUnit() ) @Composable -fun IntermediateCuffPressureData.displayHeartRate(): String = pulseRate?.toString() + " bpm" +private fun IntermediateCuffPressureData.displayCuffPressure(): String = cuffPressure.toString() + " ${unit.displayUnit()}" + +@Composable +private fun IntermediateCuffPressureData.displayHeartRate(): String = pulseRate?.toString() + " bpm" @Composable -fun BloodPressureMeasurementData.displayPulseRate(): String = pulseRate?.toString() + " bpm" +private fun BloodPressureMeasurementData.displayPulseRate(): String = pulseRate?.toString() + " bpm" @Composable -fun BloodPressureMeasurementData.displayUnit(): String = - if (unit == BloodPressureType.UNIT_MMHG) "mmHg" else "kPA" +private fun BloodPressureType.displayUnit(): String = + if (this == BloodPressureType.UNIT_MMHG) "mmHg" else "kPA" diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/cgms/CGMScreen.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/cgms/CGMScreen.kt index 41b89bcfd..dfb7cb923 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/cgms/CGMScreen.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/cgms/CGMScreen.kt @@ -2,29 +2,27 @@ package no.nordicsemi.android.toolbox.profile.view.cgms import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material.icons.filled.Download +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -32,292 +30,212 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import no.nordicsemi.android.common.ui.view.ActionOutlinedButton +import no.nordicsemi.android.common.ui.view.SectionTitle +import no.nordicsemi.android.toolbox.profile.R +import no.nordicsemi.android.toolbox.profile.data.CGMRecordWithSequenceNumber +import no.nordicsemi.android.toolbox.profile.data.CGMServiceData import no.nordicsemi.android.toolbox.profile.parser.cgms.data.CGMRecord import no.nordicsemi.android.toolbox.profile.parser.cgms.data.CGMStatus import no.nordicsemi.android.toolbox.profile.parser.common.WorkingMode import no.nordicsemi.android.toolbox.profile.parser.gls.data.RequestStatus -import no.nordicsemi.android.toolbox.profile.R -import no.nordicsemi.android.toolbox.profile.data.CGMRecordWithSequenceNumber -import no.nordicsemi.android.toolbox.profile.data.CGMServiceData -import no.nordicsemi.android.toolbox.profile.view.gls.toDisplayString import no.nordicsemi.android.toolbox.profile.viewmodel.CGMSEvent import no.nordicsemi.android.toolbox.profile.viewmodel.CGMSViewModel import no.nordicsemi.android.ui.view.KeyValueColumn import no.nordicsemi.android.ui.view.KeyValueColumnReverse import no.nordicsemi.android.ui.view.ScreenSection import no.nordicsemi.android.ui.view.SectionRow -import no.nordicsemi.android.ui.view.SectionTitle import java.util.Calendar @Composable internal fun CGMScreen() { val cgmVm = hiltViewModel() val serviceData by cgmVm.channelSoundingState.collectAsStateWithLifecycle() - var isWorkingModeClicked by rememberSaveable { mutableStateOf(false) } val onClickEvent: (CGMSEvent) -> Unit = { cgmVm.onEvent(it) } - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxWidth() - ) { - ScreenSection { - SectionTitle( - resId = R.drawable.ic_cgm, - title = "Continuous glucose monitoring", - menu = { - WorkingModeDropDown( - cgmState = serviceData, - isWorkingModeSelected = isWorkingModeClicked, - onExpand = { isWorkingModeClicked = true }, - onDismiss = { isWorkingModeClicked = false }, - onClickEvent = { onClickEvent(it) } - ) - } - ) - } - RecordsView(serviceData) - } + CGMSView(serviceData, onClickEvent) } @Composable -private fun WorkingModeDropDown( - cgmState: CGMServiceData, - isWorkingModeSelected: Boolean, - onExpand: () -> Unit, - onDismiss: () -> Unit, - onClickEvent: (CGMSEvent) -> Unit +private fun CGMSView( + serviceData: CGMServiceData, + onClickEvent: (CGMSEvent) -> Unit, ) { - if (cgmState.requestStatus == RequestStatus.PENDING) { - CircularProgressIndicator() - } else { - Column { - OutlinedButton(onClick = { onExpand() }) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = if (cgmState.workingMode != null) { - cgmState.workingMode!!.toDisplayString() - } else { - "Request" - } - ) - Icon(Icons.Default.ArrowDropDown, contentDescription = "") - } + ScreenSection { + SectionTitle( + painter = painterResource(R.drawable.ic_cgm), + title = stringResource(R.string.cgms_title), + menu = { + WorkingModeDropDown( + data = serviceData, + onClickEvent = onClickEvent, + ) } - if (isWorkingModeSelected) - WorkingModeDialog( - cgmState = cgmState, - onDismiss = onDismiss, - ) { - onClickEvent(it) - onDismiss() - } - } + ) + + RecordsView(serviceData) } } -@Preview(showBackground = true) @Composable -private fun WorkingModeDropDownPreview() { - WorkingModeDropDown(CGMServiceData(), false, {}, {}, {}) +private fun WorkingModeDropDown( + data: CGMServiceData, + onClickEvent: (CGMSEvent) -> Unit +) { + var showDialog by rememberSaveable { mutableStateOf(false) } + + ActionOutlinedButton( + text = "Request", + icon = Icons.Default.Download, + onClick = { showDialog = true }, + isInProgress = data.requestStatus == RequestStatus.PENDING, + ) + if (showDialog) { + WorkingModeDialog( + cgmState = data, + onDismiss = { showDialog = false }, + onWorkingModeSelected = onClickEvent, + ) + } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun WorkingModeDialog( cgmState: CGMServiceData, onDismiss: () -> Unit, onWorkingModeSelected: (CGMSEvent) -> Unit, ) { - val listState = rememberLazyListState() - val workingModeEntries = WorkingMode.entries.map { it } - val selectedIndex = workingModeEntries.indexOf(cgmState.workingMode) + val workingModeEntries = WorkingMode.entries.toList() - LaunchedEffect(selectedIndex) { - if (selectedIndex >= 0) { - listState.scrollToItem(selectedIndex) - } - } - - Dialog( - onDismissRequest = { onDismiss() }, + BasicAlertDialog( + onDismissRequest = onDismiss, properties = DialogProperties( dismissOnBackPress = true, dismissOnClickOutside = true ) ) { - OutlinedCard( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) + ElevatedCard( + shape = RoundedCornerShape(24.dp), ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - ) { - Text( - text = "Request record", + Text( + text = "Request record", + modifier = Modifier.padding(top = 24.dp, start = 24.dp, end = 24.dp, bottom = 16.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge + ) + workingModeEntries.forEach { entry -> + Row( modifier = Modifier .fillMaxWidth() - .padding(8.dp), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleMedium - ) - HorizontalDivider() - LazyColumn( - state = listState - ) { - items(workingModeEntries.size) { index -> - val entry = workingModeEntries[index] - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(4.dp)) - .clickable { - onWorkingModeSelected( - CGMSEvent.OnWorkingModeSelected(entry) - ) - } - .padding(8.dp), - ) { - Text( - text = entry.toDisplayString(), - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - style = MaterialTheme.typography.titleLarge, - color = if ((cgmState.workingMode == entry) && cgmState.records.isNotEmpty()) { - MaterialTheme.colorScheme.primary - } else - MaterialTheme.colorScheme.onBackground + .padding(horizontal = 8.dp) + .clip(RoundedCornerShape(16.dp)) + .clickable { + onWorkingModeSelected( + CGMSEvent.OnWorkingModeSelected(entry) ) + onDismiss() } + .height(48.dp) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val color = when (cgmState.workingMode) { + entry -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onBackground } + Text( + text = entry.toString(), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.weight(1f), + color = color + ) } } + // So that bottom padding is 24.dp. + Spacer(modifier = Modifier.height(8.dp)) } } } @Composable private fun RecordsView(state: CGMServiceData) { - ScreenSection { - if (state.records.isEmpty()) { - RecordsViewWithoutData() - } else { - RecordsViewWithData(state) - } - + if (state.records.isEmpty()) { + RecordsViewWithoutData() + } else { + RecordsViewWithData(state) } } @Composable private fun RecordsViewWithoutData() { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - SectionTitle(icon = Icons.Default.Search, title = "No items") - + Column { + Text(text = stringResource(id = R.string.cgms_no_records_info)) Text( - text = stringResource(R.string.cgms_no_records_info), - style = MaterialTheme.typography.bodyMedium + text = stringResource(id = R.string.cgms_no_records_hint), + style = MaterialTheme.typography.bodySmall, + color = LocalContentColor.current.copy(alpha = 0.6f), ) } } -@Preview(showBackground = true) -@Composable -private fun RecordsViewWithoutDataPreview() { - RecordsViewWithoutData() -} - @Composable private fun RecordsViewWithData(state: CGMServiceData) { + val newRecord = when (state.workingMode) { + WorkingMode.ALL -> state.records + WorkingMode.LAST -> listOf(state.records.last()) + WorkingMode.FIRST -> listOf(state.records.first()) + null -> state.records + } + + // Max height for the scrollable section, adjust as needed (e.g. 300.dp) Column( - verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier - .fillMaxWidth() + .heightIn(max = 500.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - SectionTitle(resId = R.drawable.ic_records, title = "Records") - - val newRecord = when (state.workingMode) { - WorkingMode.ALL -> state.records - WorkingMode.LAST -> listOf(state.records.last()) - WorkingMode.FIRST -> listOf(state.records.first()) - null -> state.records - } + newRecord.forEachIndexed { i, it -> + RecordItem(it) - // Max height for the scrollable section, adjust as needed (e.g. 300.dp) - Box( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 500.dp) - .verticalScroll(rememberScrollState()) - ) { - Column { - newRecord.forEachIndexed { i, it -> - RecordItem(it) - if (i < newRecord.size - 1) { - HorizontalDivider() - } - } + if (i < newRecord.size - 1) { + HorizontalDivider() } } } } @Composable -private fun RecordItem(record: CGMRecordWithSequenceNumber) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .clip(RoundedCornerShape(10.dp)) - .padding(8.dp) - ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .fillMaxWidth() - .weight(1f) - ) { - SectionRow { - KeyValueColumn( - value = stringResource(id = R.string.cgms_sequence_number), - key = record.sequenceNumber.toString() - ) - KeyValueColumnReverse( - "Glucose concentration", - record.glucoseConcentration(), - keyStyle = MaterialTheme.typography.titleMedium - ) - } - SectionRow { - KeyValueColumn( - value = "Date & Time", - key = record.formattedTime() - ) - } - } +private fun ColumnScope.RecordItem(record: CGMRecordWithSequenceNumber) { + SectionRow { + KeyValueColumn( + key = stringResource(id = R.string.cgms_sequence_number), + value = record.sequenceNumber.toString() + ) + KeyValueColumnReverse( + key = "Glucose concentration", + value = record.glucoseConcentration(), + ) } + KeyValueColumn( + key = "Date & Time", + value = record.formattedTime() + ) } -@Preview(showBackground = true) +@Preview @Composable -private fun RecordsViewWithDataPreview() { - RecordsViewWithData( - state = CGMServiceData( +private fun CGMSViewPreview() { + CGMSView( + serviceData = CGMServiceData( records = listOf( CGMRecordWithSequenceNumber( sequenceNumber = 12, @@ -342,9 +260,20 @@ private fun RecordsViewWithDataPreview() { crcPresent = true ), timestamp = Calendar.TUESDAY.toLong() - ) - ) - ) + ), + ), + ), + onClickEvent = {} + ) +} +@Preview +@Composable +private fun CGMSViewPreview_empty() { + CGMSView( + serviceData = CGMServiceData( + records = emptyList(), + ), + onClickEvent = {} ) } diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/channelSounding/ChannelSoundingScreen.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/channelSounding/ChannelSoundingScreen.kt index 349cda82d..4f0a7e209 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/channelSounding/ChannelSoundingScreen.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/channelSounding/ChannelSoundingScreen.kt @@ -36,9 +36,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import no.nordicsemi.android.common.theme.NordicTheme +import no.nordicsemi.android.common.ui.view.SectionTitle import no.nordicsemi.android.toolbox.profile.R import no.nordicsemi.android.toolbox.profile.data.ChannelSoundingServiceData import no.nordicsemi.android.toolbox.profile.data.ConfidenceLevel @@ -52,7 +53,6 @@ import no.nordicsemi.android.toolbox.profile.viewmodel.ChannelSoundingEvent import no.nordicsemi.android.toolbox.profile.viewmodel.ChannelSoundingViewModel import no.nordicsemi.android.ui.view.AnimatedThreeDots import no.nordicsemi.android.ui.view.ScreenSection -import no.nordicsemi.android.ui.view.SectionTitle import no.nordicsemi.android.ui.view.TextWithAnimatedDots import no.nordicsemi.android.ui.view.internal.LoadingView diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/cscs/CSCScreen.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/cscs/CSCScreen.kt index e68bfd881..121c675b9 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/cscs/CSCScreen.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/cscs/CSCScreen.kt @@ -1,31 +1,29 @@ package no.nordicsemi.android.toolbox.profile.view.cscs import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -33,21 +31,21 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import no.nordicsemi.android.common.ui.view.SectionTitle +import no.nordicsemi.android.toolbox.profile.R +import no.nordicsemi.android.toolbox.profile.data.CSCServiceData +import no.nordicsemi.android.toolbox.profile.parser.csc.CSCData import no.nordicsemi.android.toolbox.profile.parser.csc.SpeedUnit import no.nordicsemi.android.toolbox.profile.parser.csc.WheelSizes import no.nordicsemi.android.toolbox.profile.parser.csc.WheelSizes.getWheelSizeByName -import no.nordicsemi.android.toolbox.profile.R -import no.nordicsemi.android.toolbox.profile.data.CSCServiceData import no.nordicsemi.android.toolbox.profile.viewmodel.CSCEvent import no.nordicsemi.android.toolbox.profile.viewmodel.CSCViewModel import no.nordicsemi.android.ui.view.KeyValueColumn @@ -61,26 +59,34 @@ internal fun CSCScreen() { val onClickEvent: (CSCEvent) -> Unit = { csVM.onEvent(it) } val serviceData by csVM.cscState.collectAsStateWithLifecycle() - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxWidth() - ) { - ScreenSection { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "\uD83D\uDEB4" + " Cycling", - textAlign = TextAlign.Center, - fontSize = 24.sp, - fontWeight = FontWeight.Bold + CSCView( + serviceData = serviceData, + onClickEvent = onClickEvent + ) +} + +@Composable +private fun CSCView( + serviceData: CSCServiceData, + onClickEvent: (CSCEvent) -> Unit +) { + ScreenSection { + SectionTitle( + painter = painterResource(R.drawable.ic_csc), + title = stringResource(R.string.csc_cycling), + menu = { + WheelSizeDropDown( + state = serviceData, + onClickEvent = onClickEvent, + ) + CSCSettingView( + serviceData = serviceData, + onClickEvent = onClickEvent, ) - Spacer(modifier = Modifier.weight(1f)) - CSCSettingView(serviceData, onClickEvent) } - SensorsReadingView(state = serviceData, serviceData.speedUnit) - } + ) + + SensorsReadingView(state = serviceData, serviceData.speedUnit) } } @@ -89,239 +95,236 @@ private fun CSCSettingView( serviceData: CSCServiceData, onClickEvent: (CSCEvent) -> Unit ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - var isWheelSizeClicked by rememberSaveable { mutableStateOf(false) } - var isDropdownExpanded by rememberSaveable { mutableStateOf(false) } + var showDialog by rememberSaveable { mutableStateOf(false) } - WheelSizeDropDown( - state = serviceData, - isWheelSizeClicked = isWheelSizeClicked, - onExpand = { isWheelSizeClicked = true }, - onDismiss = { isWheelSizeClicked = false }, - onClickEvent = { onClickEvent(it) } - ) + IconButton( + onClick = { showDialog = true } + ) { Icon( imageVector = Icons.Default.Settings, contentDescription = "Speed unit settings", - modifier = Modifier - .clip(CircleShape) - .size(28.dp) - .clickable { isDropdownExpanded = true } ) + } - if (isDropdownExpanded) - CSCSpeedSettingsFilterDropdown( - serviceData, - onDismiss = { isDropdownExpanded = false }, - onClickEvent = { onClickEvent(it) } - ) + if (showDialog) { + CSCSpeedSettingsFilterDropdown( + state = serviceData, + onDismiss = { showDialog = false }, + onClickEvent = onClickEvent, + ) } } @Composable private fun WheelSizeDropDown( state: CSCServiceData, - isWheelSizeClicked: Boolean, - onExpand: () -> Unit, - onDismiss: () -> Unit, onClickEvent: (CSCEvent) -> Unit ) { - val wheelEntries = WheelSizes.data.map { it.name } - Column { - OutlinedButton(onClick = { onExpand() }) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = stringResource(id = R.string.csc_field_wheel_size), - ) - Icon(Icons.Default.ArrowDropDown, contentDescription = "") - } - } - if (isWheelSizeClicked) - WheelSizeDialog( - state = state, - wheelSizeEntries = wheelEntries, - onDismiss = onDismiss, - ) { - onClickEvent(CSCEvent.OnWheelSizeSelected(getWheelSizeByName(it))) - onDismiss() - } + var expanded by rememberSaveable { mutableStateOf(false) } + + OutlinedButton( + onClick = { expanded = true }, + contentPadding = PaddingValues(top = 8.dp, bottom = 8.dp, start = 24.dp, end = 12.dp), + ) { + Text(text = state.data.wheelSize.name) + Icon( + modifier = Modifier.padding(start = 8.dp), + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null + ) + } + if (expanded) { + WheelSizeDialog( + state = state, + onDismiss = { expanded = false }, + onWheelSizeSelected = { size -> + onClickEvent(CSCEvent.OnWheelSizeSelected(getWheelSizeByName(size))) + }, + ) } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun WheelSizeDialog( state: CSCServiceData, - wheelSizeEntries: List, onDismiss: () -> Unit, onWheelSizeSelected: (String) -> Unit, ) { val listState = rememberLazyListState() - val selectedIndex = wheelSizeEntries.indexOf(state.data.wheelSize.name) - - LaunchedEffect(selectedIndex) { - if (selectedIndex >= 0) { - listState.scrollToItem(selectedIndex) - } - } + val wheelSizeEntries = WheelSizes.data - AlertDialog( - onDismissRequest = { onDismiss() }, - title = { Text(text = stringResource(id = R.string.csc_dialog_title)) }, - text = { + BasicAlertDialog( + onDismissRequest = onDismiss, + properties = DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true + ) + ) { + ElevatedCard( + shape = RoundedCornerShape(24.dp), + ) { + Text( + text = stringResource(R.string.csc_dialog_title), + modifier = Modifier.padding(top = 24.dp, start = 24.dp, end = 24.dp, bottom = 16.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge + ) LazyColumn( - state = listState + modifier = Modifier.heightIn(max = 300.dp), + contentPadding = PaddingValues(bottom = 16.dp), // 24 in total + state = listState, ) { items(wheelSizeEntries.size) { index -> val entry = wheelSizeEntries[index] Row( modifier = Modifier - .clip(RoundedCornerShape(10.dp)) + .fillMaxWidth() + .padding(horizontal = 8.dp) + .clip(RoundedCornerShape(16.dp)) .clickable { - onWheelSizeSelected(entry) + onWheelSizeSelected(entry.name) + onDismiss() } - .padding(8.dp), - verticalAlignment = Alignment.CenterVertically + .height(48.dp) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, ) { + val color = when (state.data.wheelSize) { + entry -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onBackground + } + Text( + text = entry.description, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.weight(1f), + color = color + ) Text( - text = entry, - modifier = Modifier.fillMaxWidth(), - style = MaterialTheme.typography.titleLarge, - color = if (state.data.wheelSize.name == entry) - MaterialTheme.colorScheme.primary else - MaterialTheme.colorScheme.onBackground + text = entry.name, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } - }, - confirmButton = { - TextButton(onClick = { onDismiss() }) { - Text( - text = stringResource(id = no.nordicsemi.android.ui.R.string.cancel), - ) - } } - ) + } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun CSCSpeedSettingsFilterDropdown( state: CSCServiceData, onDismiss: () -> Unit, onClickEvent: (CSCEvent) -> Unit ) { - Dialog( - onDismissRequest = { onDismiss() }, + BasicAlertDialog( + onDismissRequest = onDismiss, properties = DialogProperties( dismissOnBackPress = true, dismissOnClickOutside = true ) ) { - OutlinedCard( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) + ElevatedCard( + shape = RoundedCornerShape(24.dp), ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - ) { - Text( - text = stringResource(R.string.csc_settings), + Text( + text = stringResource(R.string.csc_settings), + modifier = Modifier.padding(top = 24.dp, start = 24.dp, end = 24.dp, bottom = 16.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge + ) + SpeedUnit.entries.forEach { entry -> + Row( modifier = Modifier .fillMaxWidth() - .padding(8.dp), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleMedium - ) - HorizontalDivider() - Column( - modifier = Modifier - .padding(8.dp), + .padding(horizontal = 8.dp) + .clip(RoundedCornerShape(16.dp)) + .clickable { + onClickEvent(CSCEvent.OnSelectedSpeedUnitSelected(entry)) + onDismiss() + } + .height(48.dp) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, ) { - SpeedUnit.entries.forEach { entry -> - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(4.dp)) - .clickable { - onClickEvent(CSCEvent.OnSelectedSpeedUnitSelected(entry)) - onDismiss() - }, - ) { - Text( - text = entry.displayName, - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - style = MaterialTheme.typography.titleLarge, - color = if (state.speedUnit == entry) - MaterialTheme.colorScheme.primary else - MaterialTheme.colorScheme.onBackground - ) + Text( + text = entry.displayName, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.weight(1f), + color = when (state.speedUnit) { + entry -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onBackground } - } + ) + Text( + text = entry.unit, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) } } + // So that bottom padding is 24.dp. + Spacer(modifier = Modifier.height(8.dp)) } } } -@Preview(showBackground = true) @Composable -private fun CSCSpeedSettingsFilterDropdownPreview() { - CSCSpeedSettingsFilterDropdown( - state = CSCServiceData(), - onDismiss = {}, - onClickEvent = {} +private fun ColumnScope.SensorsReadingView(state: CSCServiceData, speedUnit: SpeedUnit) { + val csc = state.data + + SectionRow { + KeyValueColumn( + key = stringResource(id = R.string.csc_field_speed), + value = csc.displaySpeed(speedUnit) + ) + KeyValueColumnReverse( + key = stringResource(id = R.string.csc_field_cadence), + value = csc.displayCadence() + ) + } + SectionRow { + KeyValueColumn( + key = stringResource(id = R.string.csc_field_distance), + value = csc.displayDistance(speedUnit) + ) + KeyValueColumnReverse( + key = stringResource(id = R.string.csc_field_total_distance), + value = csc.displayTotalDistance(speedUnit) + ) + } + KeyValueColumn( + key = stringResource(id = R.string.csc_field_gear_ratio), + value = csc.displayGearRatio() ) } +@Preview(showBackground = true) @Composable -private fun SensorsReadingView(state: CSCServiceData, speedUnit: SpeedUnit) { - val csc = state.data - - Column( - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - SectionRow { - KeyValueColumn( - stringResource(id = R.string.csc_field_speed), - csc.displaySpeed(speedUnit) - ) - KeyValueColumnReverse( - stringResource(id = R.string.csc_field_cadence), - csc.displayCadence() - ) - } - SectionRow { - KeyValueColumn( - stringResource(id = R.string.csc_field_distance), - csc.displayDistance(speedUnit) - ) - KeyValueColumnReverse( - stringResource(id = R.string.csc_field_total_distance), - csc.displayTotalDistance(speedUnit) - ) - } - Row { - KeyValueColumn( - stringResource(id = R.string.csc_field_gear_ratio), - csc.displayGearRatio() +private fun SensorsReadingViewPreview() { + CSCView( + serviceData = CSCServiceData( + data = CSCData( + speed = 3.1f, + cadence = 123f, + distance = 1234f, + totalDistance = 12345f, + gearRatio = 12.3f, + wheelSize = WheelSizes.data.first() ) - } - } + ), + onClickEvent = { } + ) } @Preview(showBackground = true) @Composable -private fun SensorsReadingViewPreview() { - SensorsReadingView(CSCServiceData(), SpeedUnit.KM_H) +private fun CSCSpeedSettingsFilterDropdownPreview() { + CSCSpeedSettingsFilterDropdown( + state = CSCServiceData(), + onDismiss = {}, + onClickEvent = {} + ) } diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/dfu/DFUScreen.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/dfu/DFUScreen.kt index 0aa38d080..6f3f41a75 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/dfu/DFUScreen.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/dfu/DFUScreen.kt @@ -1,10 +1,13 @@ package no.nordicsemi.android.toolbox.profile.view.dfu +import android.content.Intent import androidx.compose.foundation.layout.Arrangement 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.material3.Button @@ -22,19 +25,21 @@ import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import no.nordicsemi.android.toolbox.profile.R +import no.nordicsemi.android.toolbox.profile.data.DFUsAvailable import no.nordicsemi.android.toolbox.profile.viewmodel.ConnectionEvent import no.nordicsemi.android.toolbox.profile.viewmodel.DFUViewModel +import no.nordicsemi.android.ui.view.ScreenSection @Composable internal fun DFUScreen(onRedirection: (ConnectionEvent.DisconnectEvent) -> Unit) { val dfuViewModel = hiltViewModel() val dfuServiceState by dfuViewModel.dfuServiceState.collectAsStateWithLifecycle() val context = LocalContext.current - val uriHandler = LocalUriHandler.current dfuServiceState.dfuAppName?.let { dfuApp -> val intent = context.packageManager.getLaunchIntentForPackage(dfuApp.packageName) @@ -44,73 +49,109 @@ internal fun DFUScreen(onRedirection: (ConnectionEvent.DisconnectEvent) -> Unit) R.string.dfu_description_open, stringResource(dfuApp.appName) ) - } - ?: stringResource(R.string.dfu_description_download) + } ?: stringResource(R.string.dfu_description_download) + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + DFUInstructionsCard(dfuApp) + + Spacer(modifier = Modifier.height(16.dp)) + + DFUActionButton(dfuApp, intent, description, onRedirection) + } + } +} +@Composable +private fun DFUInstructionsCard( + dfuApp: DFUsAvailable, +) { + OutlinedCard { Column( modifier = Modifier - .fillMaxSize(), + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) ) { - OutlinedCard { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Icon( - painter = painterResource(dfuApp.appIcon), - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(56.dp) - ) - - Text( - text = stringResource( - R.string.dfu_not_supported_title, - stringResource(dfuApp.appShortName) - ), - style = MaterialTheme.typography.titleMedium - ) - - Text( - text = stringResource( - R.string.dfu_not_supported_text, - stringResource(dfuApp.appShortName), - stringResource(dfuApp.appName) - ), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium - ) - } - } - - Button( - onClick = { - intent?.let { context.startActivity(it) } - ?: uriHandler.openUri(dfuApp.appLink) - // Also disconnect from the current device. - onRedirection(ConnectionEvent.DisconnectEvent) - } - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - val icon = intent?.let { dfuApp.appIcon } ?: R.drawable.google_play_2022_icon - - Icon( - painter = painterResource(icon), - contentDescription = null, - modifier = Modifier - .size(40.dp) - .padding(end = 8.dp), - tint = if (intent == null) Color.Unspecified else MaterialTheme.colorScheme.onPrimary - ) - - Text(text = description) - } - } + Icon( + painter = painterResource(dfuApp.appIcon), + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(56.dp) + ) + + Text( + text = stringResource( + R.string.dfu_not_supported_title, + stringResource(dfuApp.appShortName) + ), + style = MaterialTheme.typography.titleMedium + ) + + Text( + text = stringResource( + R.string.dfu_not_supported_text, + stringResource(dfuApp.appShortName), + stringResource(dfuApp.appName) + ), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium + ) + } + } +} + +@Composable +private fun DFUActionButton( + dfuApp: DFUsAvailable, + intent: Intent?, + title: String, + onRedirection: (ConnectionEvent.DisconnectEvent) -> Unit +) { + val context = LocalContext.current + val uriHandler = LocalUriHandler.current + + Button( + onClick = { + intent?.let { context.startActivity(it) } + ?: uriHandler.openUri(dfuApp.appLink) + // Also disconnect from the current device. + onRedirection(ConnectionEvent.DisconnectEvent) + } + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + val icon = intent?.let { dfuApp.appIcon } ?: R.drawable.google_play_2022_icon + + Icon( + painter = painterResource(icon), + contentDescription = null, + modifier = Modifier + .size(40.dp) + .padding(end = 8.dp), + tint = if (intent == null) Color.Unspecified else MaterialTheme.colorScheme.onPrimary + ) + + Text(text = title) } } } + +@Preview +@Composable +private fun DFUInstructionsCardPreview() { + DFUInstructionsCard(DFUsAvailable.DFU_SERVICE) +} + +@Preview +@Composable +private fun DFUActionButtonPreview() { + DFUActionButton( + dfuApp = DFUsAvailable.DFU_SERVICE, + intent = null, + title = "Download", + onRedirection = {}, + ) +} + diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/AzimuthAndElevationSection.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/AzimuthAndElevationSection.kt index 5576f0feb..8f1d78768 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/AzimuthAndElevationSection.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/AzimuthAndElevationSection.kt @@ -18,65 +18,52 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import no.nordicsemi.android.common.ui.view.SectionTitle import no.nordicsemi.android.toolbox.profile.parser.directionFinder.PeripheralBluetoothAddress import no.nordicsemi.android.toolbox.profile.parser.directionFinder.azimuthal.AzimuthMeasurementData import no.nordicsemi.android.toolbox.profile.parser.directionFinder.elevation.ElevationMeasurementData import no.nordicsemi.android.toolbox.profile.R import no.nordicsemi.android.toolbox.profile.data.SensorData import no.nordicsemi.android.toolbox.profile.data.SensorValue -import no.nordicsemi.android.toolbox.profile.data.directionFinder.Range import no.nordicsemi.android.toolbox.profile.data.directionFinder.displayAzimuth import no.nordicsemi.android.toolbox.profile.data.directionFinder.elevationValue +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.QualityIndicator import no.nordicsemi.android.ui.view.ScreenSection -import no.nordicsemi.android.ui.view.SectionTitle +import no.nordicsemi.android.ui.view.SubsectionTitle @Composable -internal fun AzimuthAndElevationSection(data: SensorData, range: Range) { +internal fun AzimuthAndElevationSection(data: SensorData, range: IntRange) { ScreenSection { - SectionTitle( - R.drawable.ic_azimuth, - stringResource(id = R.string.azimuth_section) - ) - - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { + data.displayAzimuth()?.let { + SectionTitle( + painter = painterResource(id = R.drawable.ic_azimuth), + title = stringResource(id = R.string.azimuth_section) + ) Column( - verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Box { - Image( - painter = painterResource(id = R.drawable.ic_azimuth), - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.surface), - contentDescription = null, - modifier = Modifier - .background( - color = MaterialTheme.colorScheme.secondaryContainer, - shape = CircleShape - ) - .size(200.dp) - ) - AzimuthView(data, range) - } - data.displayAzimuth()?.let { - Text( - text = "Direction relative to North: $it", - style = MaterialTheme.typography.titleLarge, - ) - } + AzimuthView(data, range) + Text( + text = it, + style = MaterialTheme.typography.titleLarge, + ) } + } + data.elevationValue()?.let { SectionTitle( - R.drawable.ic_elevation, - stringResource(id = R.string.elevation_section) + painter = painterResource(id = R.drawable.ic_elevation), + title = stringResource(id = R.string.elevation_section) ) - Box { - ElevationView(value = data.elevationValue()!!, data) - } + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ElevationView(value = it) + } } } } @@ -88,19 +75,21 @@ private fun AzimuthAndElevationSectionPreview() { azimuth = SensorValue( values = listOf( AzimuthMeasurementData( + quality = QualityIndicator.POOR, + address = PeripheralBluetoothAddress.TEST, azimuth = 50, - address = PeripheralBluetoothAddress.TEST ) ) ), elevation = SensorValue( values = listOf( ElevationMeasurementData( + quality = QualityIndicator.GOOD, address = PeripheralBluetoothAddress.TEST, elevation = 30 ) ) ) ) - AzimuthAndElevationSection(sensorData, Range(0, 50)) + AzimuthAndElevationSection(sensorData, 0..50) } \ No newline at end of file diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/AzimuthSection.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/AzimuthSection.kt deleted file mode 100644 index e00957f64..000000000 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/AzimuthSection.kt +++ /dev/null @@ -1,84 +0,0 @@ -package no.nordicsemi.android.toolbox.profile.view.directionFinder - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import no.nordicsemi.android.toolbox.profile.parser.directionFinder.PeripheralBluetoothAddress -import no.nordicsemi.android.toolbox.profile.parser.directionFinder.azimuthal.AzimuthMeasurementData -import no.nordicsemi.android.toolbox.profile.R -import no.nordicsemi.android.toolbox.profile.data.SensorData -import no.nordicsemi.android.toolbox.profile.data.SensorValue -import no.nordicsemi.android.toolbox.profile.data.directionFinder.Range -import no.nordicsemi.android.toolbox.profile.data.directionFinder.displayAzimuth -import no.nordicsemi.android.ui.view.ScreenSection -import no.nordicsemi.android.ui.view.SectionTitle - -@Composable -internal fun AzimuthSection(data: SensorData, distanceRange: Range) { - ScreenSection { - SectionTitle( - resId = R.drawable.ic_azimuth, stringResource(id = R.string.azimuth_section) - ) - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - Image( - painter = painterResource(id = R.drawable.ic_azimuth), - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.surface), - contentDescription = null, - modifier = Modifier - .background( - color = MaterialTheme.colorScheme.secondaryContainer, - shape = CircleShape - ) - .height(200.dp) - .width(200.dp) - ) - AzimuthView(data, distanceRange) - } - - data.displayAzimuth()?.let { - Box( - modifier = Modifier - .fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - Text( - text = it, - style = MaterialTheme.typography.titleLarge, - ) - } - } - } -} - -@Preview -@Composable -private fun AzimuthSectionPreview() { - val sensorData = SensorData( - azimuth = SensorValue( - values = listOf( - AzimuthMeasurementData( - azimuth = 20, - address = PeripheralBluetoothAddress.TEST - ) - ) - ) - ) - AzimuthSection(sensorData, Range(0, 50)) -} diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/AzimuthView.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/AzimuthView.kt index 38a5856bb..dcd933d7c 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/AzimuthView.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/AzimuthView.kt @@ -9,10 +9,12 @@ import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -41,7 +43,6 @@ import no.nordicsemi.android.toolbox.profile.parser.directionFinder.azimuthal.Az import no.nordicsemi.android.toolbox.profile.R import no.nordicsemi.android.toolbox.profile.data.SensorData import no.nordicsemi.android.toolbox.profile.data.SensorValue -import no.nordicsemi.android.toolbox.profile.data.directionFinder.Range import no.nordicsemi.android.toolbox.profile.data.directionFinder.azimuthValue import no.nordicsemi.android.toolbox.profile.data.directionFinder.distanceValue import no.nordicsemi.android.ui.view.CircleTransitionState @@ -50,7 +51,7 @@ import no.nordicsemi.android.ui.view.createCircleTransition @Composable internal fun AzimuthView( sensorData: SensorData, - range: Range + range: IntRange ) { val azimuthValue = sensorData.azimuthValue() ?: return val distance = sensorData.distanceValue() @@ -76,10 +77,22 @@ internal fun AzimuthView( } Box { + Image( + painter = painterResource(id = R.drawable.ic_azimuth), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), + contentDescription = null, + modifier = Modifier + .background( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f), + shape = CircleShape + ) + .size(200.dp) + ) + // Render the main canvas RenderAzimuthCanvas( radius = radius, - circleBorderColor = MaterialTheme.colorScheme.secondary, + circleBorderColor = MaterialTheme.colorScheme.surfaceVariant, transition = transition, distance = distance, isClose = isClose(sensorData, range), @@ -105,7 +118,7 @@ private fun RenderAzimuthCanvas( transition: CircleTransitionState, distance: Int?, isClose: Boolean, - range: Range, + range: IntRange, duration: Int, ) { val infiniteTransition = rememberInfiniteTransition(label = "InfiniteTransition") @@ -219,12 +232,12 @@ private fun RenderArrow(azimuthValue: Int, dotColor: Color, modifier: Modifier) ) } -private fun calculateProgressWidth(range: Range, distance: Int?): Float { +private fun calculateProgressWidth(range: IntRange, distance: Int?): Float { return when { distance == null -> 0f - distance <= range.from -> 0f - distance >= range.to -> 1f - else -> (distance.toFloat() - range.from) / (range.to - range.from) + distance <= range.first -> 0f + distance >= range.last -> 1f + else -> (distance.toFloat() - range.first) / (range.last - range.first) } } @@ -244,9 +257,9 @@ private fun InfiniteTransition.createInfiniteFloatAnimation( ).value } -private fun isClose(sensorData: SensorData, range: Range): Boolean { - val validatedValue = sensorData.distanceValue()?.coerceIn(range.from, range.to) ?: 0 - return validatedValue <= range.from || (validatedValue - range.from) < 10 +private fun isClose(sensorData: SensorData, range: IntRange): Boolean { + val validatedValue = sensorData.distanceValue()?.coerceIn(range) ?: 0 + return validatedValue <= range.first || (validatedValue - range.first) < 10 } @Preview(showBackground = true) @@ -256,11 +269,11 @@ private fun AzimuthViewPreview() { azimuth = SensorValue( values = listOf( AzimuthMeasurementData( - azimuth = 20, + azimuth = 7, address = PeripheralBluetoothAddress.TEST ) ) ) ) - AzimuthView(sensorData, Range(0, 50)) + AzimuthView(sensorData, 0..50) } \ No newline at end of file diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/ControlView.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/ControlView.kt deleted file mode 100644 index 457f2201d..000000000 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/ControlView.kt +++ /dev/null @@ -1,171 +0,0 @@ -package no.nordicsemi.android.toolbox.profile.view.directionFinder - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance.DistanceMode -import no.nordicsemi.android.toolbox.profile.R -import no.nordicsemi.android.toolbox.profile.data.DFSServiceData -import no.nordicsemi.android.toolbox.profile.data.SensorData -import no.nordicsemi.android.toolbox.profile.data.uart.MacroEol -import no.nordicsemi.android.toolbox.profile.viewmodel.DFSEvent - -@Composable -internal fun ControlView( - viewEntity: DFSServiceData, - sensorData: SensorData, - onEvent: (DFSEvent) -> Unit -) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - when { - !viewEntity.isDistanceAvailabilityChecked() -> { - DistanceCheckView { - onEvent(DFSEvent.OnAvailableDistanceModeRequest) - } - } - - !viewEntity.isDistanceAvailable() -> { - DistanceNotAvailableView() - } - - viewEntity.isDoubleModeAvailable() -> { - CurrentModeView( - distanceMode = sensorData.distanceMode, - onCheckMode = { onEvent(DFSEvent.OnCheckDistanceModeRequest) }, - onSwitchMode = { newMode -> onEvent(DFSEvent.OnDistanceModeSelected(newMode)) } - ) - } - - viewEntity.ddfFeature?.isMcpdAvailable == true -> { - SingleModeAvailableView( - isMcpdAvailable = true, - ) - } - - viewEntity.ddfFeature?.isRttAvailable == true -> { - SingleModeAvailableView( - isRttAvailable = true, - ) - } - } - } -} - -@Composable -private fun DistanceCheckView(onCheckAvailability: () -> Unit) { - Text(stringResource(id = R.string.check_distance_mode)) - Button(onClick = onCheckAvailability) { - Text(stringResource(id = R.string.check_availability)) - } -} - -@Preview(showBackground = true) -@Composable -private fun DistanceCheckViewPreview() { - DistanceCheckView {} -} - -@Composable -private fun DistanceNotAvailableView() { - Text(stringResource(id = R.string.distance_not_available)) -} - -@Composable -private fun CurrentModeView( - distanceMode: DistanceMode?, - onCheckMode: () -> Unit, - onSwitchMode: (DistanceMode) -> Unit -) { - if (distanceMode == null) { - Button(onClick = onCheckMode) { - Text(stringResource(id = R.string.check_mode)) - } - } else { - Box( - modifier = Modifier.clip(RoundedCornerShape(8.dp)), - ) { - Row( - modifier = Modifier.fillMaxWidth() - ) { - DistanceMode.entries.forEachIndexed { index, it -> - val selected = it == distanceMode - val clip = if (selected) RoundedCornerShape(8.dp) else RoundedCornerShape(0.dp) - val (color, textColor) = if (selected) { - MaterialTheme.colorScheme.primary to MaterialTheme.colorScheme.onPrimary - } else { - MaterialTheme.colorScheme.surfaceContainer to MaterialTheme.colorScheme.onSurface - } - - Box( - modifier = Modifier - .weight(1f) - .clip(clip) - .background(color = color) - .clickable { onSwitchMode(it) }, - contentAlignment = Alignment.Center, - ) { - Text( - it.toString(), - modifier = Modifier.padding(8.dp), - color = textColor, - ) - } - if ((index < MacroEol.entries.size - 1) && !selected) - VerticalDivider( - modifier = Modifier - .height(IntrinsicSize.Max) - .background(MaterialTheme.colorScheme.onSurface) - ) - } - } - } - } -} - -@Composable -private fun SingleModeAvailableView( - isMcpdAvailable: Boolean? = null, - isRttAvailable: Boolean? = null -) { - val messageId = when { - isMcpdAvailable == true -> R.string.only_mcpd_available - isRttAvailable == true -> R.string.only_rtt_available - else -> null - } - messageId?.let { Text(stringResource(id = it)) } -} - -@Preview(showBackground = true) -@Composable -private fun SingleModeAvailableViewPreview() { - SingleModeAvailableView(false, true) -} - - -@Preview(showBackground = true) -@Composable -private fun ControlViewPreview() { - CurrentModeView(DistanceMode.MCPD, {}) { } -} diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/DFSScreen.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/DFSScreen.kt index cb7f99023..d3eb2862d 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/DFSScreen.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/DFSScreen.kt @@ -2,22 +2,42 @@ package no.nordicsemi.android.toolbox.profile.view.directionFinder import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MyLocation import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import no.nordicsemi.android.toolbox.profile.parser.gls.data.RequestStatus -import no.nordicsemi.android.toolbox.profile.data.directionFinder.azimuthValue +import no.nordicsemi.android.common.ui.view.SectionTitle +import no.nordicsemi.android.toolbox.profile.R +import no.nordicsemi.android.toolbox.profile.data.DFSServiceData +import no.nordicsemi.android.toolbox.profile.data.SensorData +import no.nordicsemi.android.toolbox.profile.data.SensorValue import no.nordicsemi.android.toolbox.profile.data.directionFinder.distanceValue -import no.nordicsemi.android.toolbox.profile.data.directionFinder.elevationValue -import no.nordicsemi.android.toolbox.profile.data.directionFinder.isDistanceSettingsAvailable +import no.nordicsemi.android.toolbox.profile.data.directionFinder.isAzimuthAndElevationDataAvailable +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.PeripheralBluetoothAddress +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.QualityIndicator +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.azimuthal.AzimuthMeasurementData +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance.MCPDEstimate +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance.McpdMeasurementData +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance.RTTEstimate +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance.RttMeasurementData +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.elevation.ElevationMeasurementData +import no.nordicsemi.android.toolbox.profile.parser.gls.data.RequestStatus import no.nordicsemi.android.toolbox.profile.viewmodel.DFSEvent import no.nordicsemi.android.toolbox.profile.viewmodel.DirectionFinderViewModel +import no.nordicsemi.android.ui.view.ScreenSection @Composable internal fun DFSScreen() { @@ -25,53 +45,108 @@ internal fun DFSScreen() { val onClick: (DFSEvent) -> Unit = { dfsVM.onEvent(it) } val serviceData by dfsVM.dfsState.collectAsStateWithLifecycle() + DFSView(serviceData, onClick) +} + +@Composable +private fun DFSView( + serviceData: DFSServiceData, + onClick: (DFSEvent) -> Unit, +) { Column( verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth() ) { - when (serviceData.requestStatus) { - RequestStatus.PENDING -> CircularProgressIndicator() - RequestStatus.SUCCESS -> { - SectionBluetoothDeviceComponent( - serviceData, - selectedDevice = serviceData.selectedDevice, - ) { onClick(it) } + SectionBluetoothDeviceComponent( + data = serviceData, + selectedDevice = serviceData.selectedDevice, + onEvent = onClick, + ) - if (serviceData.selectedDevice != null) { - val data = serviceData.data[serviceData.selectedDevice] - val isAzimuthAndElevationDataAvailable = - (data?.azimuthValue() != null) && (data.elevationValue() != null) + val data = serviceData.data[serviceData.selectedDevice] + data?.distanceValue()?.let { + DistanceSection(data, serviceData.distanceRange, onClick) + } - if (data != null) { - data.distanceValue()?.let { - DistanceSection(it, serviceData.distanceRange, onClick) - } - when { - isAzimuthAndElevationDataAvailable -> AzimuthAndElevationSection( - data, - serviceData.distanceRange - ) + val isAzimuthAndElevationDataAvailable = data?.isAzimuthAndElevationDataAvailable() ?: false + if (isAzimuthAndElevationDataAvailable) { + AzimuthAndElevationSection(data, serviceData.distanceRange) + } + } +} - !isAzimuthAndElevationDataAvailable && (data.azimuth != null) -> AzimuthSection( - data, - serviceData.distanceRange - ) +@Preview +@Composable +private fun LoadingViewPreview() { + DFSView( + serviceData = DFSServiceData(), + onClick = {} + ) +} - !isAzimuthAndElevationDataAvailable && data.elevation != null -> ElevationSection( - data - ) - } - if (data.isDistanceSettingsAvailable()) { - MeasurementDetailsView(serviceData, data) - } - } - } - } +@Preview +@Composable +private fun ScanningPreview() { + DFSView( + serviceData = DFSServiceData( + requestStatus = RequestStatus.SUCCESS + ), + onClick = {} + ) +} - else -> { - CircularProgressIndicator() - } - } - } +@Preview(heightDp = 1600) +@Composable +private fun DFSPreview() { + DFSView( + serviceData = DFSServiceData( + requestStatus = RequestStatus.SUCCESS, + selectedDevice = PeripheralBluetoothAddress.TEST, + data = mapOf( + PeripheralBluetoothAddress.TEST to SensorData( + azimuth = SensorValue( + values = listOf( + AzimuthMeasurementData( + quality = QualityIndicator.POOR, + address = PeripheralBluetoothAddress.TEST, + azimuth = 50, + ), + ) + ), + elevation = SensorValue( + values = listOf( + ElevationMeasurementData( + quality = QualityIndicator.GOOD, + address = PeripheralBluetoothAddress.TEST, + elevation = 30, + ) + ) + ), + rttDistance = SensorValue( + values = listOf( + RttMeasurementData( + quality = QualityIndicator.POOR, + address = PeripheralBluetoothAddress.TEST, + rtt = RTTEstimate(10), + ) + ) + ), + mcpdDistance = SensorValue( + values = listOf( + McpdMeasurementData( + quality = QualityIndicator.POOR, + address = PeripheralBluetoothAddress.TEST, + mcpd = MCPDEstimate( + ifft = 14, + phaseSlope = 15, + rssi = 16, + best = 17 + ) + ) + ) + ) + ) + ) + ), + onClick = {} + ) } \ No newline at end of file diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/DistanceSection.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/DistanceSection.kt index 0aeeb73ad..cccd61220 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/DistanceSection.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/DistanceSection.kt @@ -1,51 +1,101 @@ package no.nordicsemi.android.toolbox.profile.view.directionFinder -import androidx.compose.foundation.layout.Box +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import no.nordicsemi.android.common.ui.view.SectionTitle import no.nordicsemi.android.toolbox.profile.R -import no.nordicsemi.android.toolbox.profile.data.directionFinder.Range +import no.nordicsemi.android.toolbox.profile.data.DFSServiceData +import no.nordicsemi.android.toolbox.profile.data.SensorData +import no.nordicsemi.android.toolbox.profile.data.SensorValue +import no.nordicsemi.android.toolbox.profile.data.directionFinder.distanceValue +import no.nordicsemi.android.toolbox.profile.data.directionFinder.isDistanceSettingsAvailable +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.PeripheralBluetoothAddress +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.QualityIndicator +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.azimuthal.AzimuthMeasurementData +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance.MCPDEstimate +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance.McpdMeasurementData +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance.RTTEstimate +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance.RttMeasurementData +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.elevation.ElevationMeasurementData import no.nordicsemi.android.toolbox.profile.viewmodel.DFSEvent import no.nordicsemi.android.ui.view.ScreenSection -import no.nordicsemi.android.ui.view.SectionTitle @Composable internal fun DistanceSection( - distanceValue: Int, - range: Range, + sensorData: SensorData, + range: IntRange, onClick: (DFSEvent) -> Unit, ) { ScreenSection { + var showDetails by rememberSaveable { mutableStateOf(false) } + SectionTitle( - R.drawable.ic_distance, - stringResource(id = R.string.distance_section) + painter = painterResource(R.drawable.ic_distance), + title = stringResource(id = R.string.distance_section), + menu = { + if (sensorData.isDistanceSettingsAvailable()) { + IconButton( + onClick = { showDetails = !showDetails } + ) { + Icon( + imageVector = if (showDetails) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + ) + } + } + } ) + + val distanceValue = sensorData.distanceValue()!! DistanceView(value = distanceValue, range = range) - Box( - modifier = Modifier - .fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - Text( - text = "$distanceValue dm", - style = MaterialTheme.typography.titleLarge, - ) - } - Column { - Text( - stringResource(R.string.distance_range), - style = MaterialTheme.typography.titleSmall - ) - RangeSlider(range) { - onClick(DFSEvent.OnRangeChangedEvent(it)) + Text( + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + text = stringResource(R.string.dm_value, distanceValue), + style = MaterialTheme.typography.titleLarge, + ) + +// Text( +// text = stringResource(id = R.string.distance_range), +// style = MaterialTheme.typography.titleSmall +// ) +// RangeSlider( +// range = range, +// onChange = { +// onClick(DFSEvent.OnRangeChangedEvent(it)) +// } +// ) + + AnimatedVisibility(showDetails) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + HorizontalDivider() + LinearDataView(data = sensorData, range = range) } } } @@ -54,5 +104,51 @@ internal fun DistanceSection( @Preview(showBackground = true) @Composable private fun DistanceSectionPreview() { - DistanceSection(15, Range(0, 50)) {} + DistanceSection( + sensorData = SensorData( + azimuth = SensorValue( + values = listOf( + AzimuthMeasurementData( + quality = QualityIndicator.POOR, + address = PeripheralBluetoothAddress.TEST, + azimuth = 50, + ), + ) + ), + elevation = SensorValue( + values = listOf( + ElevationMeasurementData( + quality = QualityIndicator.GOOD, + address = PeripheralBluetoothAddress.TEST, + elevation = 30, + ) + ) + ), + rttDistance = SensorValue( + values = listOf( + RttMeasurementData( + quality = QualityIndicator.POOR, + address = PeripheralBluetoothAddress.TEST, + rtt = RTTEstimate(10), + ) + ) + ), + mcpdDistance = SensorValue( + values = listOf( + McpdMeasurementData( + quality = QualityIndicator.POOR, + address = PeripheralBluetoothAddress.TEST, + mcpd = MCPDEstimate( + ifft = 10, + phaseSlope = 15, + rssi = 30, + best = 25, + ) + ) + ) + ) + ), + range = 0..50, + onClick = {}, + ) } \ No newline at end of file diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/DistanceView.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/DistanceView.kt index 39516d8ea..d29567de8 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/DistanceView.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/DistanceView.kt @@ -11,11 +11,10 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import no.nordicsemi.android.toolbox.profile.data.directionFinder.Range import java.util.Locale @Composable -internal fun DistanceView(value: Int, range: Range) { +internal fun DistanceView(value: Int, range: IntRange) { Column { DistanceChartView(value = value, range = range) @@ -25,22 +24,22 @@ internal fun DistanceView(value: Int, range: Range) { horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth() ) { - Text(text = String.format(Locale.US, "%d dm", range.from)) + Text(text = String.format(Locale.US, "%d dm", range.first)) - val diff = range.to - range.from + val diff = range.last - range.first val part = (diff / 4) if (part > 0) { - Text(text = String.format(Locale.US, "% ddm", range.from + part)) - Text(text = String.format(Locale.US, "%d dm", range.from + 2 * part)) + Text(text = String.format(Locale.US, "%d dm", range.first + part)) + Text(text = String.format(Locale.US, "%d dm", range.first + 2 * part)) } - Text(text = String.format(Locale.US, "%d dm", range.to)) + Text(text = String.format(Locale.US, "%d dm", range.last)) } } } -@Preview +@Preview(showBackground = true) @Composable private fun DistanceViewPreview() { - DistanceView(20, Range(0, 50)) + DistanceView(20, 0.. 50) } diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/DistanceViewChart.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/DistanceViewChart.kt index 94ad87b48..d0696555f 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/DistanceViewChart.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/DistanceViewChart.kt @@ -1,79 +1,28 @@ package no.nordicsemi.android.toolbox.profile.view.directionFinder -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.border -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Size import androidx.compose.ui.tooling.preview.Preview -import no.nordicsemi.android.toolbox.profile.data.directionFinder.Range -import no.nordicsemi.android.ui.view.createLinearTransition +import androidx.compose.ui.unit.dp +@OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun DistanceChartView(value: Int, range: Range) { - val duration = 1000 - val isInAccessibilityMode = rememberSaveable { mutableStateOf(false) } - val transition = createLinearTransition(isInAccessibilityMode.value, duration) - - BoxWithConstraints { - Canvas( - modifier = Modifier - .height(transition.height.value) - .fillMaxWidth() - .border( - transition.border.value, - transition.color.value, - RoundedCornerShape(transition.radius.value) - ) - .combinedClickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = { - }, - onLongClick = { isInAccessibilityMode.value = !isInAccessibilityMode.value } - ) - ) { - drawRoundRect( - color = transition.inactiveColor.value, - size = Size(maxWidth.toPx(), transition.height.value.toPx()), - cornerRadius = CornerRadius( - transition.radius.value.toPx(), - transition.radius.value.toPx() - ) - ) - - val min = range.from - val max = range.to - val progressWidth = when { - value <= min -> 0f - value >= max -> 1f - else -> (value - min).toFloat() / (max - min).toFloat() - } - - drawRoundRect( - color = transition.color.value, - size = Size(progressWidth * size.width, transition.height.value.toPx()), - cornerRadius = CornerRadius( - transition.radius.value.toPx(), - transition.radius.value.toPx() - ) - ) - } - } +internal fun DistanceChartView(value: Int, range: IntRange) { + LinearProgressIndicator( + progress = { value.toFloat() / range.last }, + modifier = Modifier.height(12.dp).fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) } -@Preview +@Preview(showBackground = true) @Composable private fun DistanceChartViewPreview() { - DistanceChartView(20, Range(0, 50)) + DistanceChartView(20, 0..50) } \ No newline at end of file diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/ElevationSection.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/ElevationSection.kt deleted file mode 100644 index 7ba03962e..000000000 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/ElevationSection.kt +++ /dev/null @@ -1,52 +0,0 @@ -package no.nordicsemi.android.toolbox.profile.view.directionFinder - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import no.nordicsemi.android.toolbox.profile.parser.directionFinder.PeripheralBluetoothAddress -import no.nordicsemi.android.toolbox.profile.parser.directionFinder.elevation.ElevationMeasurementData -import no.nordicsemi.android.toolbox.profile.R -import no.nordicsemi.android.toolbox.profile.data.SensorData -import no.nordicsemi.android.toolbox.profile.data.SensorValue -import no.nordicsemi.android.toolbox.profile.data.directionFinder.medianValue -import no.nordicsemi.android.ui.view.ScreenSection -import no.nordicsemi.android.ui.view.SectionTitle - -@Composable -internal fun ElevationSection(data: SensorData) { - ScreenSection { - SectionTitle( - resId = R.drawable.ic_elevation, stringResource(id = R.string.elevation_section) - ) - - Row( - modifier = Modifier.padding(end = 50.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - data.elevation.medianValue { it.elevation }?.let { ElevationView(it, data) } - } - } -} - -@Preview -@Composable -private fun ElevationSectionPreview() { - val sensorData = SensorData( - elevation = SensorValue( - values = listOf( - ElevationMeasurementData( - address = PeripheralBluetoothAddress.TEST, - elevation = 30 - ) - ) - ) - ) - ElevationSection(sensorData) -} diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/ElevationView.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/ElevationView.kt index 91e62c18c..939381321 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/ElevationView.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/ElevationView.kt @@ -35,13 +35,13 @@ import no.nordicsemi.android.toolbox.profile.R import no.nordicsemi.android.toolbox.profile.data.SensorData import no.nordicsemi.android.toolbox.profile.data.SensorValue import no.nordicsemi.android.toolbox.profile.data.directionFinder.displayElevation +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.QualityIndicator import no.nordicsemi.android.ui.view.CircleTransitionState import no.nordicsemi.android.ui.view.createCircleTransition @Composable internal fun ElevationView( value: Int, - data: SensorData ) { val duration = 1000 val radius = 100.dp @@ -60,26 +60,19 @@ internal fun ElevationView( ElevationLabels(Modifier.padding(8.dp)) Box( modifier = Modifier - .size(radius * 2) - .align(Alignment.Center) // force centering + .size(radius * 2), + contentAlignment = Alignment.Center ) { ElevationCanvas( radius = radius, - circleBorderColor = MaterialTheme.colorScheme.secondary, + circleBorderColor = MaterialTheme.colorScheme.surfaceVariant, transition = transition, value = value ) { isInAccessibilityMode.value = !isInAccessibilityMode.value } - } - } - data.displayElevation()?.let { - Box( - modifier = Modifier, - contentAlignment = Alignment.BottomCenter - ) { Text( - text = "Tilt Angle: $it", + text = "$value°", style = MaterialTheme.typography.titleLarge, ) } @@ -90,18 +83,7 @@ internal fun ElevationView( @Preview(showBackground = true) @Composable private fun ElevationViewPreview() { - val value = 15 // Example elevation value - val sensorData = SensorData( - elevation = SensorValue( - values = listOf( - ElevationMeasurementData( - address = PeripheralBluetoothAddress.TEST, - elevation = 30 - ) - ) - ) - ) - ElevationView(value = value, sensorData) + ElevationView(value = 30) } @Composable diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/LinearDataView.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/LinearDataView.kt index cf0cb59cb..7d8a6acb4 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/LinearDataView.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/LinearDataView.kt @@ -18,26 +18,40 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import no.nordicsemi.android.toolbox.profile.R +import no.nordicsemi.android.toolbox.profile.data.DFSServiceData import no.nordicsemi.android.toolbox.profile.data.SensorData -import no.nordicsemi.android.toolbox.profile.data.directionFinder.Range +import no.nordicsemi.android.toolbox.profile.data.SensorValue import no.nordicsemi.android.toolbox.profile.data.directionFinder.bestEffortValue import no.nordicsemi.android.toolbox.profile.data.directionFinder.ifftValue import no.nordicsemi.android.toolbox.profile.data.directionFinder.isMcpdSectionAvailable import no.nordicsemi.android.toolbox.profile.data.directionFinder.phaseSlopeValue import no.nordicsemi.android.toolbox.profile.data.directionFinder.rssiValue import no.nordicsemi.android.toolbox.profile.data.directionFinder.rttValue +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.PeripheralBluetoothAddress +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.QualityIndicator +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.azimuthal.AzimuthMeasurementData +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance.MCPDEstimate +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance.McpdMeasurementData +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance.RTTEstimate +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance.RttMeasurementData +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.elevation.ElevationMeasurementData @Composable internal fun LinearDataView( data: SensorData, - range: Range + range: IntRange ) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { data.rttValue()?.let { Text(stringResource(id = R.string.rtt), style = MaterialTheme.typography.titleSmall) - LinearDataItemView(name = stringResource(id = R.string.rtt), range, it) + LinearDataItemView( + name = stringResource(id = R.string.rtt_label), + range = range, + item = it, + showLegend = true + ) } if (data.isMcpdSectionAvailable()) { @@ -45,34 +59,34 @@ internal fun LinearDataView( stringResource(id = R.string.mcpd), style = MaterialTheme.typography.titleSmall ) - } - - data.ifftValue()?.let { - LinearDataItemView(name = stringResource(id = R.string.ifft_label), range, it) - } - data.phaseSlopeValue()?.let { - LinearDataItemView(name = stringResource(id = R.string.phase_label), range, it) - } + data.ifftValue()?.let { + LinearDataItemView(name = stringResource(id = R.string.ifft_label), range, it) + } - data.rssiValue()?.let { - LinearDataItemView(name = stringResource(id = R.string.rssi_label), range, it) - } + data.phaseSlopeValue()?.let { + LinearDataItemView(name = stringResource(id = R.string.phase_label), range, it) + } - data.bestEffortValue()?.let { - LinearDataItemView(name = stringResource(id = R.string.best_label), range, it) - } + data.rssiValue()?.let { + LinearDataItemView(name = stringResource(id = R.string.rssi_label), range, it) + } - Spacer(modifier = Modifier.height(8.dp)) + data.bestEffortValue()?.let { + LinearDataItemView(name = stringResource(id = R.string.best_label), range, it, showLegend = true) + } - data.ifftValue()?.let { - IfftFullForm() + // Add legend if IFFT is present. + data.ifftValue()?.let { + Spacer(modifier = Modifier.height(8.dp)) + IfftFullForm() + } } } } @Composable -private fun LinearDataItemView(name: String, range: Range, item: Int) { +private fun LinearDataItemView(name: String, range: IntRange, item: Int, showLegend: Boolean = false) { val labelWidth = 48.dp Column { @@ -80,67 +94,122 @@ private fun LinearDataItemView(name: String, range: Range, item: Int) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - Column { + Column( + modifier = Modifier.width(labelWidth), + ) { Text( - modifier = Modifier.width(labelWidth), text = name, style = MaterialTheme.typography.labelSmall ) Text( - text = "($item dm)", - style = MaterialTheme.typography.labelMedium, + text = stringResource(R.string.dm_value, item), + style = MaterialTheme.typography.bodySmall, ) } DistanceChartView(value = item, range = range) } - Row( - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier - .fillMaxWidth() - .padding(start = labelWidth) - ) { - Text( - text = stringResource(R.string.dm_value, range.from), - style = MaterialTheme.typography.labelSmall - ) - - val diff = range.to - range.from - val part = (diff / 4) - if (part > 0) { + if (showLegend) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding(start = labelWidth) + ) { Text( - text = stringResource(R.string.dm_value, range.from + part), + text = stringResource(R.string.dm_value, range.first), style = MaterialTheme.typography.labelSmall ) + + val diff = range.last - range.first + val part = (diff / 4) + if (part > 0) { + Text( + text = stringResource(R.string.dm_value, range.first + part), + style = MaterialTheme.typography.labelSmall + ) + Text( + text = stringResource(R.string.dm_value, range.first + 2 * part), + style = MaterialTheme.typography.labelSmall + ) + } + Text( - text = stringResource(R.string.dm_value, range.from + 2 * part), + text = stringResource(R.string.dm_value, range.last), style = MaterialTheme.typography.labelSmall ) } - - Text( - text = stringResource(R.string.dm_value, range.to), - style = MaterialTheme.typography.labelSmall - ) } } } +@Preview(showBackground = true) +@Composable +private fun LinearDataViewPreview() { + LinearDataView( + data = SensorData( + azimuth = SensorValue( + values = listOf( + AzimuthMeasurementData( + quality = QualityIndicator.POOR, + address = PeripheralBluetoothAddress.TEST, + azimuth = 50, + ), + ) + ), + elevation = SensorValue( + values = listOf( + ElevationMeasurementData( + quality = QualityIndicator.GOOD, + address = PeripheralBluetoothAddress.TEST, + elevation = 30, + ) + ) + ), + rttDistance = SensorValue( + values = listOf( + RttMeasurementData( + quality = QualityIndicator.POOR, + address = PeripheralBluetoothAddress.TEST, + rtt = RTTEstimate(10), + ) + ) + ), + mcpdDistance = SensorValue( + values = listOf( + McpdMeasurementData( + quality = QualityIndicator.POOR, + address = PeripheralBluetoothAddress.TEST, + mcpd = MCPDEstimate( + ifft = 10, + phaseSlope = 15, + rssi = 30, + best = 25, + ) + ) + ) + ) + ), + range = 0..50, + ) +} + @Preview(showBackground = true) @Composable private fun LinearDataItemViewPreview() { LinearDataItemView( - name = "RSSI", - range = Range(0, 50), - item = 49 + name = stringResource(R.string.rssi_label), + range = 0..50, + item = 40, + showLegend = true, ) } @Preview(showBackground = true) @Composable -private fun IfftFullForm(){ +private fun IfftFullForm() { Text( - text = "ifft - Inverse Fast Fourier Transform", + text = stringResource(R.string.ifft_hint), style = MaterialTheme.typography.bodySmall, modifier = Modifier.fillMaxWidth().alpha(0.5f) ) diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/MeasurementDetailsView.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/MeasurementDetailsView.kt deleted file mode 100644 index 5d7b226e2..000000000 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/MeasurementDetailsView.kt +++ /dev/null @@ -1,33 +0,0 @@ -package no.nordicsemi.android.toolbox.profile.view.directionFinder - -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import no.nordicsemi.android.toolbox.profile.R -import no.nordicsemi.android.toolbox.profile.data.DFSServiceData -import no.nordicsemi.android.toolbox.profile.data.SensorData -import no.nordicsemi.android.ui.view.ScreenSection - -@Composable -internal fun MeasurementDetailsView( - serviceData: DFSServiceData, - data: SensorData -) { - ScreenSection { - Text( - text = stringResource(R.string.distance_settings), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleMedium, - ) - LinearDataView(data, serviceData.distanceRange) - } -} - -@Preview -@Composable -private fun MeasurementDetailsViewPreview() { - MeasurementDetailsView(DFSServiceData(), SensorData()) -} diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/RangeSlider.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/RangeSlider.kt index 364054726..73063726c 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/RangeSlider.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/RangeSlider.kt @@ -1,9 +1,10 @@ package no.nordicsemi.android.toolbox.profile.view.directionFinder -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RangeSlider import androidx.compose.material3.Text @@ -14,44 +15,45 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import no.nordicsemi.android.toolbox.profile.R -import no.nordicsemi.android.toolbox.profile.data.directionFinder.Range import java.util.Locale @Composable -internal fun RangeSlider(range: Range, onChange: (Range) -> Unit) { +internal fun RangeSlider(range: IntRange, onChange: (IntRange) -> Unit) { Column { RangeSliderView(range, onChange) Row( - horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = stringResource(R.string.from), - style = MaterialTheme.typography.bodySmall - ) - Text( - text = String.format(Locale.US, "%d", range.from), - style = MaterialTheme.typography.titleSmall - ) - } - - Row(verticalAlignment = Alignment.CenterVertically) { - Text(text = stringResource(R.string.to), style = MaterialTheme.typography.bodySmall) - Text( - text = String.format(Locale.US, "%d", range.to), - style = MaterialTheme.typography.titleSmall - ) - } + Text( + text = stringResource(R.string.from), + style = MaterialTheme.typography.bodySmall + ) + Text( + text = "${range.first}", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(start = 8.dp), + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(R.string.to), + style = MaterialTheme.typography.bodySmall + ) + Text( + text = "${range.last}", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(start = 8.dp), + ) } } } @Composable fun RangeSliderView( - range: Range, - onChange: (Range) -> Unit, + range: IntRange, + onChange: (IntRange) -> Unit, valueRange: ClosedFloatingPointRange = 0f..500f, step: Int = 1 ) { @@ -63,7 +65,7 @@ fun RangeSliderView( value = sliderValues, onValueChange = { newValues -> currentOnChange.value( - Range(newValues.start.toInt(), newValues.endInclusive.toInt()) + IntRange(newValues.start.toInt(), newValues.endInclusive.toInt()) ) }, valueRange = valueRange, @@ -71,11 +73,14 @@ fun RangeSliderView( ) } -private fun Range.toFloatRange(): ClosedFloatingPointRange = - from.toFloat()..to.toFloat() +private fun IntRange.toFloatRange(): ClosedFloatingPointRange = + start.toFloat()..endInclusive.toFloat() -@Preview +@Preview(showBackground = true) @Composable private fun RangeSliderViewPreview() { - RangeSlider(Range(0, 50)) {} + RangeSlider( + range = 0..50, + onChange = {}, + ) } diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/SectionBluetoothDeviceComponent.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/SectionBluetoothDeviceComponent.kt index db09198e0..475b5bd84 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/SectionBluetoothDeviceComponent.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/directionFinder/SectionBluetoothDeviceComponent.kt @@ -1,47 +1,42 @@ package no.nordicsemi.android.toolbox.profile.view.directionFinder -import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +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.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CornerSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowDropUp +import androidx.compose.material.icons.filled.MyLocation +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource +import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import no.nordicsemi.android.toolbox.profile.parser.directionFinder.PeripheralBluetoothAddress -import no.nordicsemi.android.toolbox.profile.R +import no.nordicsemi.android.common.ui.view.CircularIcon +import no.nordicsemi.android.common.ui.view.SectionTitle import no.nordicsemi.android.toolbox.profile.data.DFSServiceData +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.PeripheralBluetoothAddress +import no.nordicsemi.android.toolbox.profile.parser.gls.data.RequestStatus import no.nordicsemi.android.toolbox.profile.viewmodel.DFSEvent import no.nordicsemi.android.ui.view.ScreenSection -import no.nordicsemi.android.ui.view.internal.EmptyView @Composable internal fun SectionBluetoothDeviceComponent( @@ -50,227 +45,188 @@ internal fun SectionBluetoothDeviceComponent( onEvent: (DFSEvent) -> Unit ) { val devices = data.data.keys.toList() - .filter { it.address.lowercase() != PeripheralBluetoothAddress.TEST.address.lowercase() } // ignore case with TEST address - - when { - selectedDevice == null && devices.isNotEmpty() -> ScreenSection { - NotSelectedView(devices) { - onEvent(DFSEvent.OnBluetoothDeviceSelected(it)) + val shape = MaterialTheme.shapes.medium + .let { + if (devices.isNotEmpty()) { + it.copy(bottomStart = CornerSize(4.dp), bottomEnd = CornerSize(4.dp)) + } else { + it } } - selectedDevice == null -> { - EmptyView( - R.string.device_no_devices, - R.string.device_no_devices_hint, + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + ScreenSection(shape = shape) { + SectionTitle( + painter = rememberVectorPainter(Icons.Default.MyLocation), + title = "Distance measurement", ) + when { + data.requestStatus != RequestStatus.SUCCESS -> ProgressView("Loading...") + devices.isEmpty() -> ProgressView("Scanning...") + else -> { + Text( + text = "Select the device to measure distance to:", + ) + } + } } - - else -> { - SelectedDevices(selectedDevice, devices, onEvent) + if (devices.isNotEmpty()) { + if (selectedDevice == null) { + DeviceList(devices) { + onEvent(DFSEvent.OnBluetoothDeviceSelected(it)) + } + } else { + DeviceSelected(selectedDevice, devices, onEvent) + } } } +} +@Composable +private fun ProgressView(text: String) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.fillMaxWidth(), + ) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(24.dp), + ) + Text(text = text) + } } @Composable -private fun SelectedDevices( +private fun DeviceSelected( selectedDevice: PeripheralBluetoothAddress, devices: List, onEvent: (DFSEvent) -> Unit ) { var showDropdownMenu by rememberSaveable { mutableStateOf(false) } - var width by rememberSaveable { mutableIntStateOf(0) } val icon = if (showDropdownMenu) Icons.Default.ArrowDropUp else Icons.Default.ArrowDropDown - - OutlinedCard( - modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .clickable { if (devices.size > 1) showDropdownMenu = true } - .onSizeChanged { width = it.width } - ) { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.padding(16.dp) - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - ) { - Image( - painter = painterResource(id = R.drawable.ic_elevation), - contentDescription = null, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.secondary), - modifier = Modifier.size(28.dp) - ) - - Column( - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringResource(id = R.string.selected_device), - ) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - text = selectedDevice.address.uppercase(), - style = MaterialTheme.typography.titleSmall - ) - Text( - text = "(${selectedDevice.type.name})", - style = MaterialTheme.typography.labelSmall - ) - } - } - + val otherDevices = devices.filter { it != selectedDevice } + + Box { + BluetoothDeviceView( + device = selectedDevice, + enabled = otherDevices.isNotEmpty(), + isLast = true, + onClick = { showDropdownMenu = true }, + menu = { // Don't show icon if only one device is available - if (devices.size > 1) { - Spacer(Modifier.weight(1f)) + if (otherDevices.isNotEmpty()) { Icon(icon, contentDescription = "") } } - } + ) DropdownMenu( expanded = showDropdownMenu, onDismissRequest = { showDropdownMenu = false }, - modifier = Modifier.width(with(LocalDensity.current) { width.toDp() }), ) { - Column( - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = stringResource(id = R.string.devices), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(horizontal = 16.dp) + otherDevices.forEach { + DropdownMenuItem( + text = { Text(it.toString()) }, + leadingIcon = { + Icon( + imageVector = Icons.Default.MyLocation, + contentDescription = null, + ) + }, + onClick = { + onEvent(DFSEvent.OnBluetoothDeviceSelected(it)) + showDropdownMenu = false + }, ) - HorizontalDivider() - - devices.forEach { - Text( - text = it.address, - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxWidth() - .clickable { - onEvent(DFSEvent.OnBluetoothDeviceSelected(it)) - showDropdownMenu = false - } - ) - } } } } -} -@Preview(showBackground = true) -@Composable -private fun MeasuredDevicesPreview() { - SelectedDevices( - PeripheralBluetoothAddress.TEST, - devices = listOf( - PeripheralBluetoothAddress.TEST, - PeripheralBluetoothAddress.TEST, - PeripheralBluetoothAddress.TEST - ) - ) {} } -@Preview(showBackground = true) @Composable -private fun SectionBluetoothDeviceComponentPreview() { - SectionBluetoothDeviceComponent( - DFSServiceData(), null - ) {} -} - -@Composable -internal fun NotSelectedView( +internal fun DeviceList( devices: List, onClick: (PeripheralBluetoothAddress) -> Unit ) { + val count = devices.size + Column( - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(2.dp) ) { - Text( - text = stringResource(id = R.string.devices), - style = MaterialTheme.typography.titleLarge - ) - - Column { - devices.forEach { address -> - BluetoothDeviceView( - device = address, - title = stringResource(id = R.string.device_address), - modifier = Modifier.fillMaxWidth() - ) { onClick(address) } - } + devices.forEachIndexed { index, address -> + BluetoothDeviceView( + device = address, + isLast = index == count - 1, + onClick = onClick, + ) } } } @Preview(showBackground = true) @Composable -private fun NotSelectedViewPreview() { - NotSelectedView( - listOf( +private fun DeviceSelectedPreview() { + DeviceSelected( + selectedDevice = PeripheralBluetoothAddress.TEST, + devices = listOf( PeripheralBluetoothAddress.TEST, PeripheralBluetoothAddress.TEST, PeripheralBluetoothAddress.TEST - ) - ) { } + ), + onEvent = {} + ) } @Preview(showBackground = true) @Composable -private fun EmptyItemPreview() { - EmptyView(R.string.device_no_devices, R.string.device_no_devices_hint) +private fun DeviceListPreview() { + DeviceList( + devices = listOf( + PeripheralBluetoothAddress.TEST, + PeripheralBluetoothAddress.TEST, + PeripheralBluetoothAddress.TEST + ), + onClick = {} + ) } @Composable internal fun BluetoothDeviceView( device: PeripheralBluetoothAddress, - title: String, - modifier: Modifier = Modifier, - onClick: (PeripheralBluetoothAddress) -> Unit + enabled: Boolean = true, + isLast: Boolean = true, + onClick: (PeripheralBluetoothAddress) -> Unit = {}, + menu: @Composable () -> Unit = {}, ) { - Row( + val shape = MaterialTheme.shapes.extraSmall + .let { + if (isLast) { + it.copy(bottomStart = CornerSize(12.dp), bottomEnd = CornerSize(12.dp)) + } else { + it + } + } + OutlinedCard( modifier = Modifier - .clip(RoundedCornerShape(4.dp)) - .clickable { onClick(device) } - .padding(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically + .fillMaxWidth() + .clickable(enabled = enabled) { onClick(device) }, + shape = shape, ) { - Image( - painter = painterResource(id = R.drawable.ic_elevation), - contentDescription = null, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.secondary), - modifier = Modifier - .size(28.dp) + // Note: The nRF DM sample sends distance to a device with address AA:BB:CC:DD:EE:FF + // with Azimuth and Elevation. This is a fake device to test the data. + val isTestDevice = device == PeripheralBluetoothAddress.TEST + val name = if (isTestDevice) "Test Data" else "nRF DM" + + ListItem( + headlineContent = { Text(name) }, + supportingContent = { Text(device.address) }, + leadingContent = { CircularIcon(imageVector = Icons.Default.MyLocation) }, + trailingContent = menu, ) - - Column(modifier) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium - ) - Text(text = device.address, style = MaterialTheme.typography.bodyMedium) - } - } -} - -@Preview(showBackground = true) -@Composable -private fun BluetoothDeviceViewPreview() { - BluetoothDeviceView( - PeripheralBluetoothAddress.TEST, - "Bluetooth Device - Test" - ) {} -} +} \ No newline at end of file diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/gls/GLSScreen.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/gls/GLSScreen.kt index 321d3471f..41fb8652f 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/gls/GLSScreen.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/gls/GLSScreen.kt @@ -6,28 +6,27 @@ import androidx.compose.foundation.layout.Arrangement 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.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material.icons.filled.Download +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -35,28 +34,32 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import no.nordicsemi.android.common.ui.view.ActionOutlinedButton +import no.nordicsemi.android.common.ui.view.SectionTitle +import no.nordicsemi.android.toolbox.profile.R +import no.nordicsemi.android.toolbox.profile.data.GLSServiceData import no.nordicsemi.android.toolbox.profile.parser.common.WorkingMode import no.nordicsemi.android.toolbox.profile.parser.gls.data.Carbohydrate import no.nordicsemi.android.toolbox.profile.parser.gls.data.ConcentrationUnit import no.nordicsemi.android.toolbox.profile.parser.gls.data.GLSMeasurementContext import no.nordicsemi.android.toolbox.profile.parser.gls.data.GLSRecord +import no.nordicsemi.android.toolbox.profile.parser.gls.data.GlucoseStatus import no.nordicsemi.android.toolbox.profile.parser.gls.data.Health import no.nordicsemi.android.toolbox.profile.parser.gls.data.Meal import no.nordicsemi.android.toolbox.profile.parser.gls.data.Medication import no.nordicsemi.android.toolbox.profile.parser.gls.data.MedicationUnit import no.nordicsemi.android.toolbox.profile.parser.gls.data.RecordType import no.nordicsemi.android.toolbox.profile.parser.gls.data.RequestStatus +import no.nordicsemi.android.toolbox.profile.parser.gls.data.SampleLocation import no.nordicsemi.android.toolbox.profile.parser.gls.data.Tester -import no.nordicsemi.android.toolbox.profile.R -import no.nordicsemi.android.toolbox.profile.data.GLSServiceData import no.nordicsemi.android.toolbox.profile.view.gls.details.GLSDetails import no.nordicsemi.android.toolbox.profile.viewmodel.GLSEvent import no.nordicsemi.android.toolbox.profile.viewmodel.GLSEvent.OnWorkingModeSelected @@ -65,7 +68,6 @@ import no.nordicsemi.android.ui.view.KeyValueColumn import no.nordicsemi.android.ui.view.KeyValueColumnReverse import no.nordicsemi.android.ui.view.ScreenSection import no.nordicsemi.android.ui.view.SectionRow -import no.nordicsemi.android.ui.view.SectionTitle import java.util.Calendar @Composable @@ -73,161 +75,135 @@ internal fun GLSScreen() { val glsViewModel = hiltViewModel() val glsServiceData by glsViewModel.glsState.collectAsStateWithLifecycle() val onClickEvent: (GLSEvent) -> Unit = { glsViewModel.onEvent(it) } - var isWorkingModeClicked by rememberSaveable { mutableStateOf(false) } - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxWidth() - ) { - ScreenSection { - SectionTitle( - resId = R.drawable.ic_gls, - title = "Glucose level", - menu = { - WorkingModeDropDown( - glsState = glsServiceData, - isWorkingModeSelected = isWorkingModeClicked, - onExpand = { isWorkingModeClicked = true }, - onDismiss = { isWorkingModeClicked = false }, - onClickEvent = { onClickEvent(it) } - ) - } - ) - } - RecordsView(glsServiceData) - } + GLSView( + data = glsServiceData, + onClickEvent = onClickEvent, + ) } @Composable -private fun WorkingModeDropDown( - glsState: GLSServiceData, - isWorkingModeSelected: Boolean, - onExpand: () -> Unit, - onDismiss: () -> Unit, +private fun GLSView( + data: GLSServiceData, onClickEvent: (GLSEvent) -> Unit ) { - if (glsState.requestStatus == RequestStatus.PENDING) { - CircularProgressIndicator() - } else { - Column { - OutlinedButton(onClick = { onExpand() }) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(text = if (glsState.workingMode != null) glsState.workingMode!!.toDisplayString() else "Request") - Icon(Icons.Default.ArrowDropDown, contentDescription = "") - } + ScreenSection { + SectionTitle( + painter = painterResource(R.drawable.ic_gls), + title = "Glucose", + menu = { + WorkingModeDropDown( + data = data, + onClickEvent = onClickEvent + ) } - if (isWorkingModeSelected) - WorkingModeDialog( - glsState = glsState, - onDismiss = onDismiss, - ) { - onClickEvent(it) - onDismiss() - } - } + ) + + RecordsView(data) } } -@Preview(showBackground = true) @Composable -private fun WorkingModeDropDownPreview() { - WorkingModeDropDown(GLSServiceData(), false, {}, {}, {}) +private fun WorkingModeDropDown( + data: GLSServiceData, + onClickEvent: (GLSEvent) -> Unit +) { + var showDialog by rememberSaveable { mutableStateOf(false) } + + ActionOutlinedButton( + text = "Request", + icon = Icons.Default.Download, + onClick = { showDialog = true }, + isInProgress = data.requestStatus == RequestStatus.PENDING, + ) + if (showDialog) { + WorkingModeDialog( + glsState = data, + onDismiss = { showDialog = false }, + onWorkingModeSelected = onClickEvent + ) + } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun WorkingModeDialog( glsState: GLSServiceData, onDismiss: () -> Unit, onWorkingModeSelected: (GLSEvent) -> Unit, ) { - val listState = rememberLazyListState() - val workingModeEntries = WorkingMode.entries.map { it } - val selectedIndex = workingModeEntries.indexOf(glsState.workingMode) - - LaunchedEffect(selectedIndex) { - if (selectedIndex >= 0) { - listState.scrollToItem(selectedIndex) - } - } + val workingModeEntries = WorkingMode.entries.toList() - Dialog( - onDismissRequest = { onDismiss() }, + BasicAlertDialog( + onDismissRequest = onDismiss, properties = DialogProperties( dismissOnBackPress = true, dismissOnClickOutside = true ) ) { - OutlinedCard( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) + ElevatedCard( + shape = RoundedCornerShape(24.dp), ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - ) { - Text( - text = "Request record", + Text( + text = "Request records", + modifier = Modifier.padding(top = 24.dp, start = 24.dp, end = 24.dp, bottom = 16.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge + ) + workingModeEntries.forEach { entry -> + Row( modifier = Modifier .fillMaxWidth() - .padding(8.dp), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleMedium - ) - HorizontalDivider() - LazyColumn( - state = listState - ) { - items(workingModeEntries.size) { index -> - val entry = workingModeEntries[index] - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(4.dp)) - .clickable { - onWorkingModeSelected(OnWorkingModeSelected(entry)) - } - .padding(8.dp), - ) { - Text( - text = entry.toDisplayString(), - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - style = MaterialTheme.typography.titleLarge, - color = if ((glsState.workingMode == entry) && glsState.records.isNotEmpty()) { - MaterialTheme.colorScheme.primary - } else - MaterialTheme.colorScheme.onBackground + .padding(horizontal = 8.dp) + .clip(RoundedCornerShape(16.dp)) + .clickable { + onWorkingModeSelected( + OnWorkingModeSelected(entry) ) + onDismiss() } + .height(48.dp) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val color = when (glsState.workingMode) { + entry -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onBackground } + Text( + text = entry.toString(), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.weight(1f), + color = color + ) } } + // So that bottom padding is 24.dp. + Spacer(modifier = Modifier.height(8.dp)) } } } -@Preview(showBackground = true) -@Composable -private fun WorkingModeDialogPreview() { - WorkingModeDialog(GLSServiceData(workingMode = WorkingMode.ALL), {}) {} -} - @Composable private fun RecordsView( state: GLSServiceData ) { - ScreenSection { - if (state.records.isEmpty()) { - RecordsViewWithoutData() - } else { - RecordsViewWithData(state) - } + if (state.records.isEmpty()) { + RecordsViewWithoutData() + } else { + RecordsViewWithData(state) + } +} + +@Composable +private fun RecordsViewWithoutData() { + Column { + Text(text = stringResource(id = R.string.gls_no_records_info)) + Text( + text = stringResource(id = R.string.gls_no_records_hint), + style = MaterialTheme.typography.bodySmall, + color = LocalContentColor.current.copy(alpha = 0.6f), + ) } } @@ -235,12 +211,13 @@ private fun RecordsView( private fun RecordsViewWithData( state: GLSServiceData ) { + // Max height for the scrollable section, adjust as needed (e.g. 300.dp) Column( + modifier = Modifier + .heightIn(max = 500.dp) + .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth() ) { - SectionTitle(resId = R.drawable.ic_records, title = "Records") - state.records.keys.forEachIndexed { i, it -> RecordItem(it, state.records[it]) @@ -257,45 +234,36 @@ private fun RecordItem( gleContext: GLSMeasurementContext? ) { var showBottomSheet by rememberSaveable { mutableStateOf(false) } - Row( - verticalAlignment = Alignment.CenterVertically, + SectionRow( modifier = Modifier .clip(RoundedCornerShape(10.dp)) - .clickable { - showBottomSheet = true - } + .clickable { showBottomSheet = true } .padding(8.dp) ) { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier - .fillMaxWidth() - .weight(1f) - ) { - SectionRow { - record.glucoseConcentration?.let { glucoseConcentration -> - record.unit?.let { unit -> - glucoseConcentrationDisplayValue(glucoseConcentration, unit) - } - }?.let { - KeyValueColumn( - record.type.toDisplayString(), - it, - keyStyle = MaterialTheme.typography.titleMedium - ) - } - record.time?.let { - KeyValueColumnReverse( - value = stringResource(id = R.string.gls_details_date_and_time), - key = stringResource(R.string.gls_timestamp, it) - ) - } + record.glucoseConcentration?.let { glucoseConcentration -> + record.unit?.let { unit -> + glucoseConcentrationDisplayValue(glucoseConcentration, unit) } + }?.let { + KeyValueColumn( + key = record.type.toDisplayString(), + value = it, + ) + } + record.time?.let { + KeyValueColumnReverse( + key = stringResource(id = R.string.gls_details_date_and_time), + value = stringResource(R.string.gls_timestamp, it) + ) } } if (showBottomSheet) { - GLSDetailsBottomSheet(record, gleContext) { showBottomSheet = false } + GLSDetailsBottomSheet( + record = record, + context = gleContext, + onDismiss = { showBottomSheet = false }, + ) } } @@ -308,9 +276,7 @@ private fun GLSDetailsBottomSheet( ) { val sheetState = rememberModalBottomSheetState() ModalBottomSheet( - onDismissRequest = { - onDismiss() - }, + onDismissRequest = onDismiss, sheetState = sheetState, shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), containerColor = MaterialTheme.colorScheme.surface, @@ -330,50 +296,66 @@ private fun GLSDetailsBottomSheet( } } +@Preview +@Composable +private fun GLSViewPreview_empty() { + GLSView( + data = GLSServiceData(), + onClickEvent = {} + ) +} -@Preview(showBackground = true) +@Preview @Composable -private fun RecordItemPreview() { - RecordItem( - record = GLSRecord( - sequenceNumber = 1, - time = Calendar.getInstance(), - glucoseConcentration = 0.5f, - unit = ConcentrationUnit.UNIT_KGPL, - type = RecordType.VENOUS_PLASMA, - status = null, - sampleLocation = null, - contextInformationFollows = true - ), - gleContext = GLSMeasurementContext( - sequenceNumber = 20, - carbohydrate = Carbohydrate.LUNCH, - carbohydrateAmount = 12.5f, - meal = Meal.CASUAL, - tester = Tester.SELF, - health = Health.NO_HEALTH_ISSUES, - exerciseDuration = 2, - exerciseIntensity = 1, - medication = Medication.PRE_MIXED_INSULIN, - medicationQuantity = .5f, - medicationUnit = MedicationUnit.UNIT_KG, - HbA1c = 0.5f +private fun GLSViewPreview() { + GLSView( + data = GLSServiceData( + records = mapOf( + GLSRecord( + sequenceNumber = 1, + time = Calendar.getInstance(), + glucoseConcentration = 0.5f, + unit = ConcentrationUnit.UNIT_KGPL, + type = RecordType.VENOUS_PLASMA, + status = GlucoseStatus(0x03), + sampleLocation = SampleLocation.FINGER, + contextInformationFollows = true + ) to GLSMeasurementContext( + sequenceNumber = 20, + carbohydrate = Carbohydrate.LUNCH, + carbohydrateAmount = 12.5f, + meal = Meal.CASUAL, + tester = Tester.SELF, + health = Health.NO_HEALTH_ISSUES, + exerciseDuration = 2, + exerciseIntensity = 1, + medication = Medication.PRE_MIXED_INSULIN, + medicationQuantity = .5f, + medicationUnit = MedicationUnit.UNIT_KG, + HbA1c = 0.5f + ), + GLSRecord( + sequenceNumber = 2, + time = Calendar.getInstance(), + glucoseConcentration = 0.6f, + unit = ConcentrationUnit.UNIT_MOLPL, + type = RecordType.ARTERIAL_PLASMA, + status = null, + sampleLocation = null, + contextInformationFollows = false, + ) to null, + ) ), + onClickEvent = {} ) } +@Preview(showBackground = true) @Composable -private fun RecordsViewWithoutData() { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - SectionTitle(icon = Icons.Default.Search, title = "No items") - - Text( - text = stringResource(id = R.string.gls_no_records_info), - style = MaterialTheme.typography.bodyMedium - ) - } +private fun WorkingModeDialogPreview() { + WorkingModeDialog( + glsState = GLSServiceData(workingMode = WorkingMode.ALL), + onDismiss = {}, + onWorkingModeSelected = {} + ) } diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/gls/details/GLSDetails.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/gls/details/GLSDetails.kt index 278b5eae1..43ab8c081 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/gls/details/GLSDetails.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/gls/details/GLSDetails.kt @@ -1,5 +1,6 @@ package no.nordicsemi.android.toolbox.profile.view.gls.details +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -14,6 +15,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import no.nordicsemi.android.toolbox.profile.R import no.nordicsemi.android.toolbox.profile.parser.gls.data.Carbohydrate import no.nordicsemi.android.toolbox.profile.parser.gls.data.ConcentrationUnit import no.nordicsemi.android.toolbox.profile.parser.gls.data.GLSMeasurementContext @@ -26,14 +28,13 @@ import no.nordicsemi.android.toolbox.profile.parser.gls.data.MedicationUnit import no.nordicsemi.android.toolbox.profile.parser.gls.data.RecordType import no.nordicsemi.android.toolbox.profile.parser.gls.data.SampleLocation import no.nordicsemi.android.toolbox.profile.parser.gls.data.Tester -import no.nordicsemi.android.toolbox.profile.R import no.nordicsemi.android.toolbox.profile.view.gls.glucoseConcentrationDisplayValue import no.nordicsemi.android.toolbox.profile.view.gls.toDisplayString import no.nordicsemi.android.ui.view.KeyValueColumn import no.nordicsemi.android.ui.view.KeyValueColumnReverse import no.nordicsemi.android.ui.view.KeyValueField -import no.nordicsemi.android.ui.view.ScreenSection import no.nordicsemi.android.ui.view.SectionRow +import no.nordicsemi.android.ui.view.SubsectionTitle import no.nordicsemi.android.ui.view.dialog.toBooleanText import java.util.Calendar @@ -43,238 +44,188 @@ internal fun GLSDetails(record: GLSRecord, context: GLSMeasurementContext?) { modifier = Modifier .padding(16.dp) .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - ScreenSection { - Column { - SectionRow { - KeyValueColumn( - stringResource(id = R.string.gls_details_sequence_number), - record.sequenceNumber.toString() - ) - record.time?.let { - KeyValueColumnReverse( - stringResource(id = R.string.gls_details_date_and_time), - stringResource(R.string.gls_timestamp, it) - ) - } - } + SectionRow { + KeyValueColumn( + key = stringResource(id = R.string.gls_details_sequence_number), + value = record.sequenceNumber.toString() + ) + record.time?.let { + KeyValueColumnReverse( + key = stringResource(id = R.string.gls_details_date_and_time), + value = stringResource(R.string.gls_timestamp, it) + ) + } + } + SectionRow { + record.type?.let { + KeyValueColumn( + key = stringResource(id = R.string.gls_details_type), + value = it.toDisplayString() + ) } - HorizontalDivider( - color = MaterialTheme.colorScheme.secondary, - modifier = Modifier.padding(vertical = 8.dp) + record.sampleLocation?.let { + KeyValueColumnReverse( + key = stringResource(id = R.string.gls_details_location), + value = it.toDisplayString() + ) + } + } + record.glucoseConcentration?.let { glucoseConcentration -> + record.unit?.let { unit -> + KeyValueColumn( + key = stringResource(id = R.string.gls_details_glucose_condensation_title), + value = glucoseConcentrationDisplayValue(glucoseConcentration, unit), + ) + } + } + + record.status?.let { + SubsectionTitle( + text = stringResource(R.string.gls_details_status_title) ) SectionRow { - record.type?.let { - KeyValueColumn( - stringResource(id = R.string.gls_details_type), it.toDisplayString() - ) - } - record.sampleLocation?.let { - KeyValueColumnReverse( - stringResource(id = R.string.gls_details_location), - it.toDisplayString() - ) - } - + KeyValueColumn( + key = stringResource(id = R.string.gls_details_battery_low), + value = it.deviceBatteryLow.toBooleanText(), + ) + KeyValueColumnReverse( + key = stringResource(id = R.string.gls_details_sensor_malfunction), + value = it.sensorMalfunction.toBooleanText(), + ) } SectionRow { - record.glucoseConcentration?.let { glucoseConcentration -> - record.unit?.let { unit -> - KeyValueColumn( - stringResource(id = R.string.gls_details_glucose_condensation_title), - glucoseConcentrationDisplayValue(glucoseConcentration, unit), - keyStyle = MaterialTheme.typography.titleMedium - ) - } - } + KeyValueColumn( + key = stringResource(id = R.string.gls_details_insufficient_sample), + value = it.sampleSizeInsufficient.toBooleanText(), + ) + KeyValueColumnReverse( + key = stringResource(id = R.string.gls_details_strip_insertion_error), + value = it.stripInsertionError.toBooleanText(), + ) } - - record.status?.let { - HorizontalDivider( - color = MaterialTheme.colorScheme.secondary, - modifier = Modifier.padding(vertical = 8.dp) + SectionRow { + KeyValueColumn( + key = stringResource(id = R.string.gls_details_strip_type_incorrect), + value = it.stripTypeIncorrect.toBooleanText(), ) - Text( - "Glucose status", - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.secondary + KeyValueColumnReverse( + key = stringResource(id = R.string.gls_details_sensor_result_too_high), + value = it.sensorResultHigherThenDeviceCanProcess.toBooleanText(), ) + } + SectionRow { + KeyValueColumn( + key = stringResource(id = R.string.gls_details_sensor_result_too_low), + value = it.sensorResultLowerThenDeviceCanProcess.toBooleanText(), + ) + KeyValueColumnReverse( + key = stringResource(id = R.string.gls_details_temperature_too_high), + value = it.sensorTemperatureTooHigh.toBooleanText(), + ) + } + SectionRow { + KeyValueColumn( + key = stringResource(id = R.string.gls_details_temperature_too_low), + value = it.sensorTemperatureTooLow.toBooleanText(), + ) + KeyValueColumnReverse( + key = stringResource(id = R.string.gls_details_strip_pulled_too_soon), + value = it.sensorReadInterrupted.toBooleanText(), + ) + } + SectionRow { + KeyValueColumn( + key = stringResource(id = R.string.gls_details_general_device_fault), + value = it.generalDeviceFault.toBooleanText(), + ) + KeyValueColumnReverse( + key = stringResource(id = R.string.gls_details_time_fault), + value = it.timeFault.toBooleanText(), + ) + } + } - SectionRow { - KeyValueColumn( - stringResource(id = R.string.gls_details_battery_low), - it.deviceBatteryLow.toBooleanText(), - verticalSpacing = 4.dp - ) - KeyValueColumnReverse( - stringResource(id = R.string.gls_details_sensor_malfunction), - it.sensorMalfunction.toBooleanText(), - verticalSpacing = 4.dp - ) - } - SectionRow { - KeyValueColumn( - stringResource(id = R.string.gls_details_insufficient_sample), - it.sampleSizeInsufficient.toBooleanText(), - verticalSpacing = 4.dp - ) - KeyValueColumnReverse( - stringResource(id = R.string.gls_details_strip_insertion_error), - it.stripInsertionError.toBooleanText(), - verticalSpacing = 4.dp - ) - } - SectionRow { - KeyValueColumn( - stringResource(id = R.string.gls_details_strip_type_incorrect), - it.stripTypeIncorrect.toBooleanText(), - verticalSpacing = 4.dp - ) - KeyValueColumnReverse( - stringResource(id = R.string.gls_details_sensor_result_too_high), - it.sensorResultHigherThenDeviceCanProcess.toBooleanText(), - verticalSpacing = 4.dp - ) - } + context?.let { glsMeasurementContext -> + SubsectionTitle( + text = stringResource(id = R.string.gls_context_title) + ) - SectionRow { - KeyValueColumn( - stringResource(id = R.string.gls_details_sensor_result_too_low), - it.sensorResultLowerThenDeviceCanProcess.toBooleanText(), - verticalSpacing = 4.dp - ) + SectionRow { + KeyValueColumn( + key = stringResource(id = R.string.gls_details_sequence_number), + value = glsMeasurementContext.sequenceNumber.toString(), + ) + glsMeasurementContext.carbohydrate?.let { + val carbohydrateAmount = glsMeasurementContext.carbohydrateAmount KeyValueColumnReverse( - stringResource(id = R.string.gls_details_temperature_too_high), - it.sensorTemperatureTooHigh.toBooleanText(), - verticalSpacing = 4.dp + key = stringResource(id = R.string.gls_context_carbohydrate), + value = it.toDisplayString() + " ($carbohydrateAmount g)", ) } - - SectionRow { + } + SectionRow { + glsMeasurementContext.meal?.let { KeyValueColumn( - stringResource(id = R.string.gls_details_temperature_too_low), - it.sensorTemperatureTooLow.toBooleanText(), - verticalSpacing = 4.dp - ) - KeyValueColumnReverse( - stringResource(id = R.string.gls_details_strip_pulled_too_soon), - it.sensorReadInterrupted.toBooleanText(), - verticalSpacing = 4.dp + key = stringResource(id = R.string.gls_context_meal), + value = it.toDisplayString(), ) } - - SectionRow { - KeyValueColumn( - stringResource(id = R.string.gls_details_general_device_fault), - it.generalDeviceFault.toBooleanText(), - verticalSpacing = 4.dp - ) + glsMeasurementContext.tester?.let { KeyValueColumnReverse( - stringResource(id = R.string.gls_details_time_fault), - it.timeFault.toBooleanText(), - verticalSpacing = 4.dp + key = stringResource(id = R.string.gls_context_tester), + value = it.toDisplayString(), ) } } - - HorizontalDivider( - color = MaterialTheme.colorScheme.secondary, - modifier = Modifier.padding(vertical = 8.dp) - ) - context?.let { glsMeasurementContext -> - Text( - stringResource(id = R.string.gls_context_title), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.secondary - ) - SectionRow { + SectionRow { + glsMeasurementContext.health?.let { KeyValueColumn( - stringResource(id = R.string.gls_details_sequence_number), - glsMeasurementContext.sequenceNumber.toString(), - verticalSpacing = 4.dp + key = stringResource(id = R.string.gls_context_health), + value = it.toDisplayString(), ) - glsMeasurementContext.carbohydrate?.let { - val carbohydrateAmount = glsMeasurementContext.carbohydrateAmount - KeyValueColumnReverse( - stringResource(id = R.string.gls_context_carbohydrate), - it.toDisplayString() + " ($carbohydrateAmount g)", - verticalSpacing = 4.dp - ) - } } - SectionRow { - glsMeasurementContext.meal?.let { - KeyValueColumn( - stringResource(id = R.string.gls_context_meal), - it.toDisplayString(), - verticalSpacing = 4.dp - ) - } - glsMeasurementContext.tester?.let { + glsMeasurementContext.exerciseDuration?.let { duration -> + glsMeasurementContext.exerciseIntensity?.let { exerciseIntensity -> KeyValueColumnReverse( - stringResource(id = R.string.gls_context_tester), - it.toDisplayString(), - verticalSpacing = 4.dp + key = stringResource(id = R.string.gls_context_exercise_title), + value = stringResource( + id = R.string.gls_context_exercise_field, + getExerciseDuration(duration), + exerciseIntensity + ), ) } } - SectionRow { - glsMeasurementContext.health?.let { - KeyValueColumn( - stringResource(id = R.string.gls_context_health), - it.toDisplayString(), - verticalSpacing = 4.dp - ) - } - glsMeasurementContext.exerciseDuration?.let { duration -> - glsMeasurementContext.exerciseIntensity?.let { exerciseIntensity -> - KeyValueColumnReverse( - stringResource(id = R.string.gls_context_exercise_title), - stringResource( - id = R.string.gls_context_exercise_field, - getExerciseDuration(duration), - exerciseIntensity - ), - verticalSpacing = 4.dp - ) - } - } + } + SectionRow { + glsMeasurementContext.medicationUnit?.let { medicationUnit -> + val medicationField = String.format( + stringResource(id = R.string.gls_context_medication_field), + glsMeasurementContext.medication?.toDisplayString(), + glsMeasurementContext.medicationQuantity, + medicationUnit.toDisplayString() + ) + KeyValueColumn( + key = stringResource(id = R.string.gls_context_medication_title), + value = medicationField, + ) } - SectionRow { - glsMeasurementContext.medicationUnit?.let { medicationUnit -> - val medicationField = String.format( - stringResource(id = R.string.gls_context_medication_field), - glsMeasurementContext.medication?.toDisplayString(), - glsMeasurementContext.medicationQuantity, - medicationUnit.toDisplayString() - ) - KeyValueColumn( - stringResource(id = R.string.gls_context_medication_title), - medicationField, - verticalSpacing = 4.dp - ) - } - glsMeasurementContext.HbA1c?.let { hbA1c -> - KeyValueColumnReverse( - stringResource(id = R.string.gls_context_hba1c_title), - stringResource(id = R.string.gls_context_hba1c_field, hbA1c), - verticalSpacing = 4.dp - ) - } + glsMeasurementContext.HbA1c?.let { hbA1c -> + KeyValueColumnReverse( + key = stringResource(id = R.string.gls_context_hba1c_title), + value = stringResource(id = R.string.gls_context_hba1c_field, hbA1c), + ) } - - } ?: KeyValueField( - stringResource(id = R.string.gls_context_title), - stringResource(id = R.string.gls_unavailable) - ) - } + } + } ?: KeyValueField( + key = stringResource(id = R.string.gls_context_title), + value = stringResource(id = R.string.gls_unavailable) + ) } } diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/hrs/HRSScreen.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/hrs/HRSScreen.kt index 44848eb3d..a8193ba78 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/hrs/HRSScreen.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/hrs/HRSScreen.kt @@ -4,8 +4,10 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MonitorHeart @@ -21,16 +23,15 @@ import androidx.compose.ui.draw.clip 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 androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import no.nordicsemi.android.common.ui.view.SectionTitle import no.nordicsemi.android.toolbox.profile.R import no.nordicsemi.android.toolbox.profile.viewmodel.HRSEvent import no.nordicsemi.android.toolbox.profile.viewmodel.HRSEvent.SwitchZoomEvent import no.nordicsemi.android.toolbox.profile.viewmodel.HRSViewModel import no.nordicsemi.android.ui.view.KeyValueColumn import no.nordicsemi.android.ui.view.ScreenSection -import no.nordicsemi.android.ui.view.SectionRow -import no.nordicsemi.android.ui.view.SectionTitle import no.nordicsemi.android.ui.view.animate.AnimatedHeart @Composable @@ -39,52 +40,41 @@ internal fun HRSScreen() { val hrsServiceData by hrsViewModel.hrsState.collectAsStateWithLifecycle() val onClickEvent: (HRSEvent) -> Unit = { hrsViewModel.onEvent(it) } - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxWidth() - ) { - ScreenSection(modifier = Modifier.padding(0.dp) /* No padding */) { - Column(modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp)) { - SectionTitle( - icon = Icons.Default.MonitorHeart, - title = stringResource(id = R.string.hrs_section_data), - menu = { - MagnifyingGlass(hrsServiceData.zoomIn) { onClickEvent(it) } - } - ) + ScreenSection{ + SectionTitle( + icon = Icons.Default.MonitorHeart, + title = stringResource(id = R.string.hrs_section_data), + menu = { + MagnifyingGlass(hrsServiceData.zoomIn) { onClickEvent(it) } + } + ) - LineChartView(hrsServiceData, hrsServiceData.zoomIn) - hrsServiceData.heartRate?.let { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp, end = 16.dp, top = 16.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - AnimatedHeart(modifier = Modifier.padding(8.dp)) - Text( - text = hrsServiceData.displayHeartRate(), - style = MaterialTheme.typography.titleLarge, - ) - } - } + LineChartView(hrsServiceData, hrsServiceData.zoomIn) + + hrsServiceData.heartRate?.let { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + AnimatedHeart() + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = hrsServiceData.displayHeartRate(), + style = MaterialTheme.typography.displaySmall, + ) } + } + hrsServiceData.bodySensorLocation?.let { HorizontalDivider() - hrsServiceData.bodySensorLocation?.let { - SectionRow { - KeyValueColumn( - stringResource(id = R.string.body_sensor_location), - hrsServiceData.displayBodySensorLocation(), - modifier = Modifier.padding(16.dp) - ) - } - } + KeyValueColumn( + key = stringResource(id = R.string.body_sensor_location), + value = hrsServiceData.displayBodySensorLocation(), + ) } } - } @Composable diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/hts/HTSScreen.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/hts/HTSScreen.kt index 405c181da..057cd89a7 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/hts/HTSScreen.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/hts/HTSScreen.kt @@ -2,47 +2,53 @@ package no.nordicsemi.android.toolbox.profile.view.hts import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -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.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.HorizontalDivider +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import no.nordicsemi.android.toolbox.profile.parser.hts.HTSMeasurementType +import no.nordicsemi.android.common.ui.view.SectionTitle import no.nordicsemi.android.toolbox.profile.R import no.nordicsemi.android.toolbox.profile.data.HTSServiceData import no.nordicsemi.android.toolbox.profile.data.uiMapper.TemperatureUnit +import no.nordicsemi.android.toolbox.profile.parser.hts.HTSData +import no.nordicsemi.android.toolbox.profile.parser.hts.HTSMeasurementType import no.nordicsemi.android.toolbox.profile.viewmodel.HTSEvent import no.nordicsemi.android.toolbox.profile.viewmodel.HTSViewModel import no.nordicsemi.android.ui.view.KeyValueColumn +import no.nordicsemi.android.ui.view.KeyValueColumnReverse import no.nordicsemi.android.ui.view.ScreenSection import no.nordicsemi.android.ui.view.SectionRow -import no.nordicsemi.android.ui.view.SectionTitle import no.nordicsemi.android.ui.view.TextWithAnimatedDots +import java.util.Calendar @Composable internal fun HTSScreen() { @@ -58,49 +64,45 @@ private fun HTSContent( htsServiceData: HTSServiceData, onClickEvent: (HTSEvent) -> Unit ) { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxWidth() - ) { - ScreenSection { - SectionTitle( - resId = R.drawable.ic_hts, - title = stringResource(id = R.string.hts_temperature), - menu = { - TemperatureUnitSettings( - state = htsServiceData, - onClickEvent = { onClickEvent(it) }, - ) - } - ) - SectionRow { - htsServiceData.data?.temperature?.let { temperature -> - KeyValueColumn( - value = stringResource(id = R.string.temperature_title), - key = htsServiceData.temperatureUnit.displayTemperature(temperature), - keyStyle = MaterialTheme.typography.titleMedium - ) - } ?: run { - TextWithAnimatedDots(text = stringResource(id = R.string.reading_temperature_placeholder)) - } + ScreenSection { + SectionTitle( + painter = painterResource(R.drawable.ic_hts), + title = stringResource(id = R.string.hts_temperature), + menu = { + TemperatureUnitSettings( + state = htsServiceData, + onClickEvent = { onClickEvent(it) }, + ) } - if (htsServiceData.data?.type != null) { - SectionRow { - KeyValueColumn( - value = stringResource(id = R.string.temp_measurement_location), - key = htsServiceData.data!!.type?.let { - HTSMeasurementType.fromValue(it).toString() - } ?: "Unknown", - keyStyle = MaterialTheme.typography.titleMedium + ) + SectionRow( + verticalAlignment = Alignment.Top, + ) { + htsServiceData.data?.temperature?.let { temperature -> + KeyValueColumn( + key = stringResource(id = R.string.temperature_title), + value = htsServiceData.temperatureUnit.displayTemperature(temperature), + valueStyle = MaterialTheme.typography.displayMedium, + ) + } ?: run { + TextWithAnimatedDots(text = stringResource(id = R.string.reading_temperature_placeholder)) + } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.End, + ) { + htsServiceData.data?.let { data -> + KeyValueColumnReverse( + key = stringResource(id = R.string.temp_measurement_location), + value = data.type + ?.let { HTSMeasurementType.fromValue(it).toString() } + ?: "Unknown", ) } - } - htsServiceData.data?.timestamp?.let { - SectionRow { - KeyValueColumn( - value = stringResource(R.string.temp_measurement_time), - key = it.toFormattedString(), - keyStyle = MaterialTheme.typography.titleMedium + htsServiceData.data?.timestamp?.let { + KeyValueColumnReverse( + key = stringResource(R.string.temp_measurement_time), + value = it.toFormattedString(), ) } } @@ -115,93 +117,102 @@ private fun TemperatureUnitSettings( ) { var openSettingsDialog by rememberSaveable { mutableStateOf(false) } - Column { - Icon( - imageVector = Icons.Default.Settings, - contentDescription = stringResource(id = R.string.hts_temperature_unit_des), - modifier = Modifier - .clip(CircleShape) - .clickable { openSettingsDialog = true } + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = stringResource(id = R.string.hts_temperature_unit_des), + modifier = Modifier + .clip(CircleShape) + .clickable { openSettingsDialog = true } + ) + if (openSettingsDialog) { + TemperatureUnitSettingsDialog( + state = state, + onDismiss = { openSettingsDialog = false }, + onClickEvent = onClickEvent, ) - if (openSettingsDialog) { - TemperatureUnitSettingsDialog( - state, - { openSettingsDialog = false } - ) { onClickEvent(it) } - } } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun TemperatureUnitSettingsDialog( state: HTSServiceData, onDismiss: () -> Unit, onClickEvent: (HTSEvent) -> Unit, ) { - val listState = rememberLazyListState() - val entries = TemperatureUnit.entries.map { it } + val entries = TemperatureUnit.entries.toList() - Dialog( - onDismissRequest = { onDismiss() }, + BasicAlertDialog( + onDismissRequest = onDismiss, properties = DialogProperties( dismissOnBackPress = true, dismissOnClickOutside = true ) ) { - OutlinedCard( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) + ElevatedCard( + shape = RoundedCornerShape(24.dp), ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - ) { - Text( - text = stringResource(id = R.string.hts_temperature_unit), + Text( + text = stringResource(R.string.hts_temperature_unit), + modifier = Modifier.padding(top = 24.dp, start = 24.dp, end = 24.dp, bottom = 16.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge + ) + entries.forEach { entry -> + Row( modifier = Modifier .fillMaxWidth() - .padding(8.dp), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleMedium - ) - HorizontalDivider() - LazyColumn( - state = listState - ) { - items(entries.size) { index -> - val entry = entries[index] - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(4.dp)) - .clickable { - onClickEvent( - HTSEvent.OnTemperatureUnitSelected(entry) - ) - onDismiss() - } - .padding(8.dp), - ) { - Text( - text = entry.toString(), - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - style = MaterialTheme.typography.titleLarge, - color = if (state.temperatureUnit == entry) - MaterialTheme.colorScheme.primary else - MaterialTheme.colorScheme.onBackground + .clickable { + onClickEvent( + HTSEvent.OnTemperatureUnitSelected(entry) ) + onDismiss() + }, + ) { + Text( + text = entry.toString(), + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), + color = when (state.temperatureUnit) { + entry -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onBackground } - } + ) } } + // So that bottom padding is 24.dp. + Spacer(modifier = Modifier.height(8.dp)) } } } +@Preview +@Composable +private fun HTSContentPreview_reading() { + HTSContent( + htsServiceData = HTSServiceData( + data = null, + temperatureUnit = TemperatureUnit.CELSIUS + ), + onClickEvent = {} + ) +} + +@Preview +@Composable +private fun HTSContentPreview() { + HTSContent( + htsServiceData = HTSServiceData( + data = HTSData( + temperature = 36.5f, + type = HTSMeasurementType.FINGER.value, + timestamp = Calendar.getInstance() + ), + temperatureUnit = TemperatureUnit.CELSIUS + ), + onClickEvent = {} + ) +} + @Preview(showBackground = true) @Composable private fun TemperatureUnitSettingsDialogPreview() { diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/lbs/BlinkyScreen.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/lbs/BlinkyScreen.kt index 3b9f4eabb..6824c21d0 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/lbs/BlinkyScreen.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/lbs/BlinkyScreen.kt @@ -1,32 +1,30 @@ package no.nordicsemi.android.toolbox.profile.view.lbs -import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Lightbulb import androidx.compose.material.icons.filled.RadioButtonChecked import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import no.nordicsemi.android.common.ui.view.SectionTitle import no.nordicsemi.android.toolbox.profile.R import no.nordicsemi.android.toolbox.profile.viewmodel.LBSEvent import no.nordicsemi.android.toolbox.profile.viewmodel.LBSViewModel +import no.nordicsemi.android.ui.view.ScreenSection @Composable internal fun BlinkyScreen() { @@ -41,118 +39,66 @@ internal fun BlinkyScreen() { ledState = serviceData.data.ledState, onStateChanged = { onClickEvent(LBSEvent.OnLedStateChanged(it)) }, ) - ButtonControlView( buttonState = serviceData.data.buttonState, ) } } -@Preview(showBackground = true) -@Composable -private fun BlinkyScreenPreview() { - BlinkyScreen() -} - @Composable -private fun ButtonControlView( - buttonState: Boolean +private fun LedControlView( + ledState: Boolean, + onStateChanged: (Boolean) -> Unit, + modifier: Modifier = Modifier, ) { - val (text, textColor) = if (buttonState) { - stringResource(id = R.string.button_pressed) to MaterialTheme.colorScheme.primary - } else { - stringResource(id = R.string.button_released) to MaterialTheme.colorScheme.onSurface - } - OutlinedCard { - Column( - modifier = Modifier - .padding(16.dp) + ScreenSection( + modifier = modifier + .clickable { onStateChanged(!ledState) } + ) { + SectionTitle( + icon = Icons.Default.Lightbulb, + title = stringResource(id = R.string.light), + tint = if (ledState) Color.Yellow else MaterialTheme.colorScheme.primary, + ) + Row( + verticalAlignment = Alignment.CenterVertically, ) { - Row( - modifier = Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Image( - imageVector = Icons.Default.RadioButtonChecked, - contentDescription = null, - modifier = Modifier.padding(end = 16.dp), - colorFilter = ColorFilter.tint(textColor) - ) - Text( - text = stringResource(id = R.string.button), - style = MaterialTheme.typography.headlineMedium, - ) - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = text, - color = textColor, - ) - } + Text( + text = stringResource(id = R.string.led_guide), + modifier = Modifier.weight(1f) + ) + Switch( + checked = ledState, + onCheckedChange = onStateChanged + ) } } } -@Preview(showBackground = true) -@Composable -private fun ButtonControlViewPreview() { - ButtonControlView( - buttonState = true, - ) -} - @Composable -private fun LedControlView( - ledState: Boolean, - onStateChanged: (Boolean) -> Unit, +private fun ButtonControlView( + buttonState: Boolean, modifier: Modifier = Modifier, ) { - val colorFilter = if (ledState) { - ColorFilter.tint(MaterialTheme.colorScheme.primary) + val (text, textColor) = if (buttonState) { + stringResource(id = R.string.button_pressed) to MaterialTheme.colorScheme.primary.copy(alpha = 0.6f) } else { - ColorFilter.tint(MaterialTheme.colorScheme.onSurface) + stringResource(id = R.string.button_released) to MaterialTheme.colorScheme.primary } - OutlinedCard( - modifier = modifier + ScreenSection( + modifier = modifier, ) { - Column( - modifier = Modifier - .clickable { onStateChanged(!ledState) } - .padding(16.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Image( - imageVector = Icons.Default.Lightbulb, - contentDescription = null, - modifier = Modifier.padding(end = 16.dp), - colorFilter = colorFilter - ) - Text( - text = stringResource(id = R.string.light), - style = MaterialTheme.typography.headlineMedium, - ) - } - Row( - modifier = Modifier - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(id = R.string.led_guide), - modifier = Modifier.weight(1f) - ) - Switch(checked = ledState, onCheckedChange = onStateChanged) - } + SectionTitle( + icon = Icons.Default.RadioButtonChecked, + title = stringResource(id = R.string.button), + tint = textColor, + ) + Row { + Text( + text = stringResource(id = R.string.button_guide), + modifier = Modifier.weight(1f) + ) + Text(text = text) } } } @@ -163,6 +109,13 @@ private fun LecControlViewPreview() { LedControlView( ledState = true, onStateChanged = {}, - modifier = Modifier.padding(16.dp), + ) +} + +@Preview(showBackground = true) +@Composable +private fun ButtonControlViewPreview() { + ButtonControlView( + buttonState = true, ) } diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/rscs/RSCSScreen.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/rscs/RSCSScreen.kt index 8c1d66f3f..9eeccd31c 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/rscs/RSCSScreen.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/rscs/RSCSScreen.kt @@ -1,46 +1,53 @@ package no.nordicsemi.android.toolbox.profile.view.rscs import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -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.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Checklist import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import no.nordicsemi.android.toolbox.profile.parser.rscs.RSCSSettingsUnit +import no.nordicsemi.android.common.ui.view.SectionTitle import no.nordicsemi.android.toolbox.profile.R import no.nordicsemi.android.toolbox.profile.data.RSCSServiceData +import no.nordicsemi.android.toolbox.profile.parser.rscs.RSCFeatureData +import no.nordicsemi.android.toolbox.profile.parser.rscs.RSCSData +import no.nordicsemi.android.toolbox.profile.parser.rscs.RSCSSettingsUnit import no.nordicsemi.android.toolbox.profile.viewmodel.RSCSEvent import no.nordicsemi.android.toolbox.profile.viewmodel.RSCSViewModel -import no.nordicsemi.android.ui.view.FeatureSupported +import no.nordicsemi.android.ui.view.FeaturesColumn import no.nordicsemi.android.ui.view.KeyValueColumn import no.nordicsemi.android.ui.view.KeyValueColumnReverse import no.nordicsemi.android.ui.view.ScreenSection import no.nordicsemi.android.ui.view.SectionRow -import no.nordicsemi.android.ui.view.SectionTitle @Composable internal fun RSCSScreen() { @@ -48,86 +55,107 @@ internal fun RSCSScreen() { val serviceData by rscsViewModel.rscsState.collectAsStateWithLifecycle() val onClickEvent: (RSCSEvent) -> Unit = { rscsViewModel.onEvent(it) } - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxWidth() - ) { - ScreenSection(modifier = Modifier.padding(bottom = 16.dp)) { - SectionTitle( - resId = R.drawable.ic_rscs, - title = if (serviceData.data.running) "Running" else "Walking", - modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp), - menu = { RSCSSettingsDropdown(serviceData, onClickEvent) } - ) - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.padding(start = 16.dp, end = 16.dp) - ) { - SectionRow { - KeyValueColumn( - stringResource(id = R.string.rscs_cadence), - serviceData.displayPace() - ) - KeyValueColumnReverse( - value = stringResource(id = R.string.rscs_activity), - key = if (serviceData.data.running) - "\uD83C\uDFC3 ${serviceData.displayActivity()}" else - "\uD83D\uDEB6 ${serviceData.displayActivity()}" - ) - } - SectionRow { - KeyValueColumn("Speed", "${serviceData.displaySpeed()}") - serviceData.displayStrideLength()?.let { - KeyValueColumnReverse(stringResource(id = R.string.stride_length), it) - } ?: serviceData.displayNumberOfSteps()?.let { - KeyValueColumnReverse( - stringResource(id = R.string.rscs_number_of_steps), - it - ) - } - } - serviceData.data.totalDistance?.let { - SectionRow { - KeyValueColumn( - "Total distance", - serviceData.data.displayDistance( - serviceData.unit ?: RSCSSettingsUnit.UNIT_M - ) - ) - } - } + Column { + RSCSView( + serviceData = serviceData, + onClickEvent = onClickEvent + ) + + serviceData.feature?.let { + Spacer(modifier = Modifier.height(16.dp)) + + RSCSFeaturesView(data = it) + } + } +} + +@Composable +private fun RSCSView( + serviceData: RSCSServiceData, + onClickEvent: (RSCSEvent) -> Unit +) { + ScreenSection { + SectionTitle( + painter = painterResource(R.drawable.ic_rscs), + title = if (serviceData.data.running) + stringResource(R.string.rscs_running) + else + stringResource(R.string.rscs_walking), + menu = { + RSCSSettingsDropdown(serviceData, onClickEvent) } - serviceData.feature?.let { - HorizontalDivider() - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.padding(start = 16.dp, end = 16.dp) - ) { - Text("Supported features", style = MaterialTheme.typography.titleMedium) - if (it.instantaneousStrideLengthMeasurementSupported) { - FeatureSupported( - stringResource(id = R.string.instantaneous_stride_length_measurement) - ) - } - if (it.totalDistanceMeasurementSupported) { - FeatureSupported( - stringResource(id = R.string.total_distance_measurement) - ) - } - if (it.walkingOrRunningStatusSupported) { - FeatureSupported( - stringResource(id = R.string.walking_or_running_status) - ) - } - if (it.calibrationSupported) { - FeatureSupported(stringResource(id = R.string.calibration)) - } - if (it.multipleSensorLocationsSupported) { - FeatureSupported(stringResource(id = R.string.multiple_sensor_location)) - } - } + ) + SectionRow { + KeyValueColumn( + key = stringResource(id = R.string.rscs_cadence), + value = serviceData.displayPace() + ) + KeyValueColumnReverse( + key = stringResource(id = R.string.rscs_activity), + value = if (serviceData.data.running) + "\uD83C\uDFC3 ${serviceData.displayActivity()}" else + "\uD83D\uDEB6 ${serviceData.displayActivity()}" + ) + } + SectionRow { + KeyValueColumn( + key = stringResource(R.string.rscs_speed), + value = serviceData.displaySpeed() ?: "-" + ) + serviceData.displayStrideLength()?.let { + KeyValueColumnReverse( + key = stringResource(id = R.string.stride_length), + value = it + ) + } ?: serviceData.displayNumberOfSteps()?.let { + KeyValueColumnReverse( + key = stringResource(id = R.string.rscs_number_of_steps), + value = it + ) } } + serviceData.data.totalDistance?.let { + KeyValueColumn( + key = stringResource(R.string.rscs_distance), + value = serviceData.data.displayDistance( + serviceData.unit ?: RSCSSettingsUnit.UNIT_METRIC + ) + ) + } + } +} + +@Composable +private fun RSCSFeaturesView( + data: RSCFeatureData +) { + ScreenSection { + SectionTitle( + painter = rememberVectorPainter(Icons.Default.Checklist), + title = stringResource(R.string.rscs_features), + ) + FeaturesColumn { + FeatureRow( + text = stringResource(id = R.string.instantaneous_stride_length_measurement), + supported = data.instantaneousStrideLengthMeasurementSupported + ) + FeatureRow( + text = stringResource(id = R.string.total_distance_measurement), + supported = data.totalDistanceMeasurementSupported + ) + FeatureRow( + text = stringResource(id = R.string.walking_or_running_status), + supported = data.walkingOrRunningStatusSupported + ) + FeatureRow( + text = stringResource(id = R.string.calibration), + supported = data.calibrationSupported + ) + FeatureRow( + text = stringResource(id = R.string.multiple_sensor_location), + supported = data.multipleSensorLocationsSupported + ) + } } } @@ -136,86 +164,114 @@ private fun RSCSSettingsDropdown( state: RSCSServiceData, onClickEvent: (RSCSEvent) -> Unit ) { - var openSettingsDialog by rememberSaveable { mutableStateOf(false) } + var showDialog by rememberSaveable { mutableStateOf(false) } - Column { + IconButton( + onClick = { showDialog = true } + ) { Icon( imageVector = Icons.Default.Settings, - contentDescription = "display settings", - modifier = Modifier - .clip(CircleShape) - .clickable { openSettingsDialog = true } + contentDescription = "Display settings", ) + } - if (openSettingsDialog) { - RSCSSettingsDialog(state, { openSettingsDialog = false }, onClickEvent) - } + if (showDialog) { + RSCSSettingsDialog( + state = state, + onDismiss = { showDialog = false }, + onSpeedUnitSelected = onClickEvent, + ) } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun RSCSSettingsDialog( state: RSCSServiceData, onDismiss: () -> Unit, onSpeedUnitSelected: (RSCSEvent) -> Unit ) { - Dialog( - onDismissRequest = { onDismiss() }, + BasicAlertDialog( + onDismissRequest = onDismiss, properties = DialogProperties( dismissOnBackPress = true, dismissOnClickOutside = true ) ) { - OutlinedCard( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) + ElevatedCard( + shape = RoundedCornerShape(24.dp), ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - ) { - Text( - text = stringResource(R.string.csc_settings), + Text( + text = stringResource(R.string.rscs_settings_unit_title), + modifier = Modifier.padding(top = 24.dp, start = 24.dp, end = 24.dp, bottom = 16.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleLarge + ) + RSCSSettingsUnit.entries.forEach { entry -> + Row( modifier = Modifier .fillMaxWidth() - .padding(8.dp), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleMedium - ) - HorizontalDivider() - Column { - RSCSSettingsUnit.entries.forEach { entry -> - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(4.dp)) - .clickable { - onSpeedUnitSelected( - RSCSEvent.OnSelectedSpeedUnitSelected(entry) - ) - onDismiss() - }, - ) { - Text( - text = entry.toString(), - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - color = if (state.unit == entry) - MaterialTheme.colorScheme.primary else - MaterialTheme.colorScheme.onBackground + .padding(horizontal = 8.dp) + .clip(RoundedCornerShape(16.dp)) + .clickable { + onSpeedUnitSelected( + RSCSEvent.OnSelectedSpeedUnitSelected(entry) ) + onDismiss() } - } + .height(48.dp) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = entry.toString(), + style = MaterialTheme.typography.labelLarge, + color = when (state.unit) { + entry -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onBackground + } + ) } } + // So that bottom padding is 24.dp. + Spacer(modifier = Modifier.height(8.dp)) } } } -@Preview(showBackground = true) +@Preview +@Composable +private fun RSCSViewPreview() { + RSCSView( + serviceData = RSCSServiceData( + data = RSCSData( + running = true, + instantaneousSpeed = 12f, + instantaneousCadence = 123, + strideLength = 1234, + totalDistance = 12345, + ), + unit = RSCSSettingsUnit.UNIT_IMPERIAL, + ), + onClickEvent = {} + ) +} + +@Preview +@Composable +private fun RSCSFeaturesViewPreview() { + RSCSFeaturesView( + data = RSCFeatureData( + instantaneousStrideLengthMeasurementSupported = true, + totalDistanceMeasurementSupported = true, + walkingOrRunningStatusSupported = true, + calibrationSupported = false, + multipleSensorLocationsSupported = true, + ) + ) +} + +@Preview @Composable private fun RSCSSettingsDialogPreview() { RSCSSettingsDialog( diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/rscs/RSCSUiMapper.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/rscs/RSCSUiMapper.kt index 659aa6032..da35e8f6b 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/rscs/RSCSUiMapper.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/rscs/RSCSUiMapper.kt @@ -12,10 +12,6 @@ import java.util.Locale fun RSCSServiceData.displayActivity(): String = stringResource(id = if (data.running) R.string.rscs_running else R.string.rscs_walking) -@Composable -fun RSCSServiceData.displayCadence(): String = - stringResource(id = R.string.rscs_speed, data.instantaneousSpeed) - @Composable fun RSCSServiceData.displayPace(): String = stringResource(id = R.string.rscs_rpm, data.instantaneousCadence) @@ -32,20 +28,16 @@ fun RSCSServiceData.displayNumberOfSteps(): String? { internal fun RSCSData.speedWithSpeedUnit(speedUnit: RSCSSettingsUnit): Float { return when (speedUnit) { - RSCSSettingsUnit.UNIT_M -> instantaneousSpeed - RSCSSettingsUnit.UNIT_KM -> instantaneousSpeed * 3.6f - RSCSSettingsUnit.UNIT_MPH -> instantaneousSpeed * 2.2369f - RSCSSettingsUnit.UNIT_CM -> instantaneousSpeed * 100 + RSCSSettingsUnit.UNIT_METRIC -> instantaneousSpeed + RSCSSettingsUnit.UNIT_IMPERIAL -> instantaneousSpeed * 2.2369f } } internal fun RSCSServiceData.displaySpeed(): String? { val speedWithUnit = unit?.let { data.speedWithSpeedUnit(it) } return when (unit) { - RSCSSettingsUnit.UNIT_M -> String.format(Locale.US, "%.1f m/s", speedWithUnit) - RSCSSettingsUnit.UNIT_KM -> String.format(Locale.US, "%.1f km/h", speedWithUnit) - RSCSSettingsUnit.UNIT_MPH -> String.format(Locale.US, "%.1f mph", speedWithUnit) - RSCSSettingsUnit.UNIT_CM -> String.format(Locale.US, "%.1f cm/s", speedWithUnit) + RSCSSettingsUnit.UNIT_METRIC -> String.format(Locale.US, "%.1f m/s", speedWithUnit) + RSCSSettingsUnit.UNIT_IMPERIAL -> String.format(Locale.US, "%.1f mph", speedWithUnit) null -> null } } @@ -59,70 +51,40 @@ internal fun RSCSServiceData.displaySpeed(): String? { internal fun RSCSData.displayDistance(speedUnit: RSCSSettingsUnit): String { if (totalDistance == null) return "" return when (speedUnit) { - RSCSSettingsUnit.UNIT_M -> String.format( + RSCSSettingsUnit.UNIT_METRIC -> String.format( Locale.US, "%.0f m", totalDistance!!.toFloat() ) - RSCSSettingsUnit.UNIT_KM -> String.format( - Locale.US, - "%.0f m", - totalDistance!!.toFloat().toKilometers() - ) - - RSCSSettingsUnit.UNIT_MPH -> String.format( + RSCSSettingsUnit.UNIT_IMPERIAL -> String.format( Locale.US, "%.2f mile", totalDistance!!.toFloat().toMiles() ) - - RSCSSettingsUnit.UNIT_CM -> String.format( - Locale.US, - "%.2f cm", - totalDistance!!.toFloat().toCentimeter() - ) } } -private fun Float.toCentimeter(): Float = this * 100 - @Composable internal fun RSCSServiceData.displayStrideLength(): String? { if (data.strideLength == null) return null return when (unit) { - RSCSSettingsUnit.UNIT_M -> String.format( + RSCSSettingsUnit.UNIT_METRIC -> String.format( Locale.US, "%.2f m", data.strideLength!! / 100.0f ) - RSCSSettingsUnit.UNIT_KM -> String.format( + RSCSSettingsUnit.UNIT_IMPERIAL -> String.format( Locale.US, - "%.4f km", - data.strideLength!! / 100000.0f - ) - - RSCSSettingsUnit.UNIT_MPH -> String.format( - Locale.US, - "%.4f mile", - data.strideLength!! / 160931.23f - ) - - RSCSSettingsUnit.UNIT_CM -> String.format( - Locale.US, - "%.1f cm", - data.strideLength!!.toFloat() + "%.2f ft", + (data.strideLength!!.toFloat() / 100) * 3.28084f ) null -> null } } -private fun Float.toKilometers(): Float { - return this / 1000f -} - private fun Float.toMiles(): Float { return this * 0.0006f } \ No newline at end of file diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/throughput/ThroughputScreen.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/throughput/ThroughputScreen.kt index 574fbb626..9d14ebdcc 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/throughput/ThroughputScreen.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/throughput/ThroughputScreen.kt @@ -1,20 +1,15 @@ package no.nordicsemi.android.toolbox.profile.view.throughput -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.SyncAlt import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -29,8 +24,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import no.nordicsemi.android.common.ui.view.ActionOutlinedButton +import no.nordicsemi.android.common.ui.view.SectionTitle import no.nordicsemi.android.toolbox.profile.R import no.nordicsemi.android.toolbox.profile.data.NumberOfBytes import no.nordicsemi.android.toolbox.profile.data.NumberOfSeconds @@ -43,7 +40,6 @@ import no.nordicsemi.android.ui.view.KeyValueColumn import no.nordicsemi.android.ui.view.KeyValueColumnReverse import no.nordicsemi.android.ui.view.ScreenSection import no.nordicsemi.android.ui.view.SectionRow -import no.nordicsemi.android.ui.view.SectionTitle import no.nordicsemi.android.ui.view.TextInputField @Composable @@ -67,50 +63,49 @@ private fun ThroughputContent( serviceData: ThroughputServiceData, onClickEvent: (ThroughputEvent) -> Unit ) { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth() - ) { - ScreenSection { - SectionTitle( - icon = Icons.Default.SyncAlt, - title = stringResource(id = R.string.throughput_service_name), - menu = { - var expanded by rememberSaveable { mutableStateOf(false) } - var number by rememberSaveable { mutableIntStateOf(0) } - var writeDataType by rememberSaveable { mutableStateOf("") } - - if (serviceData.writingStatus == WritingStatus.IN_PROGRESS) { - Box(modifier = Modifier.padding(8.dp)) { - CircularProgressIndicator( - modifier = Modifier.size(40.dp), - color = MaterialTheme.colorScheme.secondary, - trackColor = MaterialTheme.colorScheme.surfaceVariant, - ) - } - } else - WriteDropdown( - expanded = expanded, - writeDataType = writeDataType, - number = number, - onDropdownMenuSelected = { writeDataType = it }, - onNumberUpdate = { number = it }, - onDismiss = { - expanded = false - writeDataType = "" - number = 0 - }, - onExpand = { expanded = true }, - onClickEvent = onClickEvent - ) + ScreenSection { + SectionTitle( + icon = Icons.Default.SyncAlt, + title = stringResource(id = R.string.throughput_service_name), + menu = { + WorkingModeDropDown( + data = serviceData, + onClickEvent = onClickEvent, + ) + }, + ) + // Show throughput data. + when (serviceData.writingStatus) { + WritingStatus.IN_PROGRESS -> + ThroughputInProgress(serviceData.maxWriteValueLength) { + AnimatedThreeDots() } + else -> ThroughputData(serviceData) + } + } +} + +@Composable +private fun WorkingModeDropDown( + data: ThroughputServiceData, + onClickEvent: (ThroughputEvent) -> Unit +) { + var expanded by rememberSaveable { mutableStateOf(false) } + + // The Box is required to anchor the drop-down popup. + Box { + ActionOutlinedButton( + text = stringResource(R.string.throughput_write), + icon = Icons.Filled.PlayArrow, + onClick = { expanded = true }, + isInProgress = data.writingStatus == WritingStatus.IN_PROGRESS, + ) + if (expanded) { + WriteDropdown( + expanded = expanded, + onDismiss = { expanded = false }, + onClickEvent = onClickEvent ) - // Show throughput data. - when (serviceData.writingStatus) { - WritingStatus.IN_PROGRESS -> ThroughputInProgress(serviceData.maxWriteValueLength) { AnimatedThreeDots() } - WritingStatus.IDEAL, WritingStatus.COMPLETED -> ThroughputData(serviceData) - } } } } @@ -122,21 +117,21 @@ fun ThroughputInProgress( ) { SectionRow { KeyValueColumn( - stringResource(id = R.string.total_bytes_received), + key = stringResource(id = R.string.total_bytes_received), ) { animatedThreeDots() } KeyValueColumnReverse( - stringResource(id = R.string.gatt_write_number) + key = stringResource(id = R.string.gatt_write_number) ) { animatedThreeDots() } } SectionRow { KeyValueColumn( - stringResource(id = R.string.measured_throughput) + key = stringResource(id = R.string.measured_throughput) ) { animatedThreeDots() } // Show mtu size - maxWriteValueLength?.let { + maxWriteValueLength?.let { mtu -> KeyValueColumnReverse( - stringResource(id = R.string.max_write_value), - "$it" + key = stringResource(id = R.string.max_write_value), + value = "${mtu + 3}" ) } } @@ -147,24 +142,24 @@ private fun ThroughputData(serviceData: ThroughputServiceData) { serviceData.throughputData.let { SectionRow { KeyValueColumn( - stringResource(id = R.string.total_bytes_received), - it.throughputDataReceived() + key = stringResource(id = R.string.total_bytes_received), + value = it.throughputDataReceived() ) KeyValueColumnReverse( - stringResource(id = R.string.gatt_write_number), - it.gattWritesReceived.toString() + key = stringResource(id = R.string.gatt_write_number), + value = it.gattWritesReceived.toString() ) } SectionRow { KeyValueColumn( - stringResource(id = R.string.measured_throughput), - it.displayThroughput() + key = stringResource(id = R.string.measured_throughput), + value = it.displayThroughput() ) // Show mtu size - serviceData.maxWriteValueLength?.let { + serviceData.maxWriteValueLength?.let { mtu -> KeyValueColumnReverse( - stringResource(id = R.string.max_write_value), - "$it" + key = stringResource(id = R.string.max_write_value), + value = "${mtu + 3}" ) } } @@ -174,93 +169,81 @@ private fun ThroughputData(serviceData: ThroughputServiceData) { @Composable private fun WriteDropdown( expanded: Boolean, - number: Int, - writeDataType: String, onDismiss: () -> Unit, - onExpand: () -> Unit, - onDropdownMenuSelected: (String) -> Unit, - onNumberUpdate: (Int) -> Unit, onClickEvent: (ThroughputEvent) -> Unit ) { - Box { - Button(onClick = { onExpand() }) { - Text(stringResource(id = R.string.throughput_write)) - } - // Animated dropdown menu - DropdownMenu( - expanded = expanded, - onDismissRequest = { onDismiss() }, - modifier = Modifier.padding(8.dp) - ) { - when (writeDataType) { - NumberOfBytes.getString() -> { - // Show bytes input - TextInputField( - input = number.toString(), - label = stringResource(id = R.string.throughput_bytes), - placeholder = stringResource(id = R.string.throughput_bytes_description), - errorState = number < 0, - errorMessage = stringResource(id = R.string.throughput_bytes_error), - onUpdate = { - onNumberUpdate(it.toIntOrNull() ?: 0) + var number by rememberSaveable { mutableIntStateOf(0) } + var writeDataType by rememberSaveable { mutableStateOf("") } - }, - keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number) - ) - } + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismiss, + modifier = Modifier.padding(8.dp) + ) { + when (writeDataType) { + NumberOfBytes.getString() -> { + // Show bytes input + TextInputField( + input = number.toString(), + label = stringResource(id = R.string.throughput_bytes), + placeholder = stringResource(id = R.string.throughput_bytes_description), + errorState = number < 0, + errorMessage = stringResource(id = R.string.throughput_bytes_error), + onUpdate = { number = it.toIntOrNull() ?: 0 }, + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number) + ) + } + + NumberOfSeconds.getString() -> { + // Show time input + TextInputField( + input = number.toString(), + label = stringResource(id = R.string.throughput_time), + placeholder = stringResource(id = R.string.throughput_time_description), + errorState = number < 0, + errorMessage = stringResource(id = R.string.throughput_time_error), + onUpdate = {number = it.toIntOrNull() ?: 0 }, + keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number) + ) + } - NumberOfSeconds.getString() -> { - // Show time input - TextInputField( - input = number.toString(), - label = stringResource(id = R.string.throughput_time), - placeholder = stringResource(id = R.string.throughput_time_description), - errorState = number < 0, - errorMessage = stringResource(id = R.string.throughput_time_error), - onUpdate = { - onNumberUpdate(it.toIntOrNull() ?: 0) - }, - keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number) + else -> { + // Show throughput input type + getThroughputInputTypes().forEach { + DropdownMenuItem( + text = { Text(it) }, + onClick = { + writeDataType = it + when (it) { + NumberOfBytes.getString() -> number = 100 + NumberOfSeconds.getString() -> number = 20 + } + } ) } - - else -> { - // Show throughput input type - getThroughputInputTypes().forEach { - DropdownMenuItem( - text = { Text(it) }, - onClick = { - onDropdownMenuSelected(it) - when (it) { - NumberOfBytes.getString() -> onNumberUpdate(100) - NumberOfSeconds.getString() -> onNumberUpdate(20) + } + } + // Run button. + if (writeDataType.isNotEmpty() && number > 0) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Button( + onClick = { + onClickEvent( + ThroughputEvent.OnWriteData( + when (writeDataType) { + NumberOfBytes.getString() -> NumberOfBytes(number * 1024) + NumberOfSeconds.getString() -> NumberOfSeconds(number) + else -> throw IllegalArgumentException("Invalid throughput input type") } - } + ) ) + onDismiss() } - } - } - // Run button. - if (writeDataType.isNotEmpty() && number > 0) { - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center, ) { - Button( - colors = ButtonDefaults.buttonColors(), - onClick = { - onClickEvent( - ThroughputEvent.OnWriteData( - when (writeDataType) { - NumberOfBytes.getString() -> NumberOfBytes(number * 1024) - NumberOfSeconds.getString() -> NumberOfSeconds(number) - else -> throw IllegalArgumentException("Invalid throughput input type") - } - ) - ) - onDismiss() - } - ) { Text(text = stringResource(id = R.string.throughput_start)) } + Text(text = stringResource(id = R.string.throughput_start)) } } } @@ -270,5 +253,8 @@ private fun WriteDropdown( @Preview @Composable private fun ThroughputScreenPreview() { - ThroughputContent(ThroughputServiceData()) {} + ThroughputContent( + serviceData = ThroughputServiceData(), + onClickEvent = {} + ) } diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/throughput/ThroughputUiMapper.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/throughput/ThroughputUiMapper.kt index 070a7c715..196a6b648 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/throughput/ThroughputUiMapper.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/throughput/ThroughputUiMapper.kt @@ -11,11 +11,11 @@ internal fun ThroughputMetrics.throughputDataReceived(): String { return when { megabytes >= 1 -> { - "${String.format(Locale.US, "%.2f", megabytes)} MB" + "${String.format(Locale.US, "%.2f", megabytes)} mB" } kilobytes > 0 -> { - "${String.format(Locale.US, "%.2f", kilobytes)} KB" + "${String.format(Locale.US, "%.2f", kilobytes)} kB" } else -> { @@ -27,7 +27,7 @@ internal fun ThroughputMetrics.throughputDataReceived(): String { internal fun ThroughputMetrics.displayThroughput(): String { val kbps = (this.throughputBitsPerSecond / 8f) / 1024f return if (kbps > 0) { - "${String.format(Locale.US, "%.2f", kbps)} KBps" + "${String.format(Locale.US, "%.2f", kbps)} kBps" } else { "${this.throughputBitsPerSecond} bps" } diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/uart/MacroSection.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/uart/MacroSection.kt index 88e787191..6ac67fdf4 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/uart/MacroSection.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/uart/MacroSection.kt @@ -31,14 +31,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import no.nordicsemi.android.common.ui.view.SectionTitle import no.nordicsemi.android.toolbox.profile.R import no.nordicsemi.android.toolbox.profile.data.UARTViewState import no.nordicsemi.android.toolbox.profile.data.uart.UARTConfiguration import no.nordicsemi.android.toolbox.profile.viewmodel.UARTEvent -import no.nordicsemi.android.ui.view.SectionTitle @Composable internal fun MacroSection( @@ -58,27 +59,25 @@ internal fun MacroSection( onEvent = onEvent ) - Column { - OutlinedCard { - Column( - modifier = Modifier.padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - MacroSectionTitle( - onAddClick = { showAddDialog = true } - ) + OutlinedCard { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + MacroSectionTitle( + onAddClick = { showAddDialog = true } + ) - if (viewState.configurations.isNotEmpty()) { - MacroConfigControls( - viewState = viewState, - onDeleteClick = { showDeleteDialog = true }, - onEvent = onEvent - ) + if (viewState.configurations.isNotEmpty()) { + MacroConfigControls( + viewState = viewState, + onDeleteClick = { showDeleteDialog = true }, + onEvent = onEvent + ) - viewState.selectedConfiguration?.let { - UARTMacroView(it, viewState.isConfigurationEdited, onEvent) - } + viewState.selectedConfiguration?.let { + UARTMacroView(it, viewState.isConfigurationEdited, onEvent) } } } @@ -129,10 +128,13 @@ private fun MacroSectionTitle( onAddClick: () -> Unit, ) { SectionTitle( - resId = R.drawable.ic_macro, + painter = painterResource(R.drawable.ic_macro), title = stringResource(id = R.string.uart_macros), menu = { - CircleIcon(Icons.Default.Add, R.string.uart_configuration_add) { onAddClick() } + CircleIcon( + imageVector = Icons.Default.Add, R.string.uart_configuration_add, + onClick = onAddClick, + ) } ) } diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/uart/OutputSection.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/uart/OutputSection.kt index aa683a8b3..aa28d6a8c 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/uart/OutputSection.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/uart/OutputSection.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -33,11 +34,11 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import no.nordicsemi.android.common.ui.view.SectionTitle import no.nordicsemi.android.toolbox.profile.R import no.nordicsemi.android.toolbox.profile.data.UARTRecord import no.nordicsemi.android.toolbox.profile.data.UARTRecordType import no.nordicsemi.android.toolbox.profile.viewmodel.UARTEvent -import no.nordicsemi.android.ui.view.SectionTitle import java.text.SimpleDateFormat import java.util.Locale @@ -46,85 +47,54 @@ internal fun OutputSection( records: List, onEvent: (UARTEvent) -> Unit ) { - Box( + // Scrollable message area + OutlinedCard( modifier = Modifier - .fillMaxSize() - .imePadding() + .imePadding(), // Set a fixed height for the message area ) { - // Scrollable message area - OutlinedCard( - modifier = Modifier - .fillMaxSize() - .imePadding(), // Set a fixed height for the message area - ) { - SectionTitle( - icon = Icons.AutoMirrored.Filled.Chat, - title = "Messages", - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - menu = { Menu(onEvent) } - ) - - Spacer(modifier = Modifier.height(8.dp)) + SectionTitle( + icon = Icons.AutoMirrored.Filled.Chat, + title = "Messages", + modifier = Modifier.padding(16.dp), + menu = { Menu(onEvent) } + ) - val listState = rememberLazyListState() - LaunchedEffect(records.size) { - listState.animateScrollToItem(records.lastIndex.coerceAtLeast(0)) - } + val listState = rememberLazyListState() + LaunchedEffect(records.size) { + listState.animateScrollToItem(records.lastIndex.coerceAtLeast(0)) + } - LazyColumn( - state = listState, - modifier = Modifier - .padding(16.dp) - .heightIn(max = 500.dp), // Set a fixed height for the message area - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - if (records.isEmpty()) { - item { - Text( - text = stringResource(id = R.string.uart_output_placeholder), - modifier = Modifier.padding(8.dp) - ) - } - } else { - items(records) { record -> - when (record.type) { - UARTRecordType.INPUT -> MessageItemInput(record) - UARTRecordType.OUTPUT -> MessageItemOutput(record) - } + LazyColumn( + state = listState, + modifier = Modifier + .padding(horizontal = 16.dp) + .heightIn(max = 500.dp), // Set a fixed height for the message area + contentPadding = PaddingValues(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (records.isEmpty()) { + item { + Text(text = stringResource(id = R.string.uart_output_placeholder)) + } + } else { + items(records) { record -> + when (record.type) { + UARTRecordType.INPUT -> MessageItemInput(record) + UARTRecordType.OUTPUT -> MessageItemOutput(record) } } } - Column( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface) - ) { - Spacer(modifier = Modifier.weight(1f)) - HorizontalDivider() - InputSection( - onEvent = onEvent, - ) - } } + HorizontalDivider() + InputSection(onEvent = onEvent) } -} -@Preview(showBackground = true) -@Composable -private fun OutputSectionPreview() { - OutputSection( - records = emptyList() - ) { } } @Composable private fun MessageItemInput(record: UARTRecord) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), + modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.End ) { Text( @@ -133,12 +103,11 @@ private fun MessageItemInput(record: UARTRecord) { color = MaterialTheme.colorScheme.onSurface ) Spacer(modifier = Modifier.height(4.dp)) - Column( + Box( modifier = Modifier .clip(RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp, bottomStart = 10.dp)) .background(MaterialTheme.colorScheme.primaryContainer) .padding(8.dp), - horizontalAlignment = Alignment.End ) { Text( text = record.text.visualizeNewlines(), @@ -149,23 +118,10 @@ private fun MessageItemInput(record: UARTRecord) { } } -@Preview(showBackground = true) -@Composable -private fun MessageItemInputPreview() { - MessageItemInput( - record = UARTRecord( - text = "Hello, World!", - type = UARTRecordType.INPUT - ) - ) -} - @Composable private fun MessageItemOutput(record: UARTRecord) { Column( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), + modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start ) { Text( @@ -174,12 +130,11 @@ private fun MessageItemOutput(record: UARTRecord) { color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(4.dp)) - Column( + Box( modifier = Modifier .clip(RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp, bottomEnd = 10.dp)) .background(MaterialTheme.colorScheme.primary) .padding(8.dp), - horizontalAlignment = Alignment.Start ) { Text( text = record.text, @@ -190,21 +145,10 @@ private fun MessageItemOutput(record: UARTRecord) { } } -@Preview(showBackground = true) -@Composable -private fun MessageItemOutputPreview() { - MessageItemOutput( - record = UARTRecord( - text = "Hello, World!", - type = UARTRecordType.OUTPUT - ) - ) -} - @Composable private fun Menu(onEvent: (UARTEvent) -> Unit) { Icon( - Icons.Default.Delete, + imageVector = Icons.Default.Delete, contentDescription = stringResource(id = R.string.uart_clear_items), modifier = Modifier .clip(CircleShape) @@ -214,6 +158,55 @@ private fun Menu(onEvent: (UARTEvent) -> Unit) { ) } +@Preview +@Composable +private fun OutputSectionPreview_empty() { + OutputSection( + records = emptyList(), + onEvent = {}, + ) +} + +@Preview +@Composable +private fun OutputSectionPreview() { + OutputSection( + records = listOf( + UARTRecord( + text = "Knock, knock!", + type = UARTRecordType.INPUT + ), + UARTRecord( + text = "Who's there?", + type = UARTRecordType.OUTPUT + ), + ), + onEvent = {}, + ) +} + +@Preview(showBackground = true) +@Composable +private fun MessageItemInputPreview() { + MessageItemInput( + record = UARTRecord( + text = "Hello, World!", + type = UARTRecordType.INPUT + ) + ) +} + +@Preview(showBackground = true) +@Composable +private fun MessageItemOutputPreview() { + MessageItemOutput( + record = UARTRecord( + text = "Hello, World!", + type = UARTRecordType.OUTPUT + ) + ) +} + @Preview(showBackground = true) @Composable private fun MenuPreview() { diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/uart/UARTScreen.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/uart/UARTScreen.kt index 8389965cb..a2500d266 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/uart/UARTScreen.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/view/uart/UARTScreen.kt @@ -6,7 +6,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import no.nordicsemi.android.toolbox.profile.viewmodel.UARTEvent import no.nordicsemi.android.toolbox.profile.viewmodel.UartViewModel diff --git a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/viewmodel/DirectionFinderViewModel.kt b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/viewmodel/DirectionFinderViewModel.kt index 7f1d92b71..b0462ee3e 100644 --- a/profile/src/main/java/no/nordicsemi/android/toolbox/profile/viewmodel/DirectionFinderViewModel.kt +++ b/profile/src/main/java/no/nordicsemi/android/toolbox/profile/viewmodel/DirectionFinderViewModel.kt @@ -10,23 +10,20 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import no.nordicsemi.android.common.navigation.Navigator import no.nordicsemi.android.common.navigation.viewmodel.SimpleNavigationViewModel -import no.nordicsemi.android.toolbox.profile.parser.directionFinder.PeripheralBluetoothAddress -import no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance.DistanceMode -import no.nordicsemi.android.toolbox.profile.manager.repository.DFSRepository import no.nordicsemi.android.toolbox.lib.utils.Profile import no.nordicsemi.android.toolbox.profile.ProfileDestinationId import no.nordicsemi.android.toolbox.profile.data.DFSServiceData -import no.nordicsemi.android.toolbox.profile.data.directionFinder.MeasurementSection -import no.nordicsemi.android.toolbox.profile.data.directionFinder.Range +import no.nordicsemi.android.toolbox.profile.manager.repository.DFSRepository +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.PeripheralBluetoothAddress +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.controlPoint.ControlPointMode import no.nordicsemi.android.toolbox.profile.repository.DeviceRepository import javax.inject.Inject internal sealed interface DFSEvent { data object OnAvailableDistanceModeRequest : DFSEvent data object OnCheckDistanceModeRequest : DFSEvent - data class OnRangeChangedEvent(val range: Range) : DFSEvent - data class OnDistanceModeSelected(val mode: DistanceMode) : DFSEvent - data class OnDetailsSectionParamsSelected(val section: MeasurementSection) : DFSEvent + data class OnRangeChangedEvent(val range: IntRange) : DFSEvent + data class OnDistanceModeSelected(val mode: ControlPointMode) : DFSEvent data class OnBluetoothDeviceSelected(val device: PeripheralBluetoothAddress) : DFSEvent } @@ -97,10 +94,6 @@ internal class DirectionFinderViewModel @Inject constructor( } } - is DFSEvent.OnDetailsSectionParamsSelected -> { - DFSRepository.updateDetailsSection(address, event.section) - } - is DFSEvent.OnBluetoothDeviceSelected -> DFSRepository.updateSelectedDevice( address, event.device diff --git a/profile/src/main/res/drawable/ic_rscs.xml b/profile/src/main/res/drawable/ic_rscs.xml deleted file mode 100644 index 2ce614363..000000000 --- a/profile/src/main/res/drawable/ic_rscs.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - diff --git a/profile/src/main/res/drawable/ic_sync_down.xml b/profile/src/main/res/drawable/ic_sync_down.xml index e348756f0..598871df1 100644 --- a/profile/src/main/res/drawable/ic_sync_down.xml +++ b/profile/src/main/res/drawable/ic_sync_down.xml @@ -32,11 +32,10 @@ diff --git a/profile/src/main/res/drawable/ic_sync_down_off.xml b/profile/src/main/res/drawable/ic_sync_down_off.xml index e870ca64b..97361e2d3 100644 --- a/profile/src/main/res/drawable/ic_sync_down_off.xml +++ b/profile/src/main/res/drawable/ic_sync_down_off.xml @@ -32,15 +32,14 @@ diff --git a/profile/src/main/res/values/blinkyStrings.xml b/profile/src/main/res/values/blinkyStrings.xml index 69e1b4302..e755c1550 100644 --- a/profile/src/main/res/values/blinkyStrings.xml +++ b/profile/src/main/res/values/blinkyStrings.xml @@ -1,9 +1,11 @@ - Light - Switch light ON/OFF + LED + Switch to toggle the LED + Button + The button state PRESSED RELEASED \ No newline at end of file diff --git a/profile/src/main/res/values/bpsStrings.xml b/profile/src/main/res/values/bpsStrings.xml index ff6136095..fed90a386 100644 --- a/profile/src/main/res/values/bpsStrings.xml +++ b/profile/src/main/res/values/bpsStrings.xml @@ -1,10 +1,12 @@ Blood pressure + Intermediate cuff pressure Waiting for Blood Pressure sample… Click Button 1 on a DK to trigger one. + Intermediate cuff pressure Systolic pressure Diastolic pressure Mean arterial pressure (AP) @@ -13,12 +15,22 @@ %.2f %s %1$te %1$tb %1$tY at %1$tT - Body movement detected - Cuff fit detected - Irregular heart rate detected - Pulse rate in range - Measurement position detected - Improper measurement position - Pulse rate less than lower limit - Pulse rate exceeds upper limit + Supported features + Body movement detection + Cuff fit detection + Irregular heart rate detection + Pulse rate range detection + Measurement position detection + Multiple bonds + E2E CRC supported + User data service supported + User facing time supported + + Measurement status + Body movement detected + Cuff too lose + Irregular pulse detected + Pulse rate less than lower limit + Pulse rate exceeds upper limit + Improper measurement position \ No newline at end of file diff --git a/profile/src/main/res/values/cgmStrings.xml b/profile/src/main/res/values/cgmStrings.xml index 311b1797e..e1047a604 100644 --- a/profile/src/main/res/values/cgmStrings.xml +++ b/profile/src/main/res/values/cgmStrings.xml @@ -1,6 +1,8 @@ - There is no data available. Every record is created once a minute or longer. Please wait. + Continuous glucose + No data available. + Records are generated approximately once a minute. Sequence number %.2f mg/dL diff --git a/profile/src/main/res/values/cscStrings.xml b/profile/src/main/res/values/cscStrings.xml index e6909ab9c..3fc182247 100644 --- a/profile/src/main/res/values/cscStrings.xml +++ b/profile/src/main/res/values/cscStrings.xml @@ -1,6 +1,7 @@ - Cyclic and speed cadence + Cycling and speed cadence + Cycling Select wheel size @@ -13,5 +14,4 @@ Wheel size Select unit - \ No newline at end of file diff --git a/profile/src/main/res/values/dfsStrings.xml b/profile/src/main/res/values/dfsStrings.xml index ddb6b3785..f4c00d97b 100644 --- a/profile/src/main/res/values/dfsStrings.xml +++ b/profile/src/main/res/values/dfsStrings.xml @@ -5,17 +5,14 @@ Because of interferences the last read value can be wrong. To minimize this effect the current value is taken as middle value from cached values. The details are available on the chart below. Control device - Measurement details Azimuth - Distance in dm (decimeter) + Distance Elevation Measurement details - There is no device in range. - Make sure the device is in range. Address - Selected device - List of devices in range + Selected device: + Choose the device to measure distance to: Cancel Click button to check available distance modes. @@ -39,15 +36,15 @@ %d dm - Round-Trip Time (RTT) - ifft* - phase - rssi - best + RTT + IFFT* + Phase + RSSI + Best + IFFT - Inverse Fast Fourier Transform From: To: - Distance range - Measurement details + Distance range settings \ No newline at end of file diff --git a/profile/src/main/res/values/glsStrings.xml b/profile/src/main/res/values/glsStrings.xml index 53be38591..a9373b42b 100644 --- a/profile/src/main/res/values/glsStrings.xml +++ b/profile/src/main/res/values/glsStrings.xml @@ -31,7 +31,8 @@ --> - There is no data available. This peripheral downloads data only when requested. Please select what kind of data you want to download by clicking one from the working mode settings. + No records available. + Tap Request button above to read records. %1$te %1$tb %1$tY,%1$tT @@ -106,6 +107,7 @@ Glucose condensation %.2f %s + Measurement status Battery low Sensor malfunction Insufficient sample diff --git a/profile/src/main/res/values/rscsStrings.xml b/profile/src/main/res/values/rscsStrings.xml index f3ed7ba3d..a282c7a81 100644 --- a/profile/src/main/res/values/rscsStrings.xml +++ b/profile/src/main/res/values/rscsStrings.xml @@ -5,14 +5,15 @@ Activity Pace Cadence + Speed + Total distance + Supported features Number of steps Walking Running - %.1f min/km %d RPM - Number of Steps %d - Stride Length + Stride length Select unit diff --git a/profile/src/main/res/values/throughputStings.xml b/profile/src/main/res/values/throughputStings.xml index da14d4193..81c9c0a23 100644 --- a/profile/src/main/res/values/throughputStings.xml +++ b/profile/src/main/res/values/throughputStings.xml @@ -5,17 +5,18 @@ Start test Data - Write count - MTU (in bytes) + Number of packets sent + MTU Speed Test size (in kB) - Enter the number of bytes to be sent in the throughput test + Enter the number of kB to be sent in the throughput test. Test size (in kB) Test in time (seconds) - Enter the number of seconds to run the throughput test + Enter the number of seconds to run the throughput test. Test in time (seconds) + Run \ No newline at end of file diff --git a/profile_data/src/main/java/no/nordicsemi/android/toolbox/profile/data/DFSServiceData.kt b/profile_data/src/main/java/no/nordicsemi/android/toolbox/profile/data/DFSServiceData.kt index 3d96de486..6f21f3290 100644 --- a/profile_data/src/main/java/no/nordicsemi/android/toolbox/profile/data/DFSServiceData.kt +++ b/profile_data/src/main/java/no/nordicsemi/android/toolbox/profile/data/DFSServiceData.kt @@ -1,16 +1,13 @@ package no.nordicsemi.android.toolbox.profile.data +import no.nordicsemi.android.toolbox.lib.utils.Profile import no.nordicsemi.android.toolbox.profile.parser.directionFinder.PeripheralBluetoothAddress import no.nordicsemi.android.toolbox.profile.parser.directionFinder.azimuthal.AzimuthMeasurementData import no.nordicsemi.android.toolbox.profile.parser.directionFinder.ddf.DDFData -import no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance.DistanceMode import no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance.McpdMeasurementData import no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance.RttMeasurementData import no.nordicsemi.android.toolbox.profile.parser.directionFinder.elevation.ElevationMeasurementData import no.nordicsemi.android.toolbox.profile.parser.gls.data.RequestStatus -import no.nordicsemi.android.toolbox.lib.utils.Profile -import no.nordicsemi.android.toolbox.profile.data.directionFinder.MeasurementSection -import no.nordicsemi.android.toolbox.profile.data.directionFinder.Range private const val MAX_STORED_ITEMS = 5 @@ -20,9 +17,8 @@ data class DFSServiceData( val data: Map = emptyMap(), val ddfFeature: DDFData? = null, val selectedDevice: PeripheralBluetoothAddress? = null, - val distanceRange: Range = Range(0, 50), + val distanceRange: IntRange = 0..50, ) : ProfileServiceData() { - private val isMcpdAvailable = ddfFeature?.isMcpdAvailable private val isRttAvailable = ddfFeature?.isRttAvailable @@ -44,8 +40,6 @@ data class SensorData( val elevation: SensorValue? = null, val mcpdDistance: SensorValue? = null, val rttDistance: SensorValue? = null, - val distanceMode: DistanceMode? = null, - val selectedMeasurementSection: MeasurementSection? = null ) data class SensorValue( diff --git a/profile_data/src/main/java/no/nordicsemi/android/toolbox/profile/data/RSCSServiceData.kt b/profile_data/src/main/java/no/nordicsemi/android/toolbox/profile/data/RSCSServiceData.kt index 9c90b3254..b4ec6f1bd 100644 --- a/profile_data/src/main/java/no/nordicsemi/android/toolbox/profile/data/RSCSServiceData.kt +++ b/profile_data/src/main/java/no/nordicsemi/android/toolbox/profile/data/RSCSServiceData.kt @@ -8,6 +8,6 @@ import no.nordicsemi.android.toolbox.lib.utils.Profile data class RSCSServiceData( override val profile: Profile = Profile.RSCS, val data: RSCSData = RSCSData(), - val unit: RSCSSettingsUnit? = RSCSSettingsUnit.UNIT_M, + val unit: RSCSSettingsUnit? = RSCSSettingsUnit.UNIT_METRIC, val feature: RSCFeatureData? = null, ) : ProfileServiceData() diff --git a/profile_data/src/main/java/no/nordicsemi/android/toolbox/profile/data/ThroughputServiceData.kt b/profile_data/src/main/java/no/nordicsemi/android/toolbox/profile/data/ThroughputServiceData.kt index 01832679e..a24b39145 100644 --- a/profile_data/src/main/java/no/nordicsemi/android/toolbox/profile/data/ThroughputServiceData.kt +++ b/profile_data/src/main/java/no/nordicsemi/android/toolbox/profile/data/ThroughputServiceData.kt @@ -6,7 +6,7 @@ import no.nordicsemi.android.toolbox.lib.utils.Profile data class ThroughputServiceData( override val profile: Profile = Profile.THROUGHPUT, val throughputData: ThroughputMetrics = ThroughputMetrics(), - val writingStatus: WritingStatus = WritingStatus.IDEAL, + val writingStatus: WritingStatus = WritingStatus.IDLE, val maxWriteValueLength: Int? = null ) : ProfileServiceData() @@ -33,5 +33,5 @@ data class NumberOfSeconds( } enum class WritingStatus { - IDEAL, IN_PROGRESS, COMPLETED + IDLE, IN_PROGRESS, COMPLETED } \ No newline at end of file diff --git a/profile_data/src/main/java/no/nordicsemi/android/toolbox/profile/data/directionFinder/SensorDataExt.kt b/profile_data/src/main/java/no/nordicsemi/android/toolbox/profile/data/directionFinder/SensorDataExt.kt index feeb74618..0d1706504 100644 --- a/profile_data/src/main/java/no/nordicsemi/android/toolbox/profile/data/directionFinder/SensorDataExt.kt +++ b/profile_data/src/main/java/no/nordicsemi/android/toolbox/profile/data/directionFinder/SensorDataExt.kt @@ -3,26 +3,9 @@ package no.nordicsemi.android.toolbox.profile.data.directionFinder import no.nordicsemi.android.toolbox.profile.data.SensorData import no.nordicsemi.android.toolbox.profile.data.SensorValue -fun SensorValue?.mapValues(selector: (T) -> R): List? = - this?.values?.map(selector) - fun > SensorValue?.medianValue(selector: (T) -> R): R? = this?.values?.map(selector)?.sorted()?.let { it.getOrNull(it.size / 2) } -fun SensorData.azimuthValues() = azimuth.mapValues { it.azimuth } - -fun SensorData.elevationValues() = elevation.mapValues { it.elevation } - -fun SensorData.ifftValues() = mcpdDistance.mapValues { it.mcpd.ifft } - -fun SensorData.phaseSlopeValues() = mcpdDistance.mapValues { it.mcpd.phaseSlope } - -fun SensorData.rssiValues() = mcpdDistance.mapValues { it.mcpd.rssi } - -fun SensorData.bestEffortValues() = mcpdDistance.mapValues { it.mcpd.best } - -fun SensorData.rttValues() = rttDistance.mapValues { it.rtt.value } - fun SensorData.azimuthValue() = azimuth.medianValue { it.azimuth } fun SensorData.elevationValue() = elevation.medianValue { it.elevation } @@ -47,39 +30,7 @@ fun SensorData.displayElevation() = elevationValue()?.let { "$it°" } fun SensorData.isDistanceSettingsAvailable() = mcpdDistance != null || rttDistance != null +fun SensorData.isAzimuthAndElevationDataAvailable() = azimuthValue() != null && elevationValue() != null + fun SensorData.isMcpdSectionAvailable() = - rttValue() != null || rssiValue() != null || phaseSlopeValue() != null || bestEffortValue() != null - -enum class MeasurementSection(val displayName: String) { - DISTANCE_RTT("Round-Trip Time (RTT)"), - DISTANCE_MCPD_IFFT("Inverse Fast Fourier Transform (IFFT)"), - DISTANCE_MCPD_PHASE_SLOPE("Phase slope"), - DISTANCE_MCPD_RSSI("Rssi"), - DISTANCE_MCPD_BEST("Best effort distance estimate"); - - override fun toString(): String = displayName -} - -fun SensorData.availableSections(): List = listOfNotNull( - this.rttValue()?.let { MeasurementSection.DISTANCE_RTT }, - this.rssiValue()?.let { MeasurementSection.DISTANCE_MCPD_RSSI }, - this.ifftValue()?.let { MeasurementSection.DISTANCE_MCPD_IFFT }, - this.phaseSlopeValue()?.let { MeasurementSection.DISTANCE_MCPD_PHASE_SLOPE }, - this.bestEffortValue()?.let { MeasurementSection.DISTANCE_MCPD_BEST }, -) - -// Direction Finder Profile Events -data class Range( - val from: Int, - val to: Int -) - -fun SensorData.selectedMeasurementSectionValues(): List? = - when (this.selectedMeasurementSection) { - MeasurementSection.DISTANCE_RTT -> this.rttValues() - MeasurementSection.DISTANCE_MCPD_IFFT -> this.ifftValues() - MeasurementSection.DISTANCE_MCPD_PHASE_SLOPE -> this.phaseSlopeValues() - MeasurementSection.DISTANCE_MCPD_RSSI -> this.rssiValues() - MeasurementSection.DISTANCE_MCPD_BEST -> this.bestEffortValues() - null -> this.bestEffortValues() - } \ No newline at end of file + ifftValue() != null || rssiValue() != null || phaseSlopeValue() != null || bestEffortValue() != null \ No newline at end of file diff --git a/profile_manager/src/main/java/no/nordicsemi/android/toolbox/profile/manager/DFSManager.kt b/profile_manager/src/main/java/no/nordicsemi/android/toolbox/profile/manager/DFSManager.kt index d27ff3ac0..91dadd0f0 100644 --- a/profile_manager/src/main/java/no/nordicsemi/android/toolbox/profile/manager/DFSManager.kt +++ b/profile_manager/src/main/java/no/nordicsemi/android/toolbox/profile/manager/DFSManager.kt @@ -1,3 +1,5 @@ +@file:OptIn(ExperimentalUuidApi::class) + package no.nordicsemi.android.toolbox.profile.manager import kotlinx.coroutines.CoroutineScope @@ -7,35 +9,34 @@ import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.withContext +import no.nordicsemi.android.toolbox.lib.utils.Profile +import no.nordicsemi.android.toolbox.lib.utils.logAndReport +import no.nordicsemi.android.toolbox.profile.manager.repository.DFSRepository import no.nordicsemi.android.toolbox.profile.parser.directionFinder.azimuthal.AzimuthalMeasurementDataParser import no.nordicsemi.android.toolbox.profile.parser.directionFinder.controlPoint.ControlPointDataParser +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.controlPoint.ControlPointMode import no.nordicsemi.android.toolbox.profile.parser.directionFinder.ddf.DDFDataParser import no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance.DistanceMeasurementDataParser -import no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance.DistanceMode import no.nordicsemi.android.toolbox.profile.parser.directionFinder.elevation.ElevationMeasurementDataParser import no.nordicsemi.android.toolbox.profile.parser.gls.data.RequestStatus -import no.nordicsemi.android.toolbox.profile.manager.repository.DFSRepository -import no.nordicsemi.android.toolbox.lib.utils.Profile -import no.nordicsemi.android.toolbox.lib.utils.logAndReport import no.nordicsemi.kotlin.ble.client.RemoteCharacteristic import no.nordicsemi.kotlin.ble.client.RemoteService import no.nordicsemi.kotlin.ble.core.CharacteristicProperty import no.nordicsemi.kotlin.ble.core.WriteType import timber.log.Timber -import java.util.UUID import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.toKotlinUuid +import kotlin.uuid.Uuid private val DISTANCE_MEASUREMENT_CHARACTERISTIC_UUID = - UUID.fromString("21490001-494a-4573-98af-f126af76f490") + Uuid.parse("21490001-494a-4573-98af-f126af76f490") private val AZIMUTH_MEASUREMENT_CHARACTERISTIC_UUID = - UUID.fromString("21490002-494a-4573-98af-f126af76f490") + Uuid.parse("21490002-494a-4573-98af-f126af76f490") private val ELEVATION_MEASUREMENT_CHARACTERISTIC_UUID = - UUID.fromString("21490003-494a-4573-98af-f126af76f490") + Uuid.parse("21490003-494a-4573-98af-f126af76f490") private val DDF_FEATURE_CHARACTERISTIC_UUID = - UUID.fromString("21490004-494a-4573-98af-f126af76f490") + Uuid.parse("21490004-494a-4573-98af-f126af76f490") private val CONTROL_POINT_CHARACTERISTIC_UUID = - UUID.fromString("21490005-494a-4573-98af-f126af76f490") + Uuid.parse("21490005-494a-4573-98af-f126af76f490") internal class DFSManager : ServiceManager { override val profile: Profile @@ -49,7 +50,7 @@ internal class DFSManager : ServiceManager { ) { withContext(scope.coroutineContext) { remoteService.characteristics - .firstOrNull { it.uuid == AZIMUTH_MEASUREMENT_CHARACTERISTIC_UUID.toKotlinUuid() } + .firstOrNull { it.uuid == AZIMUTH_MEASUREMENT_CHARACTERISTIC_UUID } ?.subscribe() ?.mapNotNull { AzimuthalMeasurementDataParser().parse(it) } ?.onEach { DFSRepository.addNewAzimuth(deviceId, it) } @@ -58,7 +59,7 @@ internal class DFSManager : ServiceManager { ?.launchIn(scope) remoteService.characteristics - .firstOrNull { it.uuid == DISTANCE_MEASUREMENT_CHARACTERISTIC_UUID.toKotlinUuid() } + .firstOrNull { it.uuid == DISTANCE_MEASUREMENT_CHARACTERISTIC_UUID } ?.subscribe() ?.mapNotNull { DistanceMeasurementDataParser().parse(it) } ?.onEach { DFSRepository.addNewDistance(deviceId, it) } @@ -67,7 +68,7 @@ internal class DFSManager : ServiceManager { ?.launchIn(scope) remoteService.characteristics - .firstOrNull { it.uuid == ELEVATION_MEASUREMENT_CHARACTERISTIC_UUID.toKotlinUuid() } + .firstOrNull { it.uuid == ELEVATION_MEASUREMENT_CHARACTERISTIC_UUID } ?.subscribe() ?.mapNotNull { ElevationMeasurementDataParser().parse(it) } ?.onEach { DFSRepository.addNewElevation(deviceId, it) } @@ -76,7 +77,7 @@ internal class DFSManager : ServiceManager { ?.launchIn(scope) val ddfFeatureCharacteristics = remoteService.characteristics - .firstOrNull { it.uuid == DDF_FEATURE_CHARACTERISTIC_UUID.toKotlinUuid() } + .firstOrNull { it.uuid == DDF_FEATURE_CHARACTERISTIC_UUID } ?.apply { ddfFeatureCharacteristic = this } val isReadPropertyAvailable = ddfFeatureCharacteristics ?.properties?.contains(CharacteristicProperty.READ) @@ -89,7 +90,7 @@ internal class DFSManager : ServiceManager { } remoteService.characteristics - .firstOrNull { it.uuid == CONTROL_POINT_CHARACTERISTIC_UUID.toKotlinUuid() } + .firstOrNull { it.uuid == CONTROL_POINT_CHARACTERISTIC_UUID } ?.apply { controlPointCharacteristic = this } ?.subscribe() ?.mapNotNull { ControlPointDataParser().parse(it) } @@ -108,33 +109,28 @@ internal class DFSManager : ServiceManager { private val RTT_ENABLED_BYTES = byteArrayOf(0x01, 0x00) private val CHECK_CONFIG_BYTES = byteArrayOf(0x0A) - suspend fun enableDistanceMode(deviceId: String, mode: DistanceMode) { + suspend fun enableDistanceMode(deviceId: String, mode: ControlPointMode) { val data = when (mode) { - DistanceMode.MCPD -> MCPD_ENABLED_BYTES - DistanceMode.RTT -> RTT_ENABLED_BYTES + ControlPointMode.MCPD -> MCPD_ENABLED_BYTES + ControlPointMode.RTT -> RTT_ENABLED_BYTES } try { controlPointCharacteristic.write(data, WriteType.WITH_RESPONSE) + DFSRepository.updateNewRequestStatus(deviceId, RequestStatus.SUCCESS) } catch (e: Exception) { Timber.e(e, "Failed to enable distance mode: $mode for device: $deviceId") DFSRepository.updateNewRequestStatus(deviceId, RequestStatus.FAILED) - } finally { - DFSRepository.updateNewRequestStatus(deviceId, RequestStatus.SUCCESS) } } suspend fun checkForCurrentDistanceMode(deviceId: String) { try { - controlPointCharacteristic.write( - CHECK_CONFIG_BYTES, - writeType = WriteType.WITH_RESPONSE - ) + controlPointCharacteristic.write(CHECK_CONFIG_BYTES, WriteType.WITH_RESPONSE) + DFSRepository.updateNewRequestStatus(deviceId, RequestStatus.SUCCESS) } catch (e: Exception) { Timber.e(e, "Failed to check current distance mode for device: $deviceId") DFSRepository.updateNewRequestStatus(deviceId, RequestStatus.FAILED) - } finally { - DFSRepository.updateNewRequestStatus(deviceId, RequestStatus.SUCCESS) } } diff --git a/profile_manager/src/main/java/no/nordicsemi/android/toolbox/profile/manager/repository/DFSRepository.kt b/profile_manager/src/main/java/no/nordicsemi/android/toolbox/profile/manager/repository/DFSRepository.kt index 9845a2794..5c66dedc8 100644 --- a/profile_manager/src/main/java/no/nordicsemi/android/toolbox/profile/manager/repository/DFSRepository.kt +++ b/profile_manager/src/main/java/no/nordicsemi/android/toolbox/profile/manager/repository/DFSRepository.kt @@ -5,27 +5,24 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import no.nordicsemi.android.toolbox.profile.data.DFSServiceData +import no.nordicsemi.android.toolbox.profile.data.SensorData +import no.nordicsemi.android.toolbox.profile.data.SensorValue +import no.nordicsemi.android.toolbox.profile.manager.DFSManager import no.nordicsemi.android.toolbox.profile.parser.directionFinder.PeripheralBluetoothAddress import no.nordicsemi.android.toolbox.profile.parser.directionFinder.azimuthal.AzimuthMeasurementData import no.nordicsemi.android.toolbox.profile.parser.directionFinder.controlPoint.ControlPointChangeModeError import no.nordicsemi.android.toolbox.profile.parser.directionFinder.controlPoint.ControlPointChangeModeSuccess import no.nordicsemi.android.toolbox.profile.parser.directionFinder.controlPoint.ControlPointCheckModeError import no.nordicsemi.android.toolbox.profile.parser.directionFinder.controlPoint.ControlPointCheckModeSuccess +import no.nordicsemi.android.toolbox.profile.parser.directionFinder.controlPoint.ControlPointMode import no.nordicsemi.android.toolbox.profile.parser.directionFinder.controlPoint.ControlPointResult import no.nordicsemi.android.toolbox.profile.parser.directionFinder.ddf.DDFData import no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance.DistanceMeasurementData -import no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance.DistanceMode import no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance.McpdMeasurementData import no.nordicsemi.android.toolbox.profile.parser.directionFinder.distance.RttMeasurementData import no.nordicsemi.android.toolbox.profile.parser.directionFinder.elevation.ElevationMeasurementData -import no.nordicsemi.android.toolbox.profile.parser.directionFinder.toDistanceMode import no.nordicsemi.android.toolbox.profile.parser.gls.data.RequestStatus -import no.nordicsemi.android.toolbox.profile.data.DFSServiceData -import no.nordicsemi.android.toolbox.profile.data.SensorData -import no.nordicsemi.android.toolbox.profile.data.SensorValue -import no.nordicsemi.android.toolbox.profile.data.directionFinder.MeasurementSection -import no.nordicsemi.android.toolbox.profile.data.directionFinder.Range -import no.nordicsemi.android.toolbox.profile.manager.DFSManager object DFSRepository { private val _dataMap = mutableMapOf>() @@ -55,8 +52,8 @@ object DFSRepository { fun addNewDistance(deviceId: String, distance: DistanceMeasurementData) { when (distance) { - is McpdMeasurementData -> addDistance(deviceId, distance, DistanceMode.MCPD) - is RttMeasurementData -> addDistance(deviceId, distance, DistanceMode.RTT) + is McpdMeasurementData -> addDistance(deviceId, distance) + is RttMeasurementData -> addDistance(deviceId, distance) } } @@ -67,22 +64,19 @@ object DFSRepository { private fun addDistance( deviceId: String, distance: DistanceMeasurementData, - distanceMode: DistanceMode, ) { _dataMap[deviceId]?.update { current -> val key = distance.address val sensorData = current.data[key] ?: SensorData() - val newSensorData = when (distanceMode) { - DistanceMode.MCPD -> sensorData.copy( + val newSensorData = when (distance) { + is McpdMeasurementData -> sensorData.copy( mcpdDistance = sensorData.mcpdDistance - ?.copyWithNewValue(distance as McpdMeasurementData) ?: SensorValue(), - distanceMode = distanceMode + ?.copyWithNewValue(distance) ?: SensorValue(), ) - DistanceMode.RTT -> sensorData.copy( + is RttMeasurementData -> sensorData.copy( rttDistance = sensorData.rttDistance - ?.copyWithNewValue(distance as RttMeasurementData) ?: SensorValue(), - distanceMode = distanceMode + ?.copyWithNewValue(distance) ?: SensorValue(), ) } val newDevicesData = current.data.toMutableMap().apply { @@ -113,25 +107,11 @@ object DFSRepository { _dataMap[deviceId]?.update { it.copy(requestStatus = requestStatus) } } - suspend fun enableDistanceMode(deviceId: String, distanceMode: DistanceMode) { + suspend fun enableDistanceMode(deviceId: String, distanceMode: ControlPointMode) { _dataMap[deviceId]?.update { it.copy(requestStatus = RequestStatus.PENDING) } DFSManager.enableDistanceMode(deviceId, distanceMode) } - private fun setDistanceMode(deviceId: String, distanceMode: DistanceMode) { - _dataMap[deviceId]?.update { serviceData -> - serviceData.copy( - data = serviceData.data.mapValues { (key, sensorData) -> - if (key == serviceData.selectedDevice) { - sensorData.copy(distanceMode = distanceMode) - } else { - sensorData - } - } - ) - } - } - fun setAvailableDistanceModes(deviceId: String, ddfData: DDFData) { updateNewRequestStatus(deviceId, RequestStatus.PENDING) _dataMap[deviceId]?.update { @@ -160,7 +140,6 @@ object DFSRepository { is ControlPointChangeModeSuccess -> { scope.launch { - setDistanceMode(deviceId, data.mode.toDistanceMode()) updateNewRequestStatus(deviceId, RequestStatus.SUCCESS) } } @@ -174,7 +153,6 @@ object DFSRepository { is ControlPointCheckModeSuccess -> { scope.launch { - setDistanceMode(deviceId, data.mode.toDistanceMode()) updateNewRequestStatus(deviceId, RequestStatus.SUCCESS) } } @@ -187,28 +165,10 @@ object DFSRepository { DFSManager.checkForCurrentDistanceMode(deviceId) } - - fun updateDistanceRange(deviceId: String, range: Range) { + fun updateDistanceRange(deviceId: String, range: IntRange) { _dataMap[deviceId]?.update { it.copy(distanceRange = range) } } - /** - * Update section to the sensor data. - */ - fun updateDetailsSection(deviceId: String, section: MeasurementSection) { - _dataMap[deviceId]?.update { serviceData -> - serviceData.copy( - data = serviceData.data.mapValues { (key, sensorData) -> - if (key == serviceData.selectedDevice) { - sensorData.copy(selectedMeasurementSection = section) - } else { - sensorData - } - } - ) - } - } - suspend fun checkAvailableFeatures(deviceId: String) { DFSManager.checkAvailableFeatures(deviceId) } diff --git a/settings.gradle.kts b/settings.gradle.kts index 7d46fc650..7ac55beca 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -52,11 +52,11 @@ dependencyResolutionManagement { versionCatalogs { // Use Nordic Gradle Version Catalog with common external libraries versions. create("libs") { - from("no.nordicsemi.android.gradle:version-catalog:2.11") + from("no.nordicsemi.android.gradle:version-catalog:2.12-2") } // Fixed versions for Nordic libraries. create("nordic") { - from("no.nordicsemi.android:version-catalog:2025.11.02") + from("no.nordicsemi.android:version-catalog:2025.12.00") } } }