Add HRS service
@@ -51,8 +51,10 @@ dependencies {
|
|||||||
//Hilt requires to implement every module in the main app module
|
//Hilt requires to implement every module in the main app module
|
||||||
//https://github.com/google/dagger/issues/2123
|
//https://github.com/google/dagger/issues/2123
|
||||||
implementation project(":feature_csc")
|
implementation project(":feature_csc")
|
||||||
implementation project(":lib_theme")
|
implementation project(":feature_hrs")
|
||||||
implementation project(':feature_scanner')
|
implementation project(':feature_scanner')
|
||||||
|
implementation project(":lib_theme")
|
||||||
|
implementation project(":lib_utils")
|
||||||
|
|
||||||
implementation libs.nordic.ble.common
|
implementation libs.nordic.ble.common
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package no.nordicsemi.android.nrftoolbox
|
||||||
|
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
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.CircleShape
|
||||||
|
import androidx.compose.material.Button
|
||||||
|
import androidx.compose.material.ButtonDefaults
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import no.nordicsemi.android.theme.NordicColors
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FeatureButton(@DrawableRes iconId: Int, @StringRes nameId: Int, onClick: () -> Unit) {
|
||||||
|
Button(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
onClick = { onClick() },
|
||||||
|
colors = ButtonDefaults.buttonColors(backgroundColor = NordicColors.NordicGray4.value()),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(iconId),
|
||||||
|
contentDescription = stringResource(id = nameId),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(64.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color.White)
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = nameId),
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,86 +1,105 @@
|
|||||||
package no.nordicsemi.android.nrftoolbox
|
package no.nordicsemi.android.nrftoolbox
|
||||||
|
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.activity.OnBackPressedCallback
|
||||||
import androidx.annotation.StringRes
|
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
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.CircleShape
|
|
||||||
import androidx.compose.material.Button
|
|
||||||
import androidx.compose.material.ButtonDefaults
|
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
import androidx.compose.material.TopAppBar
|
import androidx.compose.material.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.navigation.NavController
|
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import no.nordicsemi.android.csc.CSCRoute
|
import no.nordicsemi.android.csc.view.CscScreen
|
||||||
|
import no.nordicsemi.android.hrs.view.HRSScreen
|
||||||
|
import no.nordicsemi.android.scanner.view.BluetoothNotAvailableScreen
|
||||||
|
import no.nordicsemi.android.scanner.view.BluetoothNotEnabledScreen
|
||||||
|
import no.nordicsemi.android.scanner.view.RequestPermissionScreen
|
||||||
|
import no.nordicsemi.android.scanner.view.ScanDeviceScreen
|
||||||
|
import no.nordicsemi.android.scanner.view.ScanDeviceScreenResult
|
||||||
|
import no.nordicsemi.android.utils.exhaustive
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen() {
|
fun HomeScreen() {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
|
|
||||||
NavHost(navController = navController, startDestination = "home") {
|
val viewModel = hiltViewModel<NavigationViewModel>()
|
||||||
composable("home") { HomeView(navController) }
|
val continueAction: () -> Unit = { viewModel.finish() }
|
||||||
composable("csc-route") { CSCRoute() }
|
val state = viewModel.state.collectAsState().value
|
||||||
|
|
||||||
|
BackHandler { viewModel.navigateUp() }
|
||||||
|
|
||||||
|
NavHost(navController = navController, startDestination = NavDestination.HOME.id) {
|
||||||
|
composable(NavDestination.HOME.id) { HomeView { viewModel.navigate(it) } }
|
||||||
|
composable(NavDestination.CSC.id) { CscScreen { viewModel.navigateUp() } }
|
||||||
|
composable(NavDestination.HRS.id) { HRSScreen { viewModel.navigateUp() } }
|
||||||
|
composable(NavDestination.REQUEST_PERMISSION.id) { RequestPermissionScreen(continueAction) }
|
||||||
|
composable(NavDestination.BLUETOOTH_NOT_AVAILABLE.id) { BluetoothNotAvailableScreen() }
|
||||||
|
composable(NavDestination.BLUETOOTH_NOT_ENABLED.id) {
|
||||||
|
BluetoothNotEnabledScreen(continueAction)
|
||||||
|
}
|
||||||
|
composable(NavDestination.DEVICE_NOT_CONNECTED.id) {
|
||||||
|
ScanDeviceScreen {
|
||||||
|
when (it) {
|
||||||
|
ScanDeviceScreenResult.SUCCESS -> viewModel.finish()
|
||||||
|
ScanDeviceScreenResult.CANCEL -> viewModel.navigateUp()
|
||||||
|
}.exhaustive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(state) {
|
||||||
|
navController.navigate(state.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeView(navHostController: NavController) {
|
fun HomeView(callback: (NavDestination) -> Unit) {
|
||||||
Column {
|
Column {
|
||||||
TopAppBar(title = { Text(text = stringResource(id = R.string.app_name)) })
|
TopAppBar(title = { Text(text = stringResource(id = R.string.app_name)) })
|
||||||
|
|
||||||
FeatureButton(R.drawable.ic_csc, R.string.csc_module) { navHostController.navigate("csc-route") }
|
FeatureButton(R.drawable.ic_csc, R.string.csc_module) { callback(NavDestination.CSC) }
|
||||||
|
FeatureButton(R.drawable.ic_hrs, R.string.hrs_module) { callback(NavDestination.HRS) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun FeatureButton(@DrawableRes iconId: Int, @StringRes nameId: Int, onClick: () -> Unit) {
|
private fun BackHandler(enabled: Boolean = true, onBack: () -> Unit) {
|
||||||
Button(
|
val currentOnBack = rememberUpdatedState(onBack)
|
||||||
modifier = Modifier.fillMaxWidth(),
|
val backCallback = remember {
|
||||||
onClick = { onClick() },
|
object : OnBackPressedCallback(enabled) {
|
||||||
colors = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent)
|
override fun handleOnBackPressed() {
|
||||||
) {
|
currentOnBack.value()
|
||||||
Image(
|
|
||||||
painter = painterResource(iconId),
|
|
||||||
contentDescription = stringResource(id = nameId),
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(64.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(Color.White)
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(16.dp)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = nameId),
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SideEffect {
|
||||||
|
backCallback.isEnabled = enabled
|
||||||
|
}
|
||||||
|
val backDispatcher = checkNotNull(LocalOnBackPressedDispatcherOwner.current) {
|
||||||
|
"No OnBackPressedDispatcherOwner was provided via LocalOnBackPressedDispatcherOwner"
|
||||||
|
}.onBackPressedDispatcher
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
DisposableEffect(lifecycleOwner, backDispatcher) {
|
||||||
|
backDispatcher.addCallback(lifecycleOwner, backCallback)
|
||||||
|
onDispose {
|
||||||
|
backCallback.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
fun DefaultPreview() {
|
fun DefaultPreview() {
|
||||||
HomeView(rememberNavController())
|
HomeView { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package no.nordicsemi.android.nrftoolbox
|
||||||
|
|
||||||
|
enum class NavDestination(val id: String) {
|
||||||
|
HOME("home-screen"),
|
||||||
|
CSC("csc-screen"),
|
||||||
|
HRS("hrs-screen"),
|
||||||
|
REQUEST_PERMISSION("request-permission"),
|
||||||
|
BLUETOOTH_NOT_AVAILABLE("bluetooth-not-available"),
|
||||||
|
BLUETOOTH_NOT_ENABLED("bluetooth-not-enabled"),
|
||||||
|
DEVICE_NOT_CONNECTED("device-not-connected"),
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package no.nordicsemi.android.nrftoolbox
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import no.nordicsemi.android.scanner.tools.NordicBleScanner
|
||||||
|
import no.nordicsemi.android.scanner.tools.PermissionHelper
|
||||||
|
import no.nordicsemi.android.scanner.tools.ScannerStatus
|
||||||
|
import no.nordicsemi.android.service.SelectedBluetoothDeviceHolder
|
||||||
|
import no.nordicsemi.android.scanner.viewmodel.BluetoothPermissionState
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class NavigationViewModel @Inject constructor(
|
||||||
|
private val bleScanner: NordicBleScanner,
|
||||||
|
private val permissionHelper: PermissionHelper,
|
||||||
|
private val selectedDevice: no.nordicsemi.android.service.SelectedBluetoothDeviceHolder
|
||||||
|
): ViewModel() {
|
||||||
|
|
||||||
|
val state= MutableStateFlow(NavDestination.HOME)
|
||||||
|
private var targetDestination = NavDestination.HOME
|
||||||
|
|
||||||
|
fun navigate(destination: NavDestination) {
|
||||||
|
targetDestination = destination
|
||||||
|
navigateToNextScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun navigateUp() {
|
||||||
|
targetDestination = NavDestination.HOME
|
||||||
|
state.value = NavDestination.HOME
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finish() {
|
||||||
|
if (state.value != targetDestination) {
|
||||||
|
navigateToNextScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getBluetoothState(): BluetoothPermissionState {
|
||||||
|
return if (!permissionHelper.isRequiredPermissionGranted()) {
|
||||||
|
BluetoothPermissionState.PERMISSION_REQUIRED
|
||||||
|
} else when (bleScanner.getBluetoothStatus()) {
|
||||||
|
ScannerStatus.NOT_AVAILABLE -> BluetoothPermissionState.BLUETOOTH_NOT_AVAILABLE
|
||||||
|
ScannerStatus.DISABLED -> BluetoothPermissionState.BLUETOOTH_NOT_ENABLED
|
||||||
|
ScannerStatus.ENABLED -> selectedDevice.device?.let { BluetoothPermissionState.READY } ?: BluetoothPermissionState.DEVICE_NOT_CONNECTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun navigateToNextScreen() {
|
||||||
|
state.value = when (getBluetoothState()) {
|
||||||
|
BluetoothPermissionState.PERMISSION_REQUIRED -> NavDestination.REQUEST_PERMISSION
|
||||||
|
BluetoothPermissionState.BLUETOOTH_NOT_AVAILABLE -> NavDestination.BLUETOOTH_NOT_AVAILABLE
|
||||||
|
BluetoothPermissionState.BLUETOOTH_NOT_ENABLED -> NavDestination.BLUETOOTH_NOT_ENABLED
|
||||||
|
BluetoothPermissionState.DEVICE_NOT_CONNECTED -> NavDestination.DEVICE_NOT_CONNECTED
|
||||||
|
BluetoothPermissionState.READY -> targetDestination
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
app/src/main/res/drawable/ic_hrs.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<vector android:height="80dp" android:viewportHeight="1024"
|
||||||
|
android:viewportWidth="1024" android:width="80dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<path android:fillColor="#00B3DC" android:pathData="M863.6,210.5c-42.2,-42.2 -98.4,-65.5 -158.2,-65.5c-57.2,0 -111.7,21.6 -153.3,60.8c-16.9,16 -31.1,34.2 -42.2,54c-11.1,-19.9 -25.2,-38.1 -42.2,-54c-41.6,-39.2 -96.1,-60.8 -153.3,-60.8c-59.7,0 -115.9,23.3 -158.2,65.5c-42.2,42.2 -65.5,98.4 -65.5,158.2c0,41.7 11.5,82.3 33.4,117.6c0.3,0.5 0.6,1.1 1,1.6C200,607.1 476.7,896 488.4,908.3c5.6,5.8 13.1,8.8 20.6,8.8c0.3,0 0.6,0 0.9,0c0.3,0 0.6,0 0.9,0c7.5,0 15,-2.9 20.6,-8.8c11.8,-12.3 288.5,-301.2 363.3,-420.5c0.3,-0.5 0.7,-1 1,-1.5c21.9,-35.3 33.4,-76 33.4,-117.6C929.1,308.9 905.9,252.7 863.6,210.5zM846.9,456.9C846.8,456.9 846.8,456.9 846.9,456.9c-0.1,0.1 -0.1,0.1 -0.1,0.1c-60.1,96.2 -271.2,321.8 -336.9,391.2c-65.7,-69.4 -276.8,-295 -336.8,-391.2c0,0 0,0 0,0c0,0 0,-0.1 -0.1,-0.1c-16.5,-26.4 -25.2,-56.9 -25.2,-88.2c0,-91.9 74.8,-166.7 166.7,-166.7c87.7,0 160.7,68.5 166.3,155.9c1,15.3 13.9,27.1 29.2,26.7c15.3,0.4 28.2,-11.3 29.2,-26.7c5.6,-87.4 78.6,-155.9 166.3,-155.9c91.9,0 166.7,74.8 166.7,166.7C872.1,399.9 863.4,430.4 846.9,456.9z"/>
|
||||||
|
</vector>
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@drawable/ic_launcher_background" />
|
|
||||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
|
||||||
</adaptive-icon>
|
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 982 B |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
@@ -1,5 +1,4 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">nRF Toolbox</string>
|
|
||||||
|
|
||||||
<string name="csc_module">CSC</string>
|
<string name="csc_module">CSC</string>
|
||||||
|
<string name="hrs_module">HRS</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -4,7 +4,6 @@ apply plugin: 'kotlin-parcelize'
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation project(":lib_service")
|
implementation project(":lib_service")
|
||||||
implementation project(":lib_theme")
|
implementation project(":lib_theme")
|
||||||
implementation project(':feature_scanner')
|
|
||||||
implementation project(":lib_utils")
|
implementation project(":lib_utils")
|
||||||
|
|
||||||
implementation libs.nordic.ble.common
|
implementation libs.nordic.ble.common
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
package="no.nordicsemi.android.csc">
|
package="no.nordicsemi.android.csc">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
|
|
||||||
tools:ignore="CoarseFineLocation" />
|
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
|
||||||
<application>
|
<application>
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
package no.nordicsemi.android.csc
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.navigation.compose.NavHost
|
|
||||||
import androidx.navigation.compose.composable
|
|
||||||
import androidx.navigation.compose.rememberNavController
|
|
||||||
import no.nordicsemi.android.csc.view.CscScreen
|
|
||||||
import no.nordicsemi.android.scanner.ScannerRoute
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun CSCRoute() {
|
|
||||||
|
|
||||||
val navController = rememberNavController()
|
|
||||||
|
|
||||||
NavHost(navController = navController, startDestination = "csc_screen") {
|
|
||||||
composable("csc_screen") { CscScreen(navController) }
|
|
||||||
composable("scanner-destination") { ScannerRoute(navController) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,10 +4,10 @@ import android.bluetooth.BluetoothDevice
|
|||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
sealed class CSCServiceEvent : Parcelable
|
internal sealed class CSCServiceEvent : Parcelable
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class OnDistanceChangedEvent(
|
internal data class OnDistanceChangedEvent(
|
||||||
val bluetoothDevice: BluetoothDevice,
|
val bluetoothDevice: BluetoothDevice,
|
||||||
val speed: Float,
|
val speed: Float,
|
||||||
val distance: Float,
|
val distance: Float,
|
||||||
@@ -15,14 +15,14 @@ data class OnDistanceChangedEvent(
|
|||||||
) : CSCServiceEvent()
|
) : CSCServiceEvent()
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class CrankDataChanged(
|
internal data class CrankDataChanged(
|
||||||
val bluetoothDevice: BluetoothDevice,
|
val bluetoothDevice: BluetoothDevice,
|
||||||
val crankCadence: Int,
|
val crankCadence: Int,
|
||||||
val gearRatio: Float
|
val gearRatio: Float
|
||||||
) : CSCServiceEvent()
|
) : CSCServiceEvent()
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class OnBatteryLevelChanged(
|
internal data class OnBatteryLevelChanged(
|
||||||
val device: BluetoothDevice,
|
val device: BluetoothDevice,
|
||||||
val batteryLevel: Int
|
val batteryLevel: Int
|
||||||
) : CSCServiceEvent()
|
) : CSCServiceEvent()
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class CSCDataReadBroadcast @Inject constructor() : BluetoothDataReadBroadcast<CSCServiceEvent>() {
|
internal class CSCDataReadBroadcast @Inject constructor() : BluetoothDataReadBroadcast<CSCServiceEvent>() {
|
||||||
|
|
||||||
private val _wheelSize = MutableSharedFlow<Int>(
|
private val _wheelSize = MutableSharedFlow<Int>(
|
||||||
replay = 1,
|
replay = 1,
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ import no.nordicsemi.android.log.LogContract
|
|||||||
import no.nordicsemi.android.service.BatteryManager
|
import no.nordicsemi.android.service.BatteryManager
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
|
/** Cycling Speed and Cadence service UUID. */
|
||||||
|
private val CYCLING_SPEED_AND_CADENCE_SERVICE_UUID = UUID.fromString("00001816-0000-1000-8000-00805f9b34fb")
|
||||||
|
|
||||||
|
/** Cycling Speed and Cadence Measurement characteristic UUID. */
|
||||||
|
private val CSC_MEASUREMENT_CHARACTERISTIC_UUID = UUID.fromString("00002A5B-0000-1000-8000-00805f9b34fb")
|
||||||
|
|
||||||
internal class CSCManager(context: Context) : BatteryManager<CSCManagerCallbacks>(context) {
|
internal class CSCManager(context: Context) : BatteryManager<CSCManagerCallbacks>(context) {
|
||||||
|
|
||||||
private var cscMeasurementCharacteristic: BluetoothGattCharacteristic? = null
|
private var cscMeasurementCharacteristic: BluetoothGattCharacteristic? = null
|
||||||
@@ -114,14 +120,4 @@ internal class CSCManager(context: Context) : BatteryManager<CSCManagerCallbacks
|
|||||||
|
|
||||||
override fun onServicesInvalidated() {}
|
override fun onServicesInvalidated() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
|
||||||
/** Cycling Speed and Cadence service UUID. */
|
|
||||||
val CYCLING_SPEED_AND_CADENCE_SERVICE_UUID =
|
|
||||||
UUID.fromString("00001816-0000-1000-8000-00805f9b34fb")
|
|
||||||
|
|
||||||
/** Cycling Speed and Cadence Measurement characteristic UUID. */
|
|
||||||
private val CSC_MEASUREMENT_CHARACTERISTIC_UUID =
|
|
||||||
UUID.fromString("00002A5B-0000-1000-8000-00805f9b34fb")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
package no.nordicsemi.android.csc.view
|
|
||||||
|
|
||||||
internal sealed class CSCViewState {
|
|
||||||
|
|
||||||
fun ensureConnectedState(): CSCViewConnectedState {
|
|
||||||
return (this as? CSCViewConnectedState)
|
|
||||||
?: throw IllegalStateException("Wrong state. Device not connected.")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun ensureDisconnectedState(): CSCViewNotConnectedState {
|
|
||||||
return (this as? CSCViewNotConnectedState)
|
|
||||||
?: throw IllegalStateException("Wrong state. Device should be connected.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//TODO("Change to navigation")
|
|
||||||
internal data class CSCViewNotConnectedState(
|
|
||||||
val showScannerDialog: Boolean = false
|
|
||||||
) : CSCViewState()
|
|
||||||
|
|
||||||
internal data class CSCViewConnectedState(
|
|
||||||
val showDialog: Boolean = false,
|
|
||||||
val scanDevices: Boolean = false,
|
|
||||||
val selectedSpeedUnit: SpeedUnit = SpeedUnit.M_S,
|
|
||||||
val speed: Float = 0f,
|
|
||||||
val cadence: Int = 0,
|
|
||||||
val distance: Float = 0f,
|
|
||||||
val totalDistance: Float = 0f,
|
|
||||||
val gearRatio: Float = 0f,
|
|
||||||
val batteryLevel: Int = 0,
|
|
||||||
val wheelSize: String = CSCSettings.DefaultWheelSize.NAME
|
|
||||||
) : CSCViewState() {
|
|
||||||
|
|
||||||
fun displaySpeed(): String {
|
|
||||||
return speed.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun displayCadence(): String {
|
|
||||||
return cadence.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun displayDistance(): String {
|
|
||||||
return distance.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun displayTotalDistance(): String {
|
|
||||||
return totalDistance.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun displayBatteryLever(): String {
|
|
||||||
return batteryLevel.toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
package no.nordicsemi.android.csc.view
|
package no.nordicsemi.android.csc.view
|
||||||
|
|
||||||
import android.bluetooth.BluetoothDevice
|
|
||||||
|
|
||||||
internal sealed class CSCViewEvent
|
internal sealed class CSCViewEvent
|
||||||
|
|
||||||
internal object OnShowEditWheelSizeDialogButtonClick : CSCViewEvent()
|
internal object OnShowEditWheelSizeDialogButtonClick : CSCViewEvent()
|
||||||
@@ -11,9 +9,3 @@ internal data class OnWheelSizeSelected(val wheelSize: Int, val wheelSizeDisplay
|
|||||||
internal data class OnSelectedSpeedUnitSelected(val selectedSpeedUnit: SpeedUnit) : CSCViewEvent()
|
internal data class OnSelectedSpeedUnitSelected(val selectedSpeedUnit: SpeedUnit) : CSCViewEvent()
|
||||||
|
|
||||||
internal object OnDisconnectButtonClick : CSCViewEvent()
|
internal object OnDisconnectButtonClick : CSCViewEvent()
|
||||||
|
|
||||||
internal object OnConnectButtonClick : CSCViewEvent()
|
|
||||||
|
|
||||||
internal object OnMovedToScannerScreen : CSCViewEvent()
|
|
||||||
|
|
||||||
internal data class OnBluetoothDeviceSelected(val device: BluetoothDevice) : CSCViewEvent()
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package no.nordicsemi.android.csc.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.Button
|
||||||
|
import androidx.compose.material.ButtonDefaults
|
||||||
|
import androidx.compose.material.Card
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
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.csc.R
|
||||||
|
import no.nordicsemi.android.csc.viewmodel.CSCViewState
|
||||||
|
import no.nordicsemi.android.theme.NordicColors
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun ContentView(state: CSCViewState, onEvent: (CSCViewEvent) -> Unit) {
|
||||||
|
if (state.showDialog) {
|
||||||
|
SelectWheelSizeDialog { onEvent(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
SettingsSection(state, onEvent)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
SensorsReadingView(state = state)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.secondary),
|
||||||
|
onClick = { onEvent(OnDisconnectButtonClick) }
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = R.string.disconnect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsSection(state: CSCViewState, onEvent: (CSCViewEvent) -> Unit) {
|
||||||
|
Card(
|
||||||
|
backgroundColor = NordicColors.NordicGray4.value(),
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
elevation = 0.dp
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
WheelSizeView(state, onEvent)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
SpeedUnitRadioGroup(state.selectedSpeedUnit) { onEvent(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun ConnectedPreview() {
|
||||||
|
ContentView(CSCViewState()) { }
|
||||||
|
}
|
||||||
@@ -1,143 +1,52 @@
|
|||||||
package no.nordicsemi.android.csc.view
|
package no.nordicsemi.android.csc.view
|
||||||
|
|
||||||
import android.bluetooth.BluetoothDevice
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material.Button
|
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
import androidx.compose.material.TopAppBar
|
import androidx.compose.material.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.livedata.observeAsState
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
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.navigation.compose.hiltViewModel
|
||||||
import androidx.navigation.NavController
|
|
||||||
import no.nordicsemi.android.csc.R
|
import no.nordicsemi.android.csc.R
|
||||||
import no.nordicsemi.android.csc.service.CSCService
|
import no.nordicsemi.android.csc.service.CSCService
|
||||||
|
import no.nordicsemi.android.csc.viewmodel.CSCViewState
|
||||||
import no.nordicsemi.android.csc.viewmodel.CscViewModel
|
import no.nordicsemi.android.csc.viewmodel.CscViewModel
|
||||||
import no.nordicsemi.android.utils.exhaustive
|
|
||||||
import no.nordicsemi.android.utils.isServiceRunning
|
import no.nordicsemi.android.utils.isServiceRunning
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun CscScreen(navController: NavController, viewModel: CscViewModel = hiltViewModel()) {
|
fun CscScreen(finishAction: () -> Unit) {
|
||||||
|
val viewModel: CscViewModel = hiltViewModel()
|
||||||
val secondScreenResult = navController.currentBackStackEntry
|
|
||||||
?.savedStateHandle
|
|
||||||
?.getLiveData<BluetoothDevice>("result")?.observeAsState()
|
|
||||||
|
|
||||||
secondScreenResult?.value?.let {
|
|
||||||
viewModel.onEvent(OnBluetoothDeviceSelected(it))
|
|
||||||
|
|
||||||
navController.currentBackStackEntry
|
|
||||||
?.savedStateHandle
|
|
||||||
?.set("result", null)
|
|
||||||
}
|
|
||||||
|
|
||||||
val state = viewModel.state.collectAsState().value
|
val state = viewModel.state.collectAsState().value
|
||||||
|
|
||||||
CSCView(navController, state) { viewModel.onEvent(it) }
|
val context = LocalContext.current
|
||||||
|
LaunchedEffect(state.isScreenActive) {
|
||||||
|
if (!state.isScreenActive) {
|
||||||
|
finishAction()
|
||||||
|
}
|
||||||
|
if (context.isServiceRunning(CSCService::class.java.name)) {
|
||||||
|
val intent = Intent(context, CSCService::class.java)
|
||||||
|
context.stopService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect("start-service") {
|
||||||
|
if (!context.isServiceRunning(CSCService::class.java.name)) {
|
||||||
|
val intent = Intent(context, CSCService::class.java)
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CSCView(state) { viewModel.onEvent(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CSCView(navController: NavController, state: CSCViewState, onEvent: (CSCViewEvent) -> Unit) {
|
private fun CSCView(state: CSCViewState, onEvent: (CSCViewEvent) -> Unit) {
|
||||||
Column {
|
Column {
|
||||||
TopAppBar(title = { Text(text = stringResource(id = R.string.csc_title)) })
|
TopAppBar(title = { Text(text = stringResource(id = R.string.csc_title)) })
|
||||||
|
|
||||||
when (state) {
|
ContentView(state) { onEvent(it) }
|
||||||
is CSCViewConnectedState -> ConnectedView(state) { onEvent(it) }
|
|
||||||
is CSCViewNotConnectedState -> NotConnectedScreen(navController, state) {
|
|
||||||
onEvent(it)
|
|
||||||
}
|
|
||||||
}.exhaustive
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun NotConnectedScreen(
|
|
||||||
navController: NavController,
|
|
||||||
state: CSCViewNotConnectedState,
|
|
||||||
onEvent: (CSCViewEvent) -> Unit
|
|
||||||
) {
|
|
||||||
if (state.showScannerDialog) {
|
|
||||||
navController.navigate("scanner-destination")
|
|
||||||
onEvent(OnMovedToScannerScreen)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (LocalContext.current.isServiceRunning(CSCService::class.java.name)) {
|
|
||||||
val intent = Intent(LocalContext.current, CSCService::class.java)
|
|
||||||
LocalContext.current.stopService(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
NotConnectedView(onEvent)
|
|
||||||
|
|
||||||
LocalContext.current.stopService(Intent(LocalContext.current, CSCService::class.java))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun NotConnectedView(
|
|
||||||
onEvent: (CSCViewEvent) -> Unit
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.fillMaxHeight(),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Text(text = stringResource(id = R.string.csc_no_connection))
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
Button(onClick = { onEvent(OnConnectButtonClick) }) {
|
|
||||||
Text(text = stringResource(id = R.string.csc_connect))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ConnectedView(state: CSCViewConnectedState, onEvent: (CSCViewEvent) -> Unit) {
|
|
||||||
if (state.showDialog) {
|
|
||||||
SelectWheelSizeDialog { onEvent(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!LocalContext.current.isServiceRunning(CSCService::class.java.name)) {
|
|
||||||
val intent = Intent(LocalContext.current, CSCService::class.java)
|
|
||||||
LocalContext.current.startService(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
WheelSizeView(state, onEvent)
|
|
||||||
|
|
||||||
SpeedUnitRadioGroup(state.selectedSpeedUnit) { onEvent(it) }
|
|
||||||
|
|
||||||
SensorsReadingView(state = state)
|
|
||||||
|
|
||||||
Button(onClick = { onEvent(OnDisconnectButtonClick) }) {
|
|
||||||
Text(text = stringResource(id = R.string.csc_disconnect))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
private fun NotConnectedPreview() {
|
|
||||||
NotConnectedView { }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
private fun ConnectedPreview() {
|
|
||||||
ConnectedView(CSCViewConnectedState()) { }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
package no.nordicsemi.android.csc.view
|
package no.nordicsemi.android.csc.view
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
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.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.Card
|
||||||
|
import androidx.compose.material.TabRowDefaults.Divider
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringArrayResource
|
import androidx.compose.ui.res.stringArrayResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import no.nordicsemi.android.csc.R
|
import no.nordicsemi.android.csc.R
|
||||||
import no.nordicsemi.android.theme.Background
|
import no.nordicsemi.android.theme.NordicColors
|
||||||
|
import no.nordicsemi.android.theme.NordicColors.NordicLightGray
|
||||||
import no.nordicsemi.android.theme.TestTheme
|
import no.nordicsemi.android.theme.TestTheme
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -27,13 +38,47 @@ private fun SelectWheelSizeView(onEvent: (OnWheelSizeSelected) -> Unit) {
|
|||||||
val wheelEntries = stringArrayResource(R.array.wheel_entries)
|
val wheelEntries = stringArrayResource(R.array.wheel_entries)
|
||||||
val wheelValues = stringArrayResource(R.array.wheel_values)
|
val wheelValues = stringArrayResource(R.array.wheel_values)
|
||||||
|
|
||||||
Box(Modifier.padding(16.dp)) {
|
Card(
|
||||||
Column(modifier = Background.whiteRoundedCorners()) {
|
modifier = Modifier.height(300.dp),
|
||||||
Text(text = "Wheel size")
|
backgroundColor = NordicColors.NordicGray4.value(),
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
elevation = 0.dp
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Wheel size",
|
||||||
|
fontSize = 28.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
|
||||||
wheelEntries.forEachIndexed { i, entry ->
|
wheelEntries.forEachIndexed { i, entry ->
|
||||||
Text(text = entry, modifier = Modifier.clickable {
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = entry,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable {
|
||||||
onEvent(OnWheelSizeSelected(wheelValues[i].toInt(), entry))
|
onEvent(OnWheelSizeSelected(wheelValues[i].toInt(), entry))
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (i != wheelEntries.size - 1) {
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Divider(color = NordicLightGray.value(), thickness = 1.dp/2)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +1,52 @@
|
|||||||
package no.nordicsemi.android.csc.view
|
package no.nordicsemi.android.csc.view
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.Card
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import no.nordicsemi.android.csc.R
|
import no.nordicsemi.android.csc.R
|
||||||
import no.nordicsemi.android.theme.Background
|
import no.nordicsemi.android.csc.viewmodel.CSCViewState
|
||||||
|
import no.nordicsemi.android.theme.NordicColors
|
||||||
|
import no.nordicsemi.android.theme.view.BatteryLevelView
|
||||||
|
import no.nordicsemi.android.theme.view.KeyValueField
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SensorsReadingView(state: CSCViewConnectedState) {
|
internal fun SensorsReadingView(state: CSCViewState) {
|
||||||
Column {
|
Card(
|
||||||
Column(modifier = Background.whiteRoundedCorners()) {
|
backgroundColor = NordicColors.NordicGray4.value(),
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
elevation = 0.dp
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
KeyValueField(stringResource(id = R.string.scs_field_speed), state.displaySpeed())
|
KeyValueField(stringResource(id = R.string.scs_field_speed), state.displaySpeed())
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
KeyValueField(stringResource(id = R.string.scs_field_cadence), state.displayCadence())
|
KeyValueField(stringResource(id = R.string.scs_field_cadence), state.displayCadence())
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
KeyValueField(stringResource(id = R.string.scs_field_distance), state.displayDistance())
|
KeyValueField(stringResource(id = R.string.scs_field_distance), state.displayDistance())
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
KeyValueField(
|
KeyValueField(
|
||||||
stringResource(id = R.string.scs_field_total_distance),
|
stringResource(id = R.string.scs_field_total_distance),
|
||||||
state.displayTotalDistance()
|
state.displayTotalDistance()
|
||||||
)
|
)
|
||||||
KeyValueField(stringResource(id = R.string.scs_field_gear_ratio), state.displaySpeed())
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
KeyValueField(stringResource(id = R.string.scs_field_gear_ratio), state.displayGearRatio())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
Column(modifier = Background.whiteRoundedCorners()) {
|
BatteryLevelView(state.batteryLevel)
|
||||||
KeyValueField(stringResource(id = R.string.scs_field_battery), state.displayBatteryLever())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun KeyValueField(key: String, value: String) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
Text(text = key)
|
|
||||||
Text(text = value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
private fun Preview() {
|
private fun Preview() {
|
||||||
SensorsReadingView(CSCViewConnectedState())
|
SensorsReadingView(CSCViewState())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package no.nordicsemi.android.csc.view
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material.RadioButton
|
import androidx.compose.material.RadioButton
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -16,7 +18,7 @@ internal fun SpeedUnitRadioGroup(
|
|||||||
onEvent: (OnSelectedSpeedUnitSelected) -> Unit
|
onEvent: (OnSelectedSpeedUnitSelected) -> Unit
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
) {
|
) {
|
||||||
SpeedUnitRadioButton(currentUnit, SpeedUnit.KM_H, onEvent)
|
SpeedUnitRadioButton(currentUnit, SpeedUnit.KM_H, onEvent)
|
||||||
@@ -36,6 +38,7 @@ internal fun SpeedUnitRadioButton(
|
|||||||
selected = (selectedUnit == displayedUnit),
|
selected = (selectedUnit == displayedUnit),
|
||||||
onClick = { onEvent(OnSelectedSpeedUnitSelected(displayedUnit)) }
|
onClick = { onEvent(OnSelectedSpeedUnitSelected(displayedUnit)) }
|
||||||
)
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Text(text = createSpeedUnitLabel(displayedUnit))
|
Text(text = createSpeedUnitLabel(displayedUnit))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import no.nordicsemi.android.csc.R
|
import no.nordicsemi.android.csc.R
|
||||||
|
import no.nordicsemi.android.csc.viewmodel.CSCViewState
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun WheelSizeView(state: CSCViewConnectedState, onEvent: (CSCViewEvent) -> Unit) {
|
internal fun WheelSizeView(state: CSCViewState, onEvent: (CSCViewEvent) -> Unit) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
value = state.wheelSize,
|
value = state.wheelSize,
|
||||||
@@ -35,5 +36,5 @@ private fun EditIcon(onEvent: (CSCViewEvent) -> Unit) {
|
|||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
private fun WheelSizeViewPreview() {
|
private fun WheelSizeViewPreview() {
|
||||||
WheelSizeView(CSCViewConnectedState()) { }
|
WheelSizeView(CSCViewState()) { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package no.nordicsemi.android.csc.viewmodel
|
||||||
|
|
||||||
|
import no.nordicsemi.android.csc.view.CSCSettings
|
||||||
|
import no.nordicsemi.android.csc.view.SpeedUnit
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
internal data class CSCViewState(
|
||||||
|
val showDialog: Boolean = false,
|
||||||
|
val scanDevices: Boolean = false,
|
||||||
|
val selectedSpeedUnit: SpeedUnit = SpeedUnit.M_S,
|
||||||
|
val speed: Float = 0f,
|
||||||
|
val cadence: Int = 0,
|
||||||
|
val distance: Float = 0f,
|
||||||
|
val totalDistance: Float = 0f,
|
||||||
|
val gearRatio: Float = 0f,
|
||||||
|
val batteryLevel: Int = 0,
|
||||||
|
val wheelSize: String = CSCSettings.DefaultWheelSize.NAME,
|
||||||
|
val isScreenActive: Boolean = true
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val speedWithUnit = when (selectedSpeedUnit) {
|
||||||
|
SpeedUnit.M_S -> speed
|
||||||
|
SpeedUnit.KM_H -> speed * 3.6f
|
||||||
|
SpeedUnit.MPH -> speed * 2.2369f
|
||||||
|
}
|
||||||
|
|
||||||
|
fun displaySpeed(): String {
|
||||||
|
return when (selectedSpeedUnit) {
|
||||||
|
SpeedUnit.M_S -> String.format("%.1f m/s", speedWithUnit)
|
||||||
|
SpeedUnit.KM_H -> String.format("%.1f km/h", speedWithUnit)
|
||||||
|
SpeedUnit.MPH -> String.format("%.1f mph", speedWithUnit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun displayCadence(): String {
|
||||||
|
return String.format("%d RPM", cadence)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun displayDistance(): String {
|
||||||
|
return when (selectedSpeedUnit) {
|
||||||
|
SpeedUnit.M_S -> String.format("%.0f m", distance)
|
||||||
|
SpeedUnit.KM_H -> String.format("%.0f m", distance)
|
||||||
|
SpeedUnit.MPH -> String.format("%.0f yd", distance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun displayTotalDistance(): String {
|
||||||
|
return when (selectedSpeedUnit) {
|
||||||
|
SpeedUnit.M_S -> String.format("%.2f km", distance)
|
||||||
|
SpeedUnit.KM_H -> String.format("%.2f km", distance)
|
||||||
|
SpeedUnit.MPH -> String.format("%.2f mile", distance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun displayGearRatio(): String {
|
||||||
|
return String.format(Locale.US, "%.1f", gearRatio)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,28 +13,20 @@ import no.nordicsemi.android.csc.events.CrankDataChanged
|
|||||||
import no.nordicsemi.android.csc.events.OnBatteryLevelChanged
|
import no.nordicsemi.android.csc.events.OnBatteryLevelChanged
|
||||||
import no.nordicsemi.android.csc.events.OnDistanceChangedEvent
|
import no.nordicsemi.android.csc.events.OnDistanceChangedEvent
|
||||||
import no.nordicsemi.android.csc.service.CSCDataReadBroadcast
|
import no.nordicsemi.android.csc.service.CSCDataReadBroadcast
|
||||||
import no.nordicsemi.android.csc.view.CSCViewConnectedState
|
|
||||||
import no.nordicsemi.android.csc.view.CSCViewEvent
|
import no.nordicsemi.android.csc.view.CSCViewEvent
|
||||||
import no.nordicsemi.android.csc.view.CSCViewNotConnectedState
|
|
||||||
import no.nordicsemi.android.csc.view.CSCViewState
|
|
||||||
import no.nordicsemi.android.csc.view.OnBluetoothDeviceSelected
|
|
||||||
import no.nordicsemi.android.csc.view.OnConnectButtonClick
|
|
||||||
import no.nordicsemi.android.csc.view.OnDisconnectButtonClick
|
import no.nordicsemi.android.csc.view.OnDisconnectButtonClick
|
||||||
import no.nordicsemi.android.csc.view.OnMovedToScannerScreen
|
|
||||||
import no.nordicsemi.android.csc.view.OnSelectedSpeedUnitSelected
|
import no.nordicsemi.android.csc.view.OnSelectedSpeedUnitSelected
|
||||||
import no.nordicsemi.android.csc.view.OnShowEditWheelSizeDialogButtonClick
|
import no.nordicsemi.android.csc.view.OnShowEditWheelSizeDialogButtonClick
|
||||||
import no.nordicsemi.android.csc.view.OnWheelSizeSelected
|
import no.nordicsemi.android.csc.view.OnWheelSizeSelected
|
||||||
import no.nordicsemi.android.scanner.tools.SelectedBluetoothDeviceHolder
|
|
||||||
import no.nordicsemi.android.utils.exhaustive
|
import no.nordicsemi.android.utils.exhaustive
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
internal class CscViewModel @Inject constructor(
|
internal class CscViewModel @Inject constructor(
|
||||||
private val localBroadcast: CSCDataReadBroadcast,
|
private val localBroadcast: CSCDataReadBroadcast
|
||||||
private val deviceHolder: SelectedBluetoothDeviceHolder
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val state = MutableStateFlow(createInitialState())
|
val state = MutableStateFlow(CSCViewState())
|
||||||
|
|
||||||
init {
|
init {
|
||||||
localBroadcast.events.onEach {
|
localBroadcast.events.onEach {
|
||||||
@@ -42,10 +34,6 @@ internal class CscViewModel @Inject constructor(
|
|||||||
}.launchIn(viewModelScope)
|
}.launchIn(viewModelScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createInitialState(): CSCViewState {
|
|
||||||
return deviceHolder.device?.let { CSCViewConnectedState() } ?: CSCViewNotConnectedState()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun consumeEvent(event: CSCServiceEvent) {
|
private fun consumeEvent(event: CSCServiceEvent) {
|
||||||
val newValue = when (event) {
|
val newValue = when (event) {
|
||||||
is CrankDataChanged -> createNewState(event)
|
is CrankDataChanged -> createNewState(event)
|
||||||
@@ -55,21 +43,21 @@ internal class CscViewModel @Inject constructor(
|
|||||||
state.value = newValue
|
state.value = newValue
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createNewState(event: CrankDataChanged): CSCViewConnectedState {
|
private fun createNewState(event: CrankDataChanged): CSCViewState {
|
||||||
return state.value.ensureConnectedState().copy(
|
return state.value.copy(
|
||||||
cadence = event.crankCadence,
|
cadence = event.crankCadence,
|
||||||
gearRatio = event.gearRatio
|
gearRatio = event.gearRatio
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createNewState(event: OnBatteryLevelChanged): CSCViewConnectedState {
|
private fun createNewState(event: OnBatteryLevelChanged): CSCViewState {
|
||||||
return state.value.ensureConnectedState().copy(
|
return state.value.copy(
|
||||||
batteryLevel = event.batteryLevel
|
batteryLevel = event.batteryLevel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createNewState(event: OnDistanceChangedEvent): CSCViewConnectedState {
|
private fun createNewState(event: OnDistanceChangedEvent): CSCViewState {
|
||||||
return state.value.ensureConnectedState().copy(
|
return state.value.copy(
|
||||||
speed = event.speed,
|
speed = event.speed,
|
||||||
distance = event.distance,
|
distance = event.distance,
|
||||||
totalDistance = event.totalDistance
|
totalDistance = event.totalDistance
|
||||||
@@ -82,41 +70,26 @@ internal class CscViewModel @Inject constructor(
|
|||||||
OnShowEditWheelSizeDialogButtonClick -> onShowDialogEvent()
|
OnShowEditWheelSizeDialogButtonClick -> onShowDialogEvent()
|
||||||
is OnWheelSizeSelected -> onWheelSizeChanged(event)
|
is OnWheelSizeSelected -> onWheelSizeChanged(event)
|
||||||
OnDisconnectButtonClick -> onDisconnectButtonClick()
|
OnDisconnectButtonClick -> onDisconnectButtonClick()
|
||||||
OnConnectButtonClick -> onConnectButtonClick()
|
|
||||||
OnMovedToScannerScreen -> onOnMovedToScannerScreen()
|
|
||||||
is OnBluetoothDeviceSelected -> onBluetoothDeviceSelected()
|
|
||||||
}.exhaustive
|
}.exhaustive
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onSelectedSpeedUnit(event: OnSelectedSpeedUnitSelected) {
|
private fun onSelectedSpeedUnit(event: OnSelectedSpeedUnitSelected) {
|
||||||
state.tryEmit(state.value.ensureConnectedState().copy(selectedSpeedUnit = event.selectedSpeedUnit))
|
state.tryEmit(state.value.copy(selectedSpeedUnit = event.selectedSpeedUnit))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onShowDialogEvent() {
|
private fun onShowDialogEvent() {
|
||||||
state.tryEmit(state.value.ensureConnectedState().copy(showDialog = true))
|
state.tryEmit(state.value.copy(showDialog = true))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onWheelSizeChanged(event: OnWheelSizeSelected) {
|
private fun onWheelSizeChanged(event: OnWheelSizeSelected) {
|
||||||
localBroadcast.setWheelSize(event.wheelSize)
|
localBroadcast.setWheelSize(event.wheelSize)
|
||||||
state.tryEmit(state.value.ensureConnectedState().copy(
|
state.tryEmit(state.value.copy(
|
||||||
showDialog = false,
|
showDialog = false,
|
||||||
wheelSize = event.wheelSizeDisplayInfo
|
wheelSize = event.wheelSizeDisplayInfo
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onDisconnectButtonClick() {
|
private fun onDisconnectButtonClick() {
|
||||||
state.tryEmit(CSCViewNotConnectedState())
|
state.tryEmit(state.value.copy(isScreenActive = false))
|
||||||
}
|
|
||||||
|
|
||||||
private fun onConnectButtonClick() {
|
|
||||||
state.tryEmit(state.value.ensureDisconnectedState().copy(showScannerDialog = true))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onOnMovedToScannerScreen() {
|
|
||||||
state.tryEmit(state.value.ensureDisconnectedState().copy(showScannerDialog = false))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onBluetoothDeviceSelected() {
|
|
||||||
state.tryEmit(CSCViewConnectedState())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,11 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="csc_title">Cyclic and speed cadence</string>
|
<string name="csc_title">Cyclic and speed cadence</string>
|
||||||
|
|
||||||
<string name="csc_disconnect">Disconnect</string>
|
|
||||||
<string name="csc_no_connection">No device connected</string>
|
|
||||||
<string name="csc_connect">Connect</string>
|
|
||||||
|
|
||||||
<string name="scs_field_speed">Speed</string>
|
<string name="scs_field_speed">Speed</string>
|
||||||
<string name="scs_field_cadence">Cadence</string>
|
<string name="scs_field_cadence">Cadence</string>
|
||||||
<string name="scs_field_distance">Distance</string>
|
<string name="scs_field_distance">Distance</string>
|
||||||
<string name="scs_field_total_distance">Total Distance</string>
|
<string name="scs_field_total_distance">Total Distance</string>
|
||||||
<string name="scs_field_gear_ratio">Gear Ratio</string>
|
<string name="scs_field_gear_ratio">Gear Ratio</string>
|
||||||
<string name="scs_field_battery">Battery</string>
|
|
||||||
|
|
||||||
<string name="scs_field_wheel_size">Wheel size</string>
|
<string name="scs_field_wheel_size">Wheel size</string>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package no.nordicsemi.android.csc
|
package no.nordicsemi.android.csc
|
||||||
|
|
||||||
|
import androidx.annotation.FloatRange
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
import org.junit.Assert.*
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Example local unit test, which will execute on the development machine (host).
|
* Example local unit test, which will execute on the development machine (host).
|
||||||
*
|
*
|
||||||
@@ -12,6 +12,12 @@ import org.junit.Assert.*
|
|||||||
class ExampleUnitTest {
|
class ExampleUnitTest {
|
||||||
@Test
|
@Test
|
||||||
fun addition_isCorrect() {
|
fun addition_isCorrect() {
|
||||||
|
|
||||||
|
println("red: ${colorToHex(0f)}")
|
||||||
|
println("green: ${colorToHex(169f)}")
|
||||||
|
println("blue: ${colorToHex(206f)}")
|
||||||
assertEquals(4, 2 + 2)
|
assertEquals(4, 2 + 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun colorToHex(@FloatRange(from = 0.0, to = 1.0) value: Float) = Integer.toHexString((0xFF * value).toInt())
|
||||||
}
|
}
|
||||||
28
feature_hrs/build.gradle
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
apply from: rootProject.file("library.gradle")
|
||||||
|
apply plugin: 'kotlin-parcelize'
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation project(":lib_service")
|
||||||
|
implementation project(":lib_theme")
|
||||||
|
implementation project(":lib_utils")
|
||||||
|
|
||||||
|
implementation libs.chart
|
||||||
|
|
||||||
|
implementation libs.nordic.ble.common
|
||||||
|
|
||||||
|
implementation libs.nordic.log
|
||||||
|
|
||||||
|
implementation libs.bundles.compose
|
||||||
|
implementation libs.androidx.core
|
||||||
|
implementation libs.material
|
||||||
|
implementation libs.lifecycle.activity
|
||||||
|
implementation libs.lifecycle.service
|
||||||
|
implementation libs.compose.lifecycle
|
||||||
|
implementation libs.compose.activity
|
||||||
|
|
||||||
|
testImplementation libs.test.junit
|
||||||
|
androidTestImplementation libs.android.test.junit
|
||||||
|
androidTestImplementation libs.android.test.espresso
|
||||||
|
androidTestImplementation libs.android.test.compose.ui
|
||||||
|
debugImplementation libs.android.test.compose.tooling
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package no.nordicsemi.android.hrs
|
||||||
|
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* See [testing documentation](http://d.android.com/tools/testing).
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ExampleInstrumentedTest {
|
||||||
|
@Test
|
||||||
|
fun useAppContext() {
|
||||||
|
// Context of the app under test.
|
||||||
|
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
assertEquals("no.nordicsemi.android.hrs.test", appContext.packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
9
feature_hrs/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
package="no.nordicsemi.android.hrs">
|
||||||
|
|
||||||
|
<application>
|
||||||
|
<service android:name=".service.HRSService" />
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package no.nordicsemi.android.hrs.events
|
||||||
|
|
||||||
|
internal data class HRSAggregatedData(
|
||||||
|
val heartRates: List<Int> = emptyList(),
|
||||||
|
val batteryLevel: Int = 0,
|
||||||
|
val sensorLocation: Int = 0
|
||||||
|
)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2015, 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.hrs.service
|
||||||
|
|
||||||
|
import no.nordicsemi.android.ble.data.Data
|
||||||
|
|
||||||
|
object BodySensorLocationParser {
|
||||||
|
fun parse(data: Data): String {
|
||||||
|
val value = data.getIntValue(Data.FORMAT_UINT8, 0)!!
|
||||||
|
return when (value) {
|
||||||
|
6 -> "Foot"
|
||||||
|
5 -> "Ear Lobe"
|
||||||
|
4 -> "Hand"
|
||||||
|
3 -> "Finger"
|
||||||
|
2 -> "Wrist"
|
||||||
|
1 -> "Chest"
|
||||||
|
0 -> "Other"
|
||||||
|
else -> "Other"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package no.nordicsemi.android.hrs.service
|
||||||
|
|
||||||
|
import no.nordicsemi.android.hrs.events.HRSAggregatedData
|
||||||
|
import no.nordicsemi.android.service.BluetoothDataReadBroadcast
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
internal class HRSDataBroadcast @Inject constructor() : BluetoothDataReadBroadcast<HRSAggregatedData>()
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2015, 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.hrs.service
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothDevice
|
||||||
|
import android.bluetooth.BluetoothGatt
|
||||||
|
import android.bluetooth.BluetoothGattCharacteristic
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.annotation.IntRange
|
||||||
|
import no.nordicsemi.android.ble.common.callback.hr.BodySensorLocationDataCallback
|
||||||
|
import no.nordicsemi.android.ble.common.callback.hr.HeartRateMeasurementDataCallback
|
||||||
|
import no.nordicsemi.android.ble.common.profile.hr.BodySensorLocation
|
||||||
|
import no.nordicsemi.android.ble.data.Data
|
||||||
|
import no.nordicsemi.android.log.LogContract
|
||||||
|
import no.nordicsemi.android.service.BatteryManager
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HRSManager class performs BluetoothGatt operations for connection, service discovery,
|
||||||
|
* enabling notification and reading characteristics.
|
||||||
|
* All operations required to connect to device with BLE Heart Rate Service and reading
|
||||||
|
* heart rate values are performed here.
|
||||||
|
*/
|
||||||
|
class HRSManager(context: Context) : BatteryManager<HRSManagerCallbacks>(context) {
|
||||||
|
|
||||||
|
private var heartRateCharacteristic: BluetoothGattCharacteristic? = null
|
||||||
|
private var bodySensorLocationCharacteristic: BluetoothGattCharacteristic? = null
|
||||||
|
|
||||||
|
override fun getGattCallback(): BatteryManagerGattCallback {
|
||||||
|
return HeartRateManagerCallback()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BluetoothGatt callbacks for connection/disconnection, service discovery,
|
||||||
|
* receiving notification, etc.
|
||||||
|
*/
|
||||||
|
private inner class HeartRateManagerCallback : BatteryManagerGattCallback() {
|
||||||
|
override fun initialize() {
|
||||||
|
super.initialize()
|
||||||
|
readCharacteristic(bodySensorLocationCharacteristic)
|
||||||
|
.with(object : BodySensorLocationDataCallback() {
|
||||||
|
|
||||||
|
override fun onDataReceived(device: BluetoothDevice, data: Data) {
|
||||||
|
log(
|
||||||
|
LogContract.Log.Level.APPLICATION,
|
||||||
|
"\"" + BodySensorLocationParser.parse(data) + "\" received"
|
||||||
|
)
|
||||||
|
super.onDataReceived(device, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBodySensorLocationReceived(
|
||||||
|
device: BluetoothDevice,
|
||||||
|
@BodySensorLocation sensorLocation: Int
|
||||||
|
) {
|
||||||
|
mCallbacks?.onBodySensorLocationReceived(device, sensorLocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
})
|
||||||
|
.fail { device: BluetoothDevice?, status: Int ->
|
||||||
|
log(Log.WARN, "Body Sensor Location characteristic not found")
|
||||||
|
}
|
||||||
|
.enqueue()
|
||||||
|
|
||||||
|
setNotificationCallback(heartRateCharacteristic)
|
||||||
|
.with(object : HeartRateMeasurementDataCallback() {
|
||||||
|
|
||||||
|
override fun onDataReceived(device: BluetoothDevice, data: Data) {
|
||||||
|
log(
|
||||||
|
LogContract.Log.Level.APPLICATION,
|
||||||
|
"\"" + HeartRateMeasurementParser.parse(data) + "\" received"
|
||||||
|
)
|
||||||
|
super.onDataReceived(device, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onHeartRateMeasurementReceived(
|
||||||
|
device: BluetoothDevice,
|
||||||
|
@IntRange(from = 0) heartRate: Int,
|
||||||
|
contactDetected: Boolean?,
|
||||||
|
@IntRange(from = 0) energyExpanded: Int?,
|
||||||
|
rrIntervals: List<Int>?
|
||||||
|
) {
|
||||||
|
mCallbacks?.onHeartRateMeasurementReceived(
|
||||||
|
device,
|
||||||
|
heartRate,
|
||||||
|
contactDetected,
|
||||||
|
energyExpanded,
|
||||||
|
rrIntervals
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
enableNotifications(heartRateCharacteristic).enqueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isRequiredServiceSupported(gatt: BluetoothGatt): Boolean {
|
||||||
|
val service = gatt.getService(HR_SERVICE_UUID)
|
||||||
|
if (service != null) {
|
||||||
|
heartRateCharacteristic = service.getCharacteristic(
|
||||||
|
HEART_RATE_MEASUREMENT_CHARACTERISTIC_UUID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return heartRateCharacteristic != null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isOptionalServiceSupported(gatt: BluetoothGatt): Boolean {
|
||||||
|
super.isOptionalServiceSupported(gatt)
|
||||||
|
val service = gatt.getService(HR_SERVICE_UUID)
|
||||||
|
if (service != null) {
|
||||||
|
bodySensorLocationCharacteristic = service.getCharacteristic(
|
||||||
|
BODY_SENSOR_LOCATION_CHARACTERISTIC_UUID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return bodySensorLocationCharacteristic != null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDeviceDisconnected() {
|
||||||
|
super.onDeviceDisconnected()
|
||||||
|
bodySensorLocationCharacteristic = null
|
||||||
|
heartRateCharacteristic = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServicesInvalidated() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
val HR_SERVICE_UUID = UUID.fromString("0000180D-0000-1000-8000-00805f9b34fb")
|
||||||
|
private val BODY_SENSOR_LOCATION_CHARACTERISTIC_UUID = UUID.fromString("00002A38-0000-1000-8000-00805f9b34fb")
|
||||||
|
private val HEART_RATE_MEASUREMENT_CHARACTERISTIC_UUID = UUID.fromString("00002A37-0000-1000-8000-00805f9b34fb")
|
||||||
|
private var managerInstance: HRSManager? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton implementation of HRSManager class.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun getInstance(context: Context): HRSManager? {
|
||||||
|
if (managerInstance == null) {
|
||||||
|
managerInstance = HRSManager(context)
|
||||||
|
}
|
||||||
|
return managerInstance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2015, 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.hrs.service
|
||||||
|
|
||||||
|
import no.nordicsemi.android.ble.common.profile.hr.BodySensorLocationCallback
|
||||||
|
import no.nordicsemi.android.ble.common.profile.hr.HeartRateMeasurementCallback
|
||||||
|
import no.nordicsemi.android.service.BatteryManagerCallbacks
|
||||||
|
|
||||||
|
interface HRSManagerCallbacks
|
||||||
|
: BatteryManagerCallbacks, BodySensorLocationCallback, HeartRateMeasurementCallback
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package no.nordicsemi.android.hrs.service
|
||||||
|
|
||||||
|
import android.bluetooth.BluetoothDevice
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import no.nordicsemi.android.ble.BleManagerCallbacks
|
||||||
|
import no.nordicsemi.android.hrs.events.HRSAggregatedData
|
||||||
|
import no.nordicsemi.android.service.ForegroundBleService
|
||||||
|
import no.nordicsemi.android.service.LoggableBleManager
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
internal class HRSService : ForegroundBleService<HRSManager>(), HRSManagerCallbacks {
|
||||||
|
|
||||||
|
private var data = HRSAggregatedData()
|
||||||
|
private val points = mutableListOf<Int>()
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var localBroadcast: HRSDataBroadcast
|
||||||
|
|
||||||
|
override val manager: HRSManager by lazy {
|
||||||
|
HRSManager(this).apply {
|
||||||
|
setGattCallbacks(this@HRSService)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun initializeManager(): LoggableBleManager<out BleManagerCallbacks> {
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBatteryLevelChanged(device: BluetoothDevice, batteryLevel: Int) {
|
||||||
|
sendNewData(data.copy(batteryLevel = batteryLevel))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBodySensorLocationReceived(device: BluetoothDevice, sensorLocation: Int) {
|
||||||
|
sendNewData(data.copy(sensorLocation = sensorLocation))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onHeartRateMeasurementReceived(
|
||||||
|
device: BluetoothDevice,
|
||||||
|
heartRate: Int,
|
||||||
|
contactDetected: Boolean?,
|
||||||
|
energyExpanded: Int?,
|
||||||
|
rrIntervals: MutableList<Int>?
|
||||||
|
) {
|
||||||
|
points.add(heartRate)
|
||||||
|
sendNewData(data.copy(heartRates = points))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendNewData(newData: HRSAggregatedData) {
|
||||||
|
data = newData
|
||||||
|
localBroadcast.offer(newData)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2015, 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.hrs.service
|
||||||
|
|
||||||
|
import no.nordicsemi.android.ble.data.Data
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
object HeartRateMeasurementParser {
|
||||||
|
|
||||||
|
private const val HEART_RATE_VALUE_FORMAT: Byte = 0x01 // 1 bit
|
||||||
|
private const val SENSOR_CONTACT_STATUS: Byte = 0x06 // 2 bits
|
||||||
|
private const val ENERGY_EXPANDED_STATUS: Byte = 0x08 // 1 bit
|
||||||
|
private const val RR_INTERVAL: Byte = 0x10 // 1 bit
|
||||||
|
|
||||||
|
fun parse(data: Data): String {
|
||||||
|
var offset = 0
|
||||||
|
val flags = data.getIntValue(Data.FORMAT_UINT8, offset++)!!
|
||||||
|
|
||||||
|
/*
|
||||||
|
* false Heart Rate Value Format is set to UINT8. Units: beats per minute (bpm)
|
||||||
|
* true Heart Rate Value Format is set to UINT16. Units: beats per minute (bpm)
|
||||||
|
*/
|
||||||
|
val value16bit = flags and HEART_RATE_VALUE_FORMAT.toInt() > 0
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 0 Sensor Contact feature is not supported in the current connection
|
||||||
|
* 1 Sensor Contact feature is not supported in the current connection
|
||||||
|
* 2 Sensor Contact feature is supported, but contact is not detected
|
||||||
|
* 3 Sensor Contact feature is supported and contact is detected
|
||||||
|
*/
|
||||||
|
val sensorContactStatus = flags and SENSOR_CONTACT_STATUS.toInt() shr 1
|
||||||
|
|
||||||
|
/*
|
||||||
|
* false Energy Expended field is not present
|
||||||
|
* true Energy Expended field is present. Units: kilo Joules
|
||||||
|
*/
|
||||||
|
val energyExpandedStatus = flags and ENERGY_EXPANDED_STATUS.toInt() > 0
|
||||||
|
|
||||||
|
/*
|
||||||
|
* false RR-Interval values are not present.
|
||||||
|
* true One or more RR-Interval values are present. Units: 1/1024 seconds
|
||||||
|
*/
|
||||||
|
val rrIntervalStatus = flags and RR_INTERVAL.toInt() > 0
|
||||||
|
|
||||||
|
// heart rate value is 8 or 16 bit long
|
||||||
|
val heartRateValue = data.getIntValue(
|
||||||
|
if (value16bit) {
|
||||||
|
Data.FORMAT_UINT16
|
||||||
|
} else {
|
||||||
|
Data.FORMAT_UINT8
|
||||||
|
},
|
||||||
|
offset++
|
||||||
|
) // bits per minute
|
||||||
|
if (value16bit) offset++
|
||||||
|
|
||||||
|
// energy expanded value is present if a flag was set
|
||||||
|
var energyExpanded = -1
|
||||||
|
if (energyExpandedStatus) energyExpanded = data.getIntValue(Data.FORMAT_UINT16, offset)!!
|
||||||
|
offset += 2
|
||||||
|
|
||||||
|
// RR-interval is set when a flag is set
|
||||||
|
val rrIntervals: MutableList<Float> = ArrayList()
|
||||||
|
if (rrIntervalStatus) {
|
||||||
|
var o = offset
|
||||||
|
while (o < data.value!!.size) {
|
||||||
|
val units = data.getIntValue(Data.FORMAT_UINT16, o)!!
|
||||||
|
rrIntervals.add(units * 1000.0f / 1024.0f) // RR interval is in [1/1024s]
|
||||||
|
o += 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val builder = StringBuilder()
|
||||||
|
builder.append("Heart Rate Measurement: ").append(heartRateValue).append(" bpm")
|
||||||
|
when (sensorContactStatus) {
|
||||||
|
0, 1 -> builder.append(",\nSensor Contact Not Supported")
|
||||||
|
2 -> builder.append(",\nContact is NOT Detected")
|
||||||
|
3 -> builder.append(",\nContact is Detected")
|
||||||
|
}
|
||||||
|
if (energyExpandedStatus) {
|
||||||
|
builder.append(",\nEnergy Expanded: ")
|
||||||
|
.append(energyExpanded)
|
||||||
|
.append(" kJ")
|
||||||
|
}
|
||||||
|
if (rrIntervalStatus) {
|
||||||
|
builder.append(",\nRR Interval: ")
|
||||||
|
for (interval in rrIntervals) builder.append(
|
||||||
|
String.format(
|
||||||
|
Locale.US,
|
||||||
|
"%.02f ms, ",
|
||||||
|
interval
|
||||||
|
)
|
||||||
|
)
|
||||||
|
builder.setLength(builder.length - 2) // remove the ", " at the end
|
||||||
|
}
|
||||||
|
return builder.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package no.nordicsemi.android.hrs.view
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.DashPathEffect
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
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.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.Button
|
||||||
|
import androidx.compose.material.ButtonDefaults
|
||||||
|
import androidx.compose.material.Card
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
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 androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.github.mikephil.charting.charts.LineChart
|
||||||
|
import com.github.mikephil.charting.data.Entry
|
||||||
|
import com.github.mikephil.charting.data.LineData
|
||||||
|
import com.github.mikephil.charting.data.LineDataSet
|
||||||
|
import com.github.mikephil.charting.formatter.IFillFormatter
|
||||||
|
import com.github.mikephil.charting.interfaces.datasets.ILineDataSet
|
||||||
|
import com.github.mikephil.charting.utils.Utils
|
||||||
|
import no.nordicsemi.android.hrs.R
|
||||||
|
import no.nordicsemi.android.hrs.viewmodel.HRSViewState
|
||||||
|
import no.nordicsemi.android.theme.NordicColors
|
||||||
|
import no.nordicsemi.android.theme.view.BatteryLevelView
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun ContentView(state: HRSViewState, onEvent: (HRSScreenViewEvent) -> Unit) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
backgroundColor = NordicColors.NordicGray4.value(),
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
elevation = 0.dp
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.padding(16.dp)) {
|
||||||
|
LineChartView(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
BatteryLevelView(state.batteryLevel)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.secondary),
|
||||||
|
onClick = { onEvent(DisconnectEvent) }
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = R.string.disconnect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LineChartView(state: HRSViewState) {
|
||||||
|
AndroidView(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(300.dp),
|
||||||
|
factory = { createLineChartView(it, state) },
|
||||||
|
update = { updateData(state.points, it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createLineChartView(context: Context, state: HRSViewState): LineChart {
|
||||||
|
return LineChart(context).apply {
|
||||||
|
setBackgroundColor(Color.WHITE)
|
||||||
|
|
||||||
|
description.isEnabled = false
|
||||||
|
|
||||||
|
setTouchEnabled(true)
|
||||||
|
|
||||||
|
// setOnChartValueSelectedListener(this)
|
||||||
|
setDrawGridBackground(false)
|
||||||
|
|
||||||
|
isDragEnabled = true
|
||||||
|
setScaleEnabled(true)
|
||||||
|
setPinchZoom(true)
|
||||||
|
|
||||||
|
xAxis.apply {
|
||||||
|
xAxis.enableGridDashedLine(10f, 10f, 0f)
|
||||||
|
}
|
||||||
|
axisLeft.apply {
|
||||||
|
enableGridDashedLine(10f, 10f, 0f)
|
||||||
|
|
||||||
|
axisMaximum = 300f
|
||||||
|
axisMinimum = 100f
|
||||||
|
}
|
||||||
|
axisRight.isEnabled = false
|
||||||
|
|
||||||
|
//---
|
||||||
|
|
||||||
|
val entries = state.points.mapIndexed { i, v ->
|
||||||
|
Entry(i.toFloat(), v.toFloat())
|
||||||
|
}
|
||||||
|
// create a dataset and give it a type
|
||||||
|
|
||||||
|
if (data != null && data.dataSetCount > 0) {
|
||||||
|
val set1 = data!!.getDataSetByIndex(0) as LineDataSet
|
||||||
|
set1.values = entries
|
||||||
|
set1.notifyDataSetChanged()
|
||||||
|
data!!.notifyDataChanged()
|
||||||
|
notifyDataSetChanged()
|
||||||
|
} else {
|
||||||
|
val set1 = LineDataSet(entries, "DataSet 1")
|
||||||
|
|
||||||
|
set1.setDrawIcons(false)
|
||||||
|
|
||||||
|
// draw dashed line
|
||||||
|
|
||||||
|
// draw dashed line
|
||||||
|
set1.enableDashedLine(10f, 5f, 0f)
|
||||||
|
|
||||||
|
// black lines and points
|
||||||
|
|
||||||
|
// black lines and points
|
||||||
|
set1.color = Color.BLACK
|
||||||
|
set1.setCircleColor(Color.BLACK)
|
||||||
|
|
||||||
|
// line thickness and point size
|
||||||
|
|
||||||
|
// line thickness and point size
|
||||||
|
set1.lineWidth = 1f
|
||||||
|
set1.circleRadius = 3f
|
||||||
|
|
||||||
|
// draw points as solid circles
|
||||||
|
|
||||||
|
// draw points as solid circles
|
||||||
|
set1.setDrawCircleHole(false)
|
||||||
|
|
||||||
|
// customize legend entry
|
||||||
|
|
||||||
|
// customize legend entry
|
||||||
|
set1.formLineWidth = 1f
|
||||||
|
set1.formLineDashEffect = DashPathEffect(floatArrayOf(10f, 5f), 0f)
|
||||||
|
set1.formSize = 15f
|
||||||
|
|
||||||
|
// text size of values
|
||||||
|
|
||||||
|
// text size of values
|
||||||
|
set1.valueTextSize = 9f
|
||||||
|
|
||||||
|
// draw selection line as dashed
|
||||||
|
|
||||||
|
// draw selection line as dashed
|
||||||
|
set1.enableDashedHighlightLine(10f, 5f, 0f)
|
||||||
|
|
||||||
|
// set the filled area
|
||||||
|
|
||||||
|
// set the filled area
|
||||||
|
set1.setDrawFilled(true)
|
||||||
|
set1.fillFormatter = IFillFormatter { _, _ ->
|
||||||
|
axisLeft.axisMinimum
|
||||||
|
}
|
||||||
|
|
||||||
|
// set color of filled area
|
||||||
|
|
||||||
|
// set color of filled area
|
||||||
|
if (Utils.getSDKInt() >= 18) {
|
||||||
|
// drawables only supported on api level 18 and above
|
||||||
|
val drawable = ContextCompat.getDrawable(context, R.drawable.fade_red)
|
||||||
|
set1.fillDrawable = drawable
|
||||||
|
} else {
|
||||||
|
set1.fillColor = Color.BLACK
|
||||||
|
}
|
||||||
|
|
||||||
|
val dataSets = ArrayList<ILineDataSet>()
|
||||||
|
dataSets.add(set1) // add the data sets
|
||||||
|
|
||||||
|
|
||||||
|
// create a data object with the data sets
|
||||||
|
|
||||||
|
// create a data object with the data sets
|
||||||
|
val data = LineData(dataSets)
|
||||||
|
|
||||||
|
// set data
|
||||||
|
|
||||||
|
// set data
|
||||||
|
setData(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateData(points: List<Int>, chart: LineChart) {
|
||||||
|
val entries = points.mapIndexed { i, v ->
|
||||||
|
Entry(i.toFloat(), v.toFloat())
|
||||||
|
}
|
||||||
|
|
||||||
|
with(chart) {
|
||||||
|
if (data != null && data.dataSetCount > 0) {
|
||||||
|
val set1 = data!!.getDataSetByIndex(0) as LineDataSet
|
||||||
|
set1.values = entries
|
||||||
|
set1.notifyDataSetChanged()
|
||||||
|
data!!.notifyDataChanged()
|
||||||
|
notifyDataSetChanged()
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun Preview() {
|
||||||
|
ContentView(state = HRSViewState()) { }
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package no.nordicsemi.android.hrs.view
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.material.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import no.nordicsemi.android.hrs.R
|
||||||
|
import no.nordicsemi.android.hrs.service.HRSService
|
||||||
|
import no.nordicsemi.android.hrs.viewmodel.HRSViewModel
|
||||||
|
import no.nordicsemi.android.hrs.viewmodel.HRSViewState
|
||||||
|
import no.nordicsemi.android.utils.isServiceRunning
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HRSScreen(finishAction: () -> Unit) {
|
||||||
|
val viewModel: HRSViewModel = hiltViewModel()
|
||||||
|
val state = viewModel.state.collectAsState().value
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
LaunchedEffect(state.isScreenActive) {
|
||||||
|
if (!state.isScreenActive) {
|
||||||
|
finishAction()
|
||||||
|
}
|
||||||
|
if (context.isServiceRunning(HRSService::class.java.name)) {
|
||||||
|
val intent = Intent(context, HRSService::class.java)
|
||||||
|
context.stopService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect("start-service") {
|
||||||
|
if (!context.isServiceRunning(HRSService::class.java.name)) {
|
||||||
|
val intent = Intent(context, HRSService::class.java)
|
||||||
|
context.startService(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HRSView(state) { viewModel.onEvent(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HRSView(state: HRSViewState, onEvent: (HRSScreenViewEvent) -> Unit) {
|
||||||
|
Column {
|
||||||
|
TopAppBar(title = { Text(text = stringResource(id = R.string.hrs_title)) })
|
||||||
|
|
||||||
|
ContentView(state) { onEvent(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package no.nordicsemi.android.hrs.view
|
||||||
|
|
||||||
|
sealed class HRSScreenViewEvent
|
||||||
|
|
||||||
|
object DisconnectEvent : HRSScreenViewEvent()
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package no.nordicsemi.android.hrs.viewmodel
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import no.nordicsemi.android.hrs.events.HRSAggregatedData
|
||||||
|
import no.nordicsemi.android.hrs.service.HRSDataBroadcast
|
||||||
|
import no.nordicsemi.android.hrs.view.DisconnectEvent
|
||||||
|
import no.nordicsemi.android.hrs.view.HRSScreenViewEvent
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
internal class HRSViewModel @Inject constructor(
|
||||||
|
private val localBroadcast: HRSDataBroadcast
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val state = MutableStateFlow(HRSViewState())
|
||||||
|
|
||||||
|
init {
|
||||||
|
localBroadcast.events.onEach {
|
||||||
|
withContext(Dispatchers.Main) { consumeEvent(it) }
|
||||||
|
}.launchIn(viewModelScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun consumeEvent(event: HRSAggregatedData) {
|
||||||
|
state.value = state.value.copy(
|
||||||
|
points = event.heartRates,
|
||||||
|
batteryLevel = event.batteryLevel,
|
||||||
|
sensorLocation = event.sensorLocation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEvent(event: HRSScreenViewEvent) {
|
||||||
|
(event as? DisconnectEvent)?.let {
|
||||||
|
onDisconnectButtonClick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onDisconnectButtonClick() {
|
||||||
|
state.tryEmit(state.value.copy(isScreenActive = false))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package no.nordicsemi.android.hrs.viewmodel
|
||||||
|
|
||||||
|
data class HRSViewState(
|
||||||
|
val points: List<Int> = listOf(1, 2, 3),
|
||||||
|
val batteryLevel: Int = 0,
|
||||||
|
val sensorLocation: Int = 0,
|
||||||
|
val isScreenActive: Boolean = true
|
||||||
|
)
|
||||||
7
feature_hrs/src/main/res/drawable/fade_red.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<gradient
|
||||||
|
android:angle="90"
|
||||||
|
android:startColor="#00ff0000"
|
||||||
|
android:endColor="#ffff0000" />
|
||||||
|
</shape>
|
||||||
4
feature_hrs/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="hrs_title">HRS</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package no.nordicsemi.android.hrs
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example local unit test, which will execute on the development machine (host).
|
||||||
|
*
|
||||||
|
* See [testing documentation](http://d.android.com/tools/testing).
|
||||||
|
*/
|
||||||
|
class ExampleUnitTest {
|
||||||
|
@Test
|
||||||
|
fun addition_isCorrect() {
|
||||||
|
assertEquals(4, 2 + 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ apply plugin: 'kotlin-parcelize'
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation project(":lib_utils")
|
implementation project(":lib_utils")
|
||||||
implementation project(":lib_theme")
|
implementation project(":lib_theme")
|
||||||
|
implementation project(":lib_service")
|
||||||
|
|
||||||
implementation libs.material
|
implementation libs.material
|
||||||
implementation libs.google.permissions
|
implementation libs.google.permissions
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import dagger.Provides
|
|||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import no.nordicsemi.android.scanner.tools.SelectedBluetoothDeviceHolder
|
import no.nordicsemi.android.scanner.tools.PermissionHelper
|
||||||
|
import no.nordicsemi.android.service.SelectedBluetoothDeviceHolder
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@@ -24,7 +25,16 @@ internal object HiltModule {
|
|||||||
fun createSelectedBluetoothDeviceHolder(
|
fun createSelectedBluetoothDeviceHolder(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
bluetoothAdapter: BluetoothAdapter?
|
bluetoothAdapter: BluetoothAdapter?
|
||||||
): SelectedBluetoothDeviceHolder {
|
): no.nordicsemi.android.service.SelectedBluetoothDeviceHolder {
|
||||||
return SelectedBluetoothDeviceHolder(context, bluetoothAdapter)
|
return no.nordicsemi.android.service.SelectedBluetoothDeviceHolder(
|
||||||
|
context,
|
||||||
|
bluetoothAdapter
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
@Provides
|
||||||
|
fun createPermissionHelper(@ApplicationContext context: Context): PermissionHelper {
|
||||||
|
return PermissionHelper(context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
package no.nordicsemi.android.scanner
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.material.Text
|
|
||||||
import androidx.compose.material.TopAppBar
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import androidx.navigation.NavController
|
|
||||||
import no.nordicsemi.android.scanner.tools.ScannerStatus
|
|
||||||
import no.nordicsemi.android.scanner.view.*
|
|
||||||
import no.nordicsemi.android.scanner.viewmodel.NordicBleScannerViewModel
|
|
||||||
import no.nordicsemi.android.scanner.viewmodel.ScannerViewEvent
|
|
||||||
import no.nordicsemi.android.utils.exhaustive
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ScannerRoute(navController: NavController) {
|
|
||||||
val viewModel = hiltViewModel<NordicBleScannerViewModel>()
|
|
||||||
|
|
||||||
val scannerStatus = viewModel.state.collectAsState().value
|
|
||||||
|
|
||||||
Column {
|
|
||||||
TopAppBar(title = { Text(text = stringResource(id = R.string.scanner__devices_list)) })
|
|
||||||
ScannerScreen(navController, scannerStatus) { viewModel.onEvent(it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ScannerScreen(
|
|
||||||
navController: NavController,
|
|
||||||
scannerStatus: ScannerStatus,
|
|
||||||
onEvent: (ScannerViewEvent) -> Unit
|
|
||||||
) {
|
|
||||||
when (scannerStatus) {
|
|
||||||
ScannerStatus.PERMISSION_REQUIRED -> RequestPermissionScreen { onEvent(ScannerViewEvent.PERMISSION_CHECKED) }
|
|
||||||
ScannerStatus.NOT_AVAILABLE -> BluetoothNotAvailableScreen()
|
|
||||||
ScannerStatus.DISABLED -> BluetoothNotEnabledScreen { onEvent(ScannerViewEvent.BLUETOOTH_ENABLED) }
|
|
||||||
ScannerStatus.ENABLED -> ScanDeviceScreen(navController)
|
|
||||||
}.exhaustive
|
|
||||||
}
|
|
||||||
@@ -2,14 +2,10 @@ package no.nordicsemi.android.scanner.tools
|
|||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.bluetooth.BluetoothAdapter
|
import android.bluetooth.BluetoothAdapter
|
||||||
import android.bluetooth.BluetoothDevice
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@SuppressLint("MissingPermission")
|
@SuppressLint("MissingPermission")
|
||||||
internal class NordicBleScanner @Inject constructor(private val bleAdapter: BluetoothAdapter?) {
|
class NordicBleScanner @Inject constructor(private val bleAdapter: BluetoothAdapter?) {
|
||||||
|
|
||||||
val scannerResult = MutableStateFlow<ScanningResult>(DeviceListResult())
|
|
||||||
|
|
||||||
fun getBluetoothStatus(): ScannerStatus {
|
fun getBluetoothStatus(): ScannerStatus {
|
||||||
return when {
|
return when {
|
||||||
@@ -19,15 +15,3 @@ internal class NordicBleScanner @Inject constructor(private val bleAdapter: Blue
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class ScanningResult
|
|
||||||
|
|
||||||
data class DeviceListResult(val devices: List<BluetoothDevice> = emptyList()) : ScanningResult()
|
|
||||||
|
|
||||||
object ScanningErrorResult : ScanningResult()
|
|
||||||
|
|
||||||
private fun <T> MutableList<T>.addIfNotExist(value: T) {
|
|
||||||
if (!contains(value)) {
|
|
||||||
add(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package no.nordicsemi.android.scanner.tools
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
|
||||||
|
class PermissionHelper(private val context: Context) {
|
||||||
|
|
||||||
|
fun isRequiredPermissionGranted(): Boolean {
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.BLUETOOTH_CONNECT
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
package no.nordicsemi.android.scanner.tools
|
package no.nordicsemi.android.scanner.tools
|
||||||
|
|
||||||
internal enum class ScannerStatus {
|
enum class ScannerStatus {
|
||||||
PERMISSION_REQUIRED, ENABLED, DISABLED, NOT_AVAILABLE
|
ENABLED, DISABLED, NOT_AVAILABLE
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,42 @@ import android.bluetooth.BluetoothAdapter
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.material.Button
|
import androidx.compose.material.Button
|
||||||
|
import androidx.compose.material.ButtonDefaults
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.material.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
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.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import no.nordicsemi.android.scanner.R
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun BluetoothNotAvailableScreen() {
|
fun BluetoothNotAvailableScreen() {
|
||||||
Text("Bluetooth not available.")
|
Column {
|
||||||
|
TopAppBar(title = { Text(text = stringResource(id = R.string.scanner__request_permission)) })
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.scanner__bluetooth_not_available))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun BluetoothNotEnabledScreen(finish: () -> Unit) {
|
fun BluetoothNotEnabledScreen(finish: () -> Unit) {
|
||||||
val contract = ActivityResultContracts.StartActivityForResult()
|
val contract = ActivityResultContracts.StartActivityForResult()
|
||||||
val launcher = rememberLauncherForActivityResult(contract = contract, onResult = {
|
val launcher = rememberLauncherForActivityResult(contract = contract, onResult = {
|
||||||
if (it.resultCode == Activity.RESULT_OK) {
|
if (it.resultCode == Activity.RESULT_OK) {
|
||||||
@@ -25,10 +49,30 @@ internal fun BluetoothNotEnabledScreen(finish: () -> Unit) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
Text(text = "Bluetooth not enabled.")
|
TopAppBar(title = { Text(text = stringResource(id = R.string.scanner__request_permission)) })
|
||||||
Text(text = "To enable Bluetooth please open settings.")
|
Column(
|
||||||
Button(onClick = { launcher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) }) {
|
modifier = Modifier
|
||||||
Text(text = "Bluetooth not available.")
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
text = stringResource(id = R.string.scanner__bluetooth_not_enabled)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
text = stringResource(id = R.string.scanner__bluetooth_open_settings_info)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
Button(
|
||||||
|
colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.secondary),
|
||||||
|
onClick = { launcher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) }
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = R.string.scanner__bluetooth_open_settings))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package no.nordicsemi.android.scanner.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.material.Button
|
||||||
|
import androidx.compose.material.ButtonDefaults
|
||||||
|
import androidx.compose.material.MaterialTheme
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
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.scanner.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NotConnectedScreen(
|
||||||
|
connect: () -> Unit
|
||||||
|
) {
|
||||||
|
NotConnectedView(connect)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NotConnectedView(
|
||||||
|
connect: () -> Unit
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight(),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = R.string.csc_no_connection))
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Button(
|
||||||
|
colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.secondary),
|
||||||
|
onClick = { connect() }
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = R.string.csc_connect))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun NotConnectedPreview() {
|
||||||
|
NotConnectedView { }
|
||||||
|
}
|
||||||
@@ -4,9 +4,18 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.compose.foundation.layout.*
|
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.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material.Button
|
import androidx.compose.material.Button
|
||||||
import androidx.compose.material.Text
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.material.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
@@ -14,6 +23,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat.startActivity
|
import androidx.core.content.ContextCompat.startActivity
|
||||||
@@ -24,12 +34,14 @@ import no.nordicsemi.android.scanner.R
|
|||||||
|
|
||||||
@OptIn(ExperimentalPermissionsApi::class)
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun RequestPermissionScreen(finish: () -> Unit) {
|
fun RequestPermissionScreen(finish: () -> Unit) {
|
||||||
val permissionsState = rememberMultiplePermissionsState(listOf(
|
val permissionsState = rememberMultiplePermissionsState(listOf(
|
||||||
android.Manifest.permission.ACCESS_FINE_LOCATION,
|
android.Manifest.permission.BLUETOOTH_CONNECT
|
||||||
// android.Manifest.permission.BLUETOOTH_SCAN,
|
|
||||||
// android.Manifest.permission.BLUETOOTH_CONNECT
|
|
||||||
))
|
))
|
||||||
|
|
||||||
|
Column {
|
||||||
|
TopAppBar(title = { Text(text = stringResource(id = R.string.scanner__request_permission)) })
|
||||||
|
|
||||||
PermissionsRequired(
|
PermissionsRequired(
|
||||||
multiplePermissionsState = permissionsState,
|
multiplePermissionsState = permissionsState,
|
||||||
permissionsNotGrantedContent = { PermissionNotGranted { permissionsState.launchMultiplePermissionRequest() } },
|
permissionsNotGrantedContent = { PermissionNotGranted { permissionsState.launchMultiplePermissionRequest() } },
|
||||||
@@ -38,6 +50,7 @@ internal fun RequestPermissionScreen(finish: () -> Unit) {
|
|||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PermissionNotGranted(onClick: () -> Unit) {
|
private fun PermissionNotGranted(onClick: () -> Unit) {
|
||||||
@@ -45,7 +58,9 @@ private fun PermissionNotGranted(onClick: () -> Unit) {
|
|||||||
|
|
||||||
if (doNotShowRationale.value) {
|
if (doNotShowRationale.value) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight(),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
@@ -53,18 +68,21 @@ private fun PermissionNotGranted(onClick: () -> Unit) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight()
|
||||||
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
Text(stringResource(id = R.string.scanner__permission_rationale))
|
Text(textAlign = TextAlign.Center, text = stringResource(id = R.string.scanner__permission_rationale))
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Row {
|
Row {
|
||||||
Button(onClick = { onClick() }) {
|
Button(modifier = Modifier.width(100.dp), onClick = { onClick() }) {
|
||||||
Text(stringResource(id = R.string.scanner__button_ok))
|
Text(stringResource(id = R.string.scanner__button_ok))
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
Spacer(Modifier.width(16.dp))
|
||||||
Button(onClick = { doNotShowRationale.value = true }) {
|
Button(modifier = Modifier.width(100.dp), onClick = { doNotShowRationale.value = true }) {
|
||||||
Text(stringResource(id = R.string.scanner__button_nope))
|
Text(stringResource(id = R.string.scanner__button_nope))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,7 +94,9 @@ private fun PermissionNotGranted(onClick: () -> Unit) {
|
|||||||
private fun PermissionNotAvailable() {
|
private fun PermissionNotAvailable() {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.fillMaxHeight(),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
package no.nordicsemi.android.scanner.view
|
package no.nordicsemi.android.scanner.view
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.bluetooth.BluetoothDevice
|
|
||||||
import android.bluetooth.le.ScanResult
|
|
||||||
import android.companion.AssociationRequest
|
import android.companion.AssociationRequest
|
||||||
import android.companion.BluetoothLeDeviceFilter
|
import android.companion.BluetoothLeDeviceFilter
|
||||||
import android.companion.CompanionDeviceManager
|
import android.companion.CompanionDeviceManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.IntentSender
|
import android.content.IntentSender
|
||||||
import android.os.Build
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.IntentSenderRequest
|
import androidx.activity.result.IntentSenderRequest
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
@@ -16,11 +13,28 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.navigation.NavController
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ScanDeviceScreen(navController: NavController,) {
|
fun ScanDeviceScreen(finish: (ScanDeviceScreenResult) -> Unit) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
val deviceManager =
|
||||||
|
LocalContext.current.getSystemService(Context.COMPANION_DEVICE_SERVICE) as CompanionDeviceManager
|
||||||
|
|
||||||
|
val contract = ActivityResultContracts.StartIntentSenderForResult()
|
||||||
|
val launcher = rememberLauncherForActivityResult(contract = contract) {
|
||||||
|
val result = if (it.resultCode == Activity.RESULT_OK) {
|
||||||
|
ScanDeviceScreenResult.SUCCESS
|
||||||
|
} else {
|
||||||
|
ScanDeviceScreenResult.CANCEL
|
||||||
|
}
|
||||||
|
finish(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasBeenInvoked = remember { mutableStateOf(false) }
|
||||||
|
if (hasBeenInvoked.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hasBeenInvoked.value = true
|
||||||
|
|
||||||
val deviceFilter = BluetoothLeDeviceFilter.Builder()
|
val deviceFilter = BluetoothLeDeviceFilter.Builder()
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
@@ -28,31 +42,6 @@ fun ScanDeviceScreen(navController: NavController,) {
|
|||||||
.addDeviceFilter(deviceFilter)
|
.addDeviceFilter(deviceFilter)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val deviceManager =
|
|
||||||
LocalContext.current.getSystemService(Context.COMPANION_DEVICE_SERVICE) as CompanionDeviceManager
|
|
||||||
|
|
||||||
val contract = ActivityResultContracts.StartIntentSenderForResult()
|
|
||||||
val launcher = rememberLauncherForActivityResult(contract = contract, onResult = {
|
|
||||||
if (it.resultCode == Activity.RESULT_OK) {
|
|
||||||
//Sometimes result is ScanResult & sometimes BluetoothDevice
|
|
||||||
val device: BluetoothDevice = try {
|
|
||||||
it.data?.getParcelableExtra(CompanionDeviceManager.EXTRA_DEVICE)!!
|
|
||||||
} catch (e: Exception) {
|
|
||||||
(it.data?.getParcelableExtra<ScanResult>(CompanionDeviceManager.EXTRA_DEVICE))!!.device
|
|
||||||
}
|
|
||||||
|
|
||||||
navController.previousBackStackEntry
|
|
||||||
?.savedStateHandle
|
|
||||||
?.set("result", device)
|
|
||||||
}
|
|
||||||
navController.popBackStack()
|
|
||||||
})
|
|
||||||
|
|
||||||
val hasBeenInvoked = remember { mutableStateOf(false) }
|
|
||||||
if (hasBeenInvoked.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hasBeenInvoked.value = true
|
|
||||||
deviceManager.associate(pairingRequest,
|
deviceManager.associate(pairingRequest,
|
||||||
object : CompanionDeviceManager.Callback() {
|
object : CompanionDeviceManager.Callback() {
|
||||||
override fun onDeviceFound(chooserLauncher: IntentSender) {
|
override fun onDeviceFound(chooserLauncher: IntentSender) {
|
||||||
@@ -62,8 +51,10 @@ fun ScanDeviceScreen(navController: NavController,) {
|
|||||||
|
|
||||||
override fun onFailure(error: CharSequence?) {
|
override fun onFailure(error: CharSequence?) {
|
||||||
}
|
}
|
||||||
}, null)
|
}, null
|
||||||
} else {
|
)
|
||||||
TODO("VERSION.SDK_INT < O")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum class ScanDeviceScreenResult {
|
||||||
|
SUCCESS, CANCEL
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package no.nordicsemi.android.scanner.viewmodel
|
||||||
|
|
||||||
|
enum class BluetoothPermissionState {
|
||||||
|
PERMISSION_REQUIRED,
|
||||||
|
BLUETOOTH_NOT_AVAILABLE,
|
||||||
|
BLUETOOTH_NOT_ENABLED,
|
||||||
|
DEVICE_NOT_CONNECTED,
|
||||||
|
READY
|
||||||
|
}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
package no.nordicsemi.android.scanner.viewmodel
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import no.nordicsemi.android.scanner.tools.NordicBleScanner
|
|
||||||
import no.nordicsemi.android.scanner.tools.ScannerStatus
|
|
||||||
import no.nordicsemi.android.utils.exhaustive
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
internal class NordicBleScannerViewModel @Inject constructor(
|
|
||||||
private val bleScanner: NordicBleScanner
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
val state =
|
|
||||||
MutableStateFlow(ScannerStatus.PERMISSION_REQUIRED)
|
|
||||||
|
|
||||||
fun onEvent(event: ScannerViewEvent) {
|
|
||||||
when (event) {
|
|
||||||
ScannerViewEvent.PERMISSION_CHECKED -> onPermissionChecked()
|
|
||||||
ScannerViewEvent.BLUETOOTH_ENABLED -> onBluetoothEnabled()
|
|
||||||
}.exhaustive
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onPermissionChecked() {
|
|
||||||
state.value = bleScanner.getBluetoothStatus()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onBluetoothEnabled() {
|
|
||||||
state.value = bleScanner.getBluetoothStatus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal enum class ScannerViewEvent {
|
|
||||||
PERMISSION_CHECKED, BLUETOOTH_ENABLED
|
|
||||||
}
|
|
||||||
@@ -4,12 +4,23 @@
|
|||||||
|
|
||||||
<string name="scanner__permission_rationale">The location permission is required when using Bluetooth LE, because surrounding devices can expose user\'s location. Please grant the permission.</string>
|
<string name="scanner__permission_rationale">The location permission is required when using Bluetooth LE, because surrounding devices can expose user\'s location. Please grant the permission.</string>
|
||||||
<string name="scanner__permission_denied">Location permission denied. Please, grant us access on the Settings screen.</string>
|
<string name="scanner__permission_denied">Location permission denied. Please, grant us access on the Settings screen.</string>
|
||||||
<string name="scanner__button_ok">OK</string>
|
|
||||||
<string name="scanner__button_nope">Nope</string>
|
|
||||||
<string name="scanner__open_settings">Open settings</string>
|
<string name="scanner__open_settings">Open settings</string>
|
||||||
<string name="scanner__feature_not_available">Feature not available</string>
|
<string name="scanner__feature_not_available">Feature not available</string>
|
||||||
|
|
||||||
<string name="scanner__list_of_devices">List of devices</string>
|
<string name="scanner__list_of_devices">List of devices</string>
|
||||||
<string name="scanner__error">Scanning failed due to technical reason.</string>
|
<string name="scanner__error">Scanning failed due to technical reason.</string>
|
||||||
<string name="scanner__no_name">Name: NONE</string>
|
<string name="scanner__no_name">Name: NONE</string>
|
||||||
|
|
||||||
|
<string name="csc_no_connection">No device connected</string>
|
||||||
|
<string name="csc_connect">Connect</string>
|
||||||
|
|
||||||
|
<string name="scanner__button_ok">Grant</string>
|
||||||
|
<string name="scanner__button_nope">Deny</string>
|
||||||
|
|
||||||
|
<string name="scanner__request_permission">Request permission</string>
|
||||||
|
|
||||||
|
<string name="scanner__bluetooth_not_available">Bluetooth not available.</string>
|
||||||
|
<string name="scanner__bluetooth_not_enabled">Bluetooth not enabled.</string>
|
||||||
|
<string name="scanner__bluetooth_open_settings_info">To enable Bluetooth please open settings.</string>
|
||||||
|
<string name="scanner__bluetooth_open_settings">Open settings</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ apply from: rootProject.file("library.gradle")
|
|||||||
apply plugin: 'kotlin-parcelize'
|
apply plugin: 'kotlin-parcelize'
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation project(":feature_scanner")
|
implementation project(":lib_theme")
|
||||||
|
|
||||||
implementation libs.nordic.ble.common
|
implementation libs.nordic.ble.common
|
||||||
implementation libs.nordic.log
|
implementation libs.nordic.log
|
||||||
|
|||||||
@@ -2,4 +2,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="no.nordicsemi.android.service">
|
package="no.nordicsemi.android.service">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -42,7 +42,6 @@ import no.nordicsemi.android.ble.BleManagerCallbacks
|
|||||||
import no.nordicsemi.android.ble.utils.ILogger
|
import no.nordicsemi.android.ble.utils.ILogger
|
||||||
import no.nordicsemi.android.log.ILogSession
|
import no.nordicsemi.android.log.ILogSession
|
||||||
import no.nordicsemi.android.log.Logger
|
import no.nordicsemi.android.log.Logger
|
||||||
import no.nordicsemi.android.scanner.tools.SelectedBluetoothDeviceHolder
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -68,7 +67,7 @@ abstract class BleProfileService : LifecycleService(), BleManagerCallbacks {
|
|||||||
* @return bluetooth device
|
* @return bluetooth device
|
||||||
*/
|
*/
|
||||||
protected val bluetoothDevice: BluetoothDevice by lazy {
|
protected val bluetoothDevice: BluetoothDevice by lazy {
|
||||||
bluetoothDeviceHolder.device ?: throw UnsupportedOperationException(
|
bluetoothDeviceHolder.device ?: throw IllegalArgumentException(
|
||||||
"No device address at EXTRA_DEVICE_ADDRESS key"
|
"No device address at EXTRA_DEVICE_ADDRESS key"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,16 +22,29 @@
|
|||||||
package no.nordicsemi.android.service
|
package no.nordicsemi.android.service
|
||||||
|
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
|
||||||
|
private const val CHANNEL_ID = "FOREGROUND_BLE_SERVICE"
|
||||||
|
|
||||||
abstract class ForegroundBleService<T : BatteryManager<out BatteryManagerCallbacks>> : BleProfileService() {
|
abstract class ForegroundBleService<T : BatteryManager<out BatteryManagerCallbacks>> : BleProfileService() {
|
||||||
|
|
||||||
protected abstract val manager: T
|
protected abstract val manager: T
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
val result = super.onStartCommand(intent, flags, startId)
|
||||||
|
startForegroundService()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
// when user has disconnected from the sensor, we have to cancel the notification that we've created some milliseconds before using unbindService
|
// when user has disconnected from the sensor, we have to cancel the notification that we've created some milliseconds before using unbindService
|
||||||
cancelNotification()
|
cancelNotification()
|
||||||
|
stopForegroundService()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,24 +100,30 @@ abstract class ForegroundBleService<T : BatteryManager<out BatteryManagerCallbac
|
|||||||
* @param defaults
|
* @param defaults
|
||||||
*/
|
*/
|
||||||
private fun createNotification(messageResId: Int, defaults: Int): Notification {
|
private fun createNotification(messageResId: Int, defaults: Int): Notification {
|
||||||
TODO()
|
createNotificationChannel(CHANNEL_ID)
|
||||||
// final Intent parentIntent = new Intent(this, FeaturesActivity.class);
|
|
||||||
// parentIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
val intent: Intent? = packageManager.getLaunchIntentForPackage(packageName)
|
||||||
// final Intent targetIntent = new Intent(this, CSCActivity.class);
|
val pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
|
||||||
//
|
|
||||||
// final Intent disconnect = new Intent(ACTION_DISCONNECT);
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
// final PendingIntent disconnectAction = PendingIntent.getBroadcast(this, DISCONNECT_REQ, disconnect, PendingIntent.FLAG_UPDATE_CURRENT);
|
.setContentTitle(getString(R.string.app_name))
|
||||||
//
|
.setContentText(getString(messageResId, manager.bluetoothDevice?.name ?: "Device"))
|
||||||
// // both activities above have launchMode="singleTask" in the AndroidManifest.xml file, so if the task is already running, it will be resumed
|
.setSmallIcon(R.mipmap.ic_launcher)
|
||||||
// final PendingIntent pendingIntent = PendingIntent.getActivities(this, OPEN_ACTIVITY_REQ, new Intent[]{parentIntent, targetIntent}, PendingIntent.FLAG_UPDATE_CURRENT);
|
.setContentIntent(pendingIntent)
|
||||||
// final NotificationCompat.Builder builder = new NotificationCompat.Builder(this, ToolboxApplication.CONNECTED_DEVICE_CHANNEL);
|
.build()
|
||||||
// builder.setContentIntent(pendingIntent);
|
}
|
||||||
// builder.setContentTitle(getString(R.string.app_name)).setContentText(getString(messageResId, getDeviceName()));
|
|
||||||
// builder.setSmallIcon(R.drawable.ic_stat_notify_csc);
|
private fun createNotificationChannel(channelName: String) {
|
||||||
// builder.setShowWhen(defaults != 0).setDefaults(defaults).setAutoCancel(true).setOngoing(true);
|
val channel = NotificationChannel(
|
||||||
// builder.addAction(new NotificationCompat.Action(R.drawable.ic_action_bluetooth, getString(R.string.csc_notification_action_disconnect), disconnectAction));
|
channelName,
|
||||||
//
|
getString(R.string.channel_connected_devices_title),
|
||||||
// return builder.build();
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
)
|
||||||
|
channel.description = getString(R.string.channel_connected_devices_description)
|
||||||
|
channel.setShowBadge(false)
|
||||||
|
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||||
|
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package no.nordicsemi.android.scanner.tools
|
package no.nordicsemi.android.service
|
||||||
|
|
||||||
import android.bluetooth.BluetoothAdapter
|
import android.bluetooth.BluetoothAdapter
|
||||||
import android.bluetooth.BluetoothDevice
|
import android.bluetooth.BluetoothDevice
|
||||||
@@ -4,4 +4,6 @@
|
|||||||
<string name="csc_bonding">Bonding with the device…</string>
|
<string name="csc_bonding">Bonding with the device…</string>
|
||||||
<string name="csc_bonded">The device is now bonded.</string>
|
<string name="csc_bonded">The device is now bonded.</string>
|
||||||
<string name="csc_notification_connected_message">%s is connected.</string>
|
<string name="csc_notification_connected_message">%s is connected.</string>
|
||||||
|
<string name="channel_connected_devices_title">Background connections</string>
|
||||||
|
<string name="channel_connected_devices_description">Shows a notification when a device is connected in background.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
package no.nordicsemi.android.theme
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
object Background {
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun whiteRoundedCorners(): Modifier {
|
|
||||||
return Modifier
|
|
||||||
.background(Color(0xffffffff))
|
|
||||||
.padding(16.dp)
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.clip(RoundedCornerShape(10.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +1,67 @@
|
|||||||
package no.nordicsemi.android.theme
|
package no.nordicsemi.android.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
object NordicColors {
|
object NordicColors {
|
||||||
val Primary = Color(0xFF00A9CE)
|
val AlmostWhite = Color(0xFFDADADA)
|
||||||
val PrimaryLight = Color(0xFF5fdbff)
|
|
||||||
val PrimaryDark = Color(0xFF007a9d)
|
|
||||||
val Secondary = Color(0xFF0077c8)
|
|
||||||
val SecondaryLight = Color(0xFF57c0e2)
|
|
||||||
val SecondaryDark = Color(0xFF004c97)
|
|
||||||
val Text = Color(0xFF00A9CE)
|
|
||||||
|
|
||||||
val NordicBlue = Color(0xFF00A9CE)
|
val NordicBlue = Color(0xFF00A9CE)
|
||||||
val NordicBlueDark = Color(0xFF0090B0)
|
val NordicLake = Color(0xFF008CD2)
|
||||||
val NordicSky = Color(0xFF6AD1E3)
|
|
||||||
val NordicBlueLate = Color(0xFF0033A0)
|
val NordicDarkGray = ThemedColor(Color(0xFF333F48), Color(0xFFCCCBC8))
|
||||||
val NordicLake = Color(0xFF0077C8)
|
|
||||||
val NordicLightGray = Color(0xFFD9E1E2)
|
// val NordicGray4 = ThemedColor(Color(0xFFD1D1D6), Color(0xFF3A3A3C))
|
||||||
val NordicMediumGray = Color(0xFF768692)
|
val NordicGray4 = ThemedColor(Color.White, Color(0xFF3A3A3C))
|
||||||
val NordicDarkGray = Color(0xFF333F48)
|
|
||||||
val NordicGrass = Color(0xFFD0DF00)
|
val NordicGray5 = ThemedColor(Color(0xFFE5E5EA), Color(0xFF2C2C2E))
|
||||||
val NordicSun = Color(0xFFFFCD00)
|
val NordicLightGray = NeutralColor(Color(0xFF929CA2))
|
||||||
val NordicRed = Color(0xFFEE2F4E)
|
val NordicMediumGray = NeutralColor(Color(0xFF929CA2))
|
||||||
val NordicFall = Color(0xFFF58220)
|
|
||||||
|
val NordicFall = ThemedColor(Color(0xFFF99535), Color(0xFFFF9F0A))
|
||||||
|
val NordicGreen = ThemedColor(Color(0xFF3ED052), Color(0xFF32D74B))
|
||||||
|
|
||||||
|
val NordicOrange = ThemedColor(Color(0xFFDF9B16), Color(0xFFFF9F0A))
|
||||||
|
val NordicRed = ThemedColor(Color(0xFFD03E51), Color(0xFFFF453A))
|
||||||
|
val NordicSky = NeutralColor(Color(0xFF6AD1E3))
|
||||||
|
val NordicYellow = ThemedColor(Color(0xFFF9EE35), Color(0xFFFFD60A))
|
||||||
|
val TableViewBackground = NeutralColor(Color(0xFFF2F2F6))
|
||||||
|
val TableViewSeparator = NeutralColor(Color(0xFFD2D2D6))
|
||||||
|
|
||||||
|
val Primary = ThemedColor(Color(0xFF00A9CE), Color(0xFF212121))
|
||||||
|
val PrimaryVariant = ThemedColor(Color(0xFF008CD2), Color.Black)
|
||||||
|
val Secondary = ThemedColor(Color(0xFF00A9CE), Color(0xFF008CD2))
|
||||||
|
val SecondaryVariant = ThemedColor(Color(0xFF008CD2), Color(0xFF008CD2))
|
||||||
|
val OnPrimary = ThemedColor(Color.White, Color.White)
|
||||||
|
val OnSecondary = ThemedColor(Color.White, Color.White)
|
||||||
|
val OnBackground = ThemedColor(Color.Black, Color.White)
|
||||||
|
val OnSurface = ThemedColor(Color.Black, Color.White)
|
||||||
|
val Background = ThemedColor(Color(0xFFDADADA), Color.Black)
|
||||||
|
val Surface = ThemedColor(Color(0xFFDADADA), Color.Black)
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class NordicColor {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
abstract fun value(): Color
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ThemedColor(val light: Color, val dark: Color): NordicColor() {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun value(): Color {
|
||||||
|
return if (isSystemInDarkTheme()) {
|
||||||
|
dark
|
||||||
|
} else {
|
||||||
|
light
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class NeutralColor(val color: Color): NordicColor() {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun value(): Color {
|
||||||
|
return color
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,41 +5,40 @@ import androidx.compose.material.MaterialTheme
|
|||||||
import androidx.compose.material.darkColors
|
import androidx.compose.material.darkColors
|
||||||
import androidx.compose.material.lightColors
|
import androidx.compose.material.lightColors
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
|
|
||||||
//TODO
|
|
||||||
private val DarkColorPalette = darkColors(
|
|
||||||
primary = NordicColors.Primary,
|
|
||||||
primaryVariant = NordicColors.PrimaryDark,
|
|
||||||
secondary = NordicColors.Secondary,
|
|
||||||
secondaryVariant = NordicColors.SecondaryDark,
|
|
||||||
onSecondary = Color.White,
|
|
||||||
onPrimary = Color.White,
|
|
||||||
onBackground = Color.Black,
|
|
||||||
onSurface = Color.Black,
|
|
||||||
background = Color.White,
|
|
||||||
surface = Color.White,
|
|
||||||
)
|
|
||||||
|
|
||||||
private val LightColorPalette = lightColors(
|
|
||||||
primary = NordicColors.Primary,
|
|
||||||
primaryVariant = NordicColors.PrimaryDark,
|
|
||||||
secondary = NordicColors.Secondary,
|
|
||||||
secondaryVariant = NordicColors.SecondaryDark,
|
|
||||||
onSecondary = Color.White,
|
|
||||||
onPrimary = Color.White,
|
|
||||||
onBackground = Color.Black,
|
|
||||||
onSurface = Color.Black,
|
|
||||||
background = Color.White,
|
|
||||||
surface = Color.White,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TestTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) {
|
fun TestTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) {
|
||||||
|
|
||||||
|
val darkColorPalette = darkColors(
|
||||||
|
primary = NordicColors.Primary.value(),
|
||||||
|
primaryVariant = NordicColors.PrimaryVariant.value(),
|
||||||
|
secondary = NordicColors.Secondary.value(),
|
||||||
|
secondaryVariant = NordicColors.SecondaryVariant.value(),
|
||||||
|
onSecondary = NordicColors.OnSecondary.value(),
|
||||||
|
onPrimary = NordicColors.OnPrimary.value(),
|
||||||
|
onBackground = NordicColors.OnBackground.value(),
|
||||||
|
onSurface = NordicColors.OnSurface.value(),
|
||||||
|
background = NordicColors.Background.value(),
|
||||||
|
surface = NordicColors.Surface.value(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val lightColorPalette = lightColors(
|
||||||
|
primary = NordicColors.Primary.value(),
|
||||||
|
primaryVariant = NordicColors.PrimaryVariant.value(),
|
||||||
|
secondary = NordicColors.Secondary.value(),
|
||||||
|
secondaryVariant = NordicColors.SecondaryVariant.value(),
|
||||||
|
onSecondary = NordicColors.OnSecondary.value(),
|
||||||
|
onPrimary = NordicColors.OnPrimary.value(),
|
||||||
|
onBackground = NordicColors.OnBackground.value(),
|
||||||
|
onSurface = NordicColors.OnSurface.value(),
|
||||||
|
background = NordicColors.Background.value(),
|
||||||
|
surface = NordicColors.Surface.value(),
|
||||||
|
)
|
||||||
|
|
||||||
val colors = if (darkTheme) {
|
val colors = if (darkTheme) {
|
||||||
DarkColorPalette
|
darkColorPalette
|
||||||
} else {
|
} else {
|
||||||
LightColorPalette
|
lightColorPalette
|
||||||
}
|
}
|
||||||
|
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package no.nordicsemi.android.theme.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.Card
|
||||||
|
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.theme.NordicColors
|
||||||
|
import no.nordicsemi.android.theme.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BatteryLevelView(batteryLevel: Int) {
|
||||||
|
Card(
|
||||||
|
backgroundColor = NordicColors.NordicGray4.value(),
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
elevation = 0.dp
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.padding(16.dp)) {
|
||||||
|
KeyValueField(
|
||||||
|
stringResource(id = R.string.field_battery),
|
||||||
|
"$batteryLevel%"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package no.nordicsemi.android.theme.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import no.nordicsemi.android.theme.NordicColors
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun KeyValueField(key: String, value: String) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(text = key)
|
||||||
|
Text(
|
||||||
|
color = NordicColors.NordicDarkGray.value(),
|
||||||
|
text = value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
27
lib_theme/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
~ Copyright (c) 2017, 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.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/actionBarColor"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
~ Copyright (c) 2017, 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.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/actionBarColor"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
BIN
lib_theme/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
lib_theme/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
lib_theme/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
lib_theme/src/main/res/mipmap-hdpi/ic_shortcut_dfu.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
lib_theme/src/main/res/mipmap-hdpi/ic_shortcut_uart.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
lib_theme/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
lib_theme/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
lib_theme/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
lib_theme/src/main/res/mipmap-mdpi/ic_shortcut_dfu.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
lib_theme/src/main/res/mipmap-mdpi/ic_shortcut_uart.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
lib_theme/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
lib_theme/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
lib_theme/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
lib_theme/src/main/res/mipmap-xhdpi/ic_shortcut_dfu.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
lib_theme/src/main/res/mipmap-xhdpi/ic_shortcut_uart.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
lib_theme/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
lib_theme/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
lib_theme/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
lib_theme/src/main/res/mipmap-xxhdpi/ic_shortcut_dfu.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |