Add HRS service
@@ -51,8 +51,10 @@ dependencies {
|
||||
//Hilt requires to implement every module in the main app module
|
||||
//https://github.com/google/dagger/issues/2123
|
||||
implementation project(":feature_csc")
|
||||
implementation project(":lib_theme")
|
||||
implementation project(":feature_hrs")
|
||||
implementation project(':feature_scanner')
|
||||
implementation project(":lib_theme")
|
||||
implementation project(":lib_utils")
|
||||
|
||||
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
|
||||
|
||||
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.activity.OnBackPressedCallback
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
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.TopAppBar
|
||||
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.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
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
|
||||
fun HomeScreen() {
|
||||
val navController = rememberNavController()
|
||||
|
||||
NavHost(navController = navController, startDestination = "home") {
|
||||
composable("home") { HomeView(navController) }
|
||||
composable("csc-route") { CSCRoute() }
|
||||
val viewModel = hiltViewModel<NavigationViewModel>()
|
||||
val continueAction: () -> Unit = { viewModel.finish() }
|
||||
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
|
||||
fun HomeView(navHostController: NavController) {
|
||||
fun HomeView(callback: (NavDestination) -> Unit) {
|
||||
Column {
|
||||
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
|
||||
fun FeatureButton(@DrawableRes iconId: Int, @StringRes nameId: Int, onClick: () -> Unit) {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = { onClick() },
|
||||
colors = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent)
|
||||
) {
|
||||
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),
|
||||
)
|
||||
private fun BackHandler(enabled: Boolean = true, onBack: () -> Unit) {
|
||||
val currentOnBack = rememberUpdatedState(onBack)
|
||||
val backCallback = remember {
|
||||
object : OnBackPressedCallback(enabled) {
|
||||
override fun handleOnBackPressed() {
|
||||
currentOnBack.value()
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
@Composable
|
||||
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>
|
||||
<string name="app_name">nRF Toolbox</string>
|
||||
|
||||
<string name="csc_module">CSC</string>
|
||||
<string name="hrs_module">HRS</string>
|
||||
</resources>
|
||||
@@ -4,7 +4,6 @@ apply plugin: 'kotlin-parcelize'
|
||||
dependencies {
|
||||
implementation project(":lib_service")
|
||||
implementation project(":lib_theme")
|
||||
implementation project(':feature_scanner')
|
||||
implementation project(":lib_utils")
|
||||
|
||||
implementation libs.nordic.ble.common
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="no.nordicsemi.android.csc">
|
||||
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<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" />
|
||||
|
||||
<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 kotlinx.parcelize.Parcelize
|
||||
|
||||
sealed class CSCServiceEvent : Parcelable
|
||||
internal sealed class CSCServiceEvent : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class OnDistanceChangedEvent(
|
||||
internal data class OnDistanceChangedEvent(
|
||||
val bluetoothDevice: BluetoothDevice,
|
||||
val speed: Float,
|
||||
val distance: Float,
|
||||
@@ -15,14 +15,14 @@ data class OnDistanceChangedEvent(
|
||||
) : CSCServiceEvent()
|
||||
|
||||
@Parcelize
|
||||
data class CrankDataChanged(
|
||||
internal data class CrankDataChanged(
|
||||
val bluetoothDevice: BluetoothDevice,
|
||||
val crankCadence: Int,
|
||||
val gearRatio: Float
|
||||
) : CSCServiceEvent()
|
||||
|
||||
@Parcelize
|
||||
data class OnBatteryLevelChanged(
|
||||
internal data class OnBatteryLevelChanged(
|
||||
val device: BluetoothDevice,
|
||||
val batteryLevel: Int
|
||||
) : CSCServiceEvent()
|
||||
|
||||
@@ -9,7 +9,7 @@ import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class CSCDataReadBroadcast @Inject constructor() : BluetoothDataReadBroadcast<CSCServiceEvent>() {
|
||||
internal class CSCDataReadBroadcast @Inject constructor() : BluetoothDataReadBroadcast<CSCServiceEvent>() {
|
||||
|
||||
private val _wheelSize = MutableSharedFlow<Int>(
|
||||
replay = 1,
|
||||
|
||||
@@ -35,6 +35,12 @@ import no.nordicsemi.android.log.LogContract
|
||||
import no.nordicsemi.android.service.BatteryManager
|
||||
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) {
|
||||
|
||||
private var cscMeasurementCharacteristic: BluetoothGattCharacteristic? = null
|
||||
@@ -114,14 +120,4 @@ internal class CSCManager(context: Context) : BatteryManager<CSCManagerCallbacks
|
||||
|
||||
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
|
||||
|
||||
import android.bluetooth.BluetoothDevice
|
||||
|
||||
internal sealed class 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 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
|
||||
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.content.Intent
|
||||
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.foundation.layout.padding
|
||||
import androidx.compose.material.Button
|
||||
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.runtime.livedata.observeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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.navigation.NavController
|
||||
import no.nordicsemi.android.csc.R
|
||||
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.utils.exhaustive
|
||||
import no.nordicsemi.android.utils.isServiceRunning
|
||||
|
||||
@Composable
|
||||
internal fun CscScreen(navController: NavController, 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)
|
||||
}
|
||||
|
||||
fun CscScreen(finishAction: () -> Unit) {
|
||||
val viewModel: CscViewModel = hiltViewModel()
|
||||
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
|
||||
private fun CSCView(navController: NavController, state: CSCViewState, onEvent: (CSCViewEvent) -> Unit) {
|
||||
private fun CSCView(state: CSCViewState, onEvent: (CSCViewEvent) -> Unit) {
|
||||
Column {
|
||||
TopAppBar(title = { Text(text = stringResource(id = R.string.csc_title)) })
|
||||
|
||||
when (state) {
|
||||
is CSCViewConnectedState -> ConnectedView(state) { onEvent(it) }
|
||||
is CSCViewNotConnectedState -> NotConnectedScreen(navController, state) {
|
||||
onEvent(it)
|
||||
}
|
||||
}.exhaustive
|
||||
ContentView(state) { onEvent(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.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.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import 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
|
||||
|
||||
@Composable
|
||||
@@ -27,13 +38,47 @@ private fun SelectWheelSizeView(onEvent: (OnWheelSizeSelected) -> Unit) {
|
||||
val wheelEntries = stringArrayResource(R.array.wheel_entries)
|
||||
val wheelValues = stringArrayResource(R.array.wheel_values)
|
||||
|
||||
Box(Modifier.padding(16.dp)) {
|
||||
Column(modifier = Background.whiteRoundedCorners()) {
|
||||
Text(text = "Wheel size")
|
||||
Card(
|
||||
modifier = Modifier.height(300.dp),
|
||||
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 ->
|
||||
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))
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.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.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.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
|
||||
internal fun SensorsReadingView(state: CSCViewConnectedState) {
|
||||
Column {
|
||||
Column(modifier = Background.whiteRoundedCorners()) {
|
||||
internal fun SensorsReadingView(state: CSCViewState) {
|
||||
Card(
|
||||
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())
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
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())
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
KeyValueField(
|
||||
stringResource(id = R.string.scs_field_total_distance),
|
||||
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))
|
||||
|
||||
Column(modifier = Background.whiteRoundedCorners()) {
|
||||
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)
|
||||
}
|
||||
BatteryLevelView(state.batteryLevel)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.RadioButton
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -16,7 +18,7 @@ internal fun SpeedUnitRadioGroup(
|
||||
onEvent: (OnSelectedSpeedUnitSelected) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
SpeedUnitRadioButton(currentUnit, SpeedUnit.KM_H, onEvent)
|
||||
@@ -36,6 +38,7 @@ internal fun SpeedUnitRadioButton(
|
||||
selected = (selectedUnit == displayedUnit),
|
||||
onClick = { onEvent(OnSelectedSpeedUnitSelected(displayedUnit)) }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(text = createSpeedUnitLabel(displayedUnit))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,10 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import no.nordicsemi.android.csc.R
|
||||
import no.nordicsemi.android.csc.viewmodel.CSCViewState
|
||||
|
||||
@Composable
|
||||
internal fun WheelSizeView(state: CSCViewConnectedState, onEvent: (CSCViewEvent) -> Unit) {
|
||||
internal fun WheelSizeView(state: CSCViewState, onEvent: (CSCViewEvent) -> Unit) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
value = state.wheelSize,
|
||||
@@ -35,5 +36,5 @@ private fun EditIcon(onEvent: (CSCViewEvent) -> Unit) {
|
||||
@Preview
|
||||
@Composable
|
||||
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.OnDistanceChangedEvent
|
||||
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.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.OnMovedToScannerScreen
|
||||
import no.nordicsemi.android.csc.view.OnSelectedSpeedUnitSelected
|
||||
import no.nordicsemi.android.csc.view.OnShowEditWheelSizeDialogButtonClick
|
||||
import no.nordicsemi.android.csc.view.OnWheelSizeSelected
|
||||
import no.nordicsemi.android.scanner.tools.SelectedBluetoothDeviceHolder
|
||||
import no.nordicsemi.android.utils.exhaustive
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
internal class CscViewModel @Inject constructor(
|
||||
private val localBroadcast: CSCDataReadBroadcast,
|
||||
private val deviceHolder: SelectedBluetoothDeviceHolder
|
||||
private val localBroadcast: CSCDataReadBroadcast
|
||||
) : ViewModel() {
|
||||
|
||||
val state = MutableStateFlow(createInitialState())
|
||||
val state = MutableStateFlow(CSCViewState())
|
||||
|
||||
init {
|
||||
localBroadcast.events.onEach {
|
||||
@@ -42,10 +34,6 @@ internal class CscViewModel @Inject constructor(
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun createInitialState(): CSCViewState {
|
||||
return deviceHolder.device?.let { CSCViewConnectedState() } ?: CSCViewNotConnectedState()
|
||||
}
|
||||
|
||||
private fun consumeEvent(event: CSCServiceEvent) {
|
||||
val newValue = when (event) {
|
||||
is CrankDataChanged -> createNewState(event)
|
||||
@@ -55,21 +43,21 @@ internal class CscViewModel @Inject constructor(
|
||||
state.value = newValue
|
||||
}
|
||||
|
||||
private fun createNewState(event: CrankDataChanged): CSCViewConnectedState {
|
||||
return state.value.ensureConnectedState().copy(
|
||||
private fun createNewState(event: CrankDataChanged): CSCViewState {
|
||||
return state.value.copy(
|
||||
cadence = event.crankCadence,
|
||||
gearRatio = event.gearRatio
|
||||
)
|
||||
}
|
||||
|
||||
private fun createNewState(event: OnBatteryLevelChanged): CSCViewConnectedState {
|
||||
return state.value.ensureConnectedState().copy(
|
||||
private fun createNewState(event: OnBatteryLevelChanged): CSCViewState {
|
||||
return state.value.copy(
|
||||
batteryLevel = event.batteryLevel
|
||||
)
|
||||
}
|
||||
|
||||
private fun createNewState(event: OnDistanceChangedEvent): CSCViewConnectedState {
|
||||
return state.value.ensureConnectedState().copy(
|
||||
private fun createNewState(event: OnDistanceChangedEvent): CSCViewState {
|
||||
return state.value.copy(
|
||||
speed = event.speed,
|
||||
distance = event.distance,
|
||||
totalDistance = event.totalDistance
|
||||
@@ -82,41 +70,26 @@ internal class CscViewModel @Inject constructor(
|
||||
OnShowEditWheelSizeDialogButtonClick -> onShowDialogEvent()
|
||||
is OnWheelSizeSelected -> onWheelSizeChanged(event)
|
||||
OnDisconnectButtonClick -> onDisconnectButtonClick()
|
||||
OnConnectButtonClick -> onConnectButtonClick()
|
||||
OnMovedToScannerScreen -> onOnMovedToScannerScreen()
|
||||
is OnBluetoothDeviceSelected -> onBluetoothDeviceSelected()
|
||||
}.exhaustive
|
||||
}
|
||||
|
||||
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() {
|
||||
state.tryEmit(state.value.ensureConnectedState().copy(showDialog = true))
|
||||
state.tryEmit(state.value.copy(showDialog = true))
|
||||
}
|
||||
|
||||
private fun onWheelSizeChanged(event: OnWheelSizeSelected) {
|
||||
localBroadcast.setWheelSize(event.wheelSize)
|
||||
state.tryEmit(state.value.ensureConnectedState().copy(
|
||||
state.tryEmit(state.value.copy(
|
||||
showDialog = false,
|
||||
wheelSize = event.wheelSizeDisplayInfo
|
||||
))
|
||||
}
|
||||
|
||||
private fun onDisconnectButtonClick() {
|
||||
state.tryEmit(CSCViewNotConnectedState())
|
||||
}
|
||||
|
||||
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())
|
||||
state.tryEmit(state.value.copy(isScreenActive = false))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,11 @@
|
||||
<resources>
|
||||
<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_cadence">Cadence</string>
|
||||
<string name="scs_field_distance">Distance</string>
|
||||
<string name="scs_field_total_distance">Total Distance</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>
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package no.nordicsemi.android.csc
|
||||
|
||||
import androidx.annotation.FloatRange
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
@@ -12,6 +12,12 @@ import org.junit.Assert.*
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
|
||||
println("red: ${colorToHex(0f)}")
|
||||
println("green: ${colorToHex(169f)}")
|
||||
println("blue: ${colorToHex(206f)}")
|
||||
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 {
|
||||
implementation project(":lib_utils")
|
||||
implementation project(":lib_theme")
|
||||
implementation project(":lib_service")
|
||||
|
||||
implementation libs.material
|
||||
implementation libs.google.permissions
|
||||
|
||||
@@ -7,7 +7,8 @@ import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
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
|
||||
|
||||
@Module
|
||||
@@ -24,7 +25,16 @@ internal object HiltModule {
|
||||
fun createSelectedBluetoothDeviceHolder(
|
||||
@ApplicationContext context: Context,
|
||||
bluetoothAdapter: BluetoothAdapter?
|
||||
): SelectedBluetoothDeviceHolder {
|
||||
return SelectedBluetoothDeviceHolder(context, bluetoothAdapter)
|
||||
): no.nordicsemi.android.service.SelectedBluetoothDeviceHolder {
|
||||
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.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
internal class NordicBleScanner @Inject constructor(private val bleAdapter: BluetoothAdapter?) {
|
||||
|
||||
val scannerResult = MutableStateFlow<ScanningResult>(DeviceListResult())
|
||||
class NordicBleScanner @Inject constructor(private val bleAdapter: BluetoothAdapter?) {
|
||||
|
||||
fun getBluetoothStatus(): ScannerStatus {
|
||||
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
|
||||
|
||||
internal enum class ScannerStatus {
|
||||
PERMISSION_REQUIRED, ENABLED, DISABLED, NOT_AVAILABLE
|
||||
enum class ScannerStatus {
|
||||
ENABLED, DISABLED, NOT_AVAILABLE
|
||||
}
|
||||
|
||||
@@ -5,18 +5,42 @@ import android.bluetooth.BluetoothAdapter
|
||||
import android.content.Intent
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
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.material.TopAppBar
|
||||
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
|
||||
internal fun BluetoothNotAvailableScreen() {
|
||||
Text("Bluetooth not available.")
|
||||
fun BluetoothNotAvailableScreen() {
|
||||
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
|
||||
internal fun BluetoothNotEnabledScreen(finish: () -> Unit) {
|
||||
fun BluetoothNotEnabledScreen(finish: () -> Unit) {
|
||||
val contract = ActivityResultContracts.StartActivityForResult()
|
||||
val launcher = rememberLauncherForActivityResult(contract = contract, onResult = {
|
||||
if (it.resultCode == Activity.RESULT_OK) {
|
||||
@@ -25,10 +49,30 @@ internal fun BluetoothNotEnabledScreen(finish: () -> Unit) {
|
||||
})
|
||||
|
||||
Column {
|
||||
Text(text = "Bluetooth not enabled.")
|
||||
Text(text = "To enable Bluetooth please open settings.")
|
||||
Button(onClick = { launcher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) }) {
|
||||
Text(text = "Bluetooth not available.")
|
||||
TopAppBar(title = { Text(text = stringResource(id = R.string.scanner__request_permission)) })
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.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.net.Uri
|
||||
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.Text
|
||||
import androidx.compose.material.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
@@ -14,6 +23,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat.startActivity
|
||||
@@ -24,12 +34,14 @@ import no.nordicsemi.android.scanner.R
|
||||
|
||||
@OptIn(ExperimentalPermissionsApi::class)
|
||||
@Composable
|
||||
internal fun RequestPermissionScreen(finish: () -> Unit) {
|
||||
fun RequestPermissionScreen(finish: () -> Unit) {
|
||||
val permissionsState = rememberMultiplePermissionsState(listOf(
|
||||
android.Manifest.permission.ACCESS_FINE_LOCATION,
|
||||
// android.Manifest.permission.BLUETOOTH_SCAN,
|
||||
// android.Manifest.permission.BLUETOOTH_CONNECT
|
||||
android.Manifest.permission.BLUETOOTH_CONNECT
|
||||
))
|
||||
|
||||
Column {
|
||||
TopAppBar(title = { Text(text = stringResource(id = R.string.scanner__request_permission)) })
|
||||
|
||||
PermissionsRequired(
|
||||
multiplePermissionsState = permissionsState,
|
||||
permissionsNotGrantedContent = { PermissionNotGranted { permissionsState.launchMultiplePermissionRequest() } },
|
||||
@@ -37,6 +49,7 @@ internal fun RequestPermissionScreen(finish: () -> Unit) {
|
||||
) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -45,7 +58,9 @@ private fun PermissionNotGranted(onClick: () -> Unit) {
|
||||
|
||||
if (doNotShowRationale.value) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
@@ -53,18 +68,21 @@ private fun PermissionNotGranted(onClick: () -> Unit) {
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(stringResource(id = R.string.scanner__permission_rationale))
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(textAlign = TextAlign.Center, text = stringResource(id = R.string.scanner__permission_rationale))
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Row {
|
||||
Button(onClick = { onClick() }) {
|
||||
Button(modifier = Modifier.width(100.dp), onClick = { onClick() }) {
|
||||
Text(stringResource(id = R.string.scanner__button_ok))
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(onClick = { doNotShowRationale.value = true }) {
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Button(modifier = Modifier.width(100.dp), onClick = { doNotShowRationale.value = true }) {
|
||||
Text(stringResource(id = R.string.scanner__button_nope))
|
||||
}
|
||||
}
|
||||
@@ -76,7 +94,9 @@ private fun PermissionNotGranted(onClick: () -> Unit) {
|
||||
private fun PermissionNotAvailable() {
|
||||
val context = LocalContext.current
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
package no.nordicsemi.android.scanner.view
|
||||
|
||||
import android.app.Activity
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.companion.AssociationRequest
|
||||
import android.companion.BluetoothLeDeviceFilter
|
||||
import android.companion.CompanionDeviceManager
|
||||
import android.content.Context
|
||||
import android.content.IntentSender
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.IntentSenderRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -16,11 +13,28 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.navigation.NavController
|
||||
|
||||
@Composable
|
||||
fun ScanDeviceScreen(navController: NavController,) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
fun ScanDeviceScreen(finish: (ScanDeviceScreenResult) -> Unit) {
|
||||
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()
|
||||
.build()
|
||||
|
||||
@@ -28,31 +42,6 @@ fun ScanDeviceScreen(navController: NavController,) {
|
||||
.addDeviceFilter(deviceFilter)
|
||||
.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,
|
||||
object : CompanionDeviceManager.Callback() {
|
||||
override fun onDeviceFound(chooserLauncher: IntentSender) {
|
||||
@@ -62,8 +51,10 @@ fun ScanDeviceScreen(navController: NavController,) {
|
||||
|
||||
override fun onFailure(error: CharSequence?) {
|
||||
}
|
||||
}, null)
|
||||
} else {
|
||||
TODO("VERSION.SDK_INT < O")
|
||||
}
|
||||
}, null
|
||||
)
|
||||
}
|
||||
|
||||
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_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__feature_not_available">Feature not available</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__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>
|
||||
|
||||
@@ -2,7 +2,7 @@ apply from: rootProject.file("library.gradle")
|
||||
apply plugin: 'kotlin-parcelize'
|
||||
|
||||
dependencies {
|
||||
implementation project(":feature_scanner")
|
||||
implementation project(":lib_theme")
|
||||
|
||||
implementation libs.nordic.ble.common
|
||||
implementation libs.nordic.log
|
||||
|
||||
@@ -2,4 +2,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="no.nordicsemi.android.service">
|
||||
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
|
||||
</manifest>
|
||||
@@ -42,7 +42,6 @@ import no.nordicsemi.android.ble.BleManagerCallbacks
|
||||
import no.nordicsemi.android.ble.utils.ILogger
|
||||
import no.nordicsemi.android.log.ILogSession
|
||||
import no.nordicsemi.android.log.Logger
|
||||
import no.nordicsemi.android.scanner.tools.SelectedBluetoothDeviceHolder
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -68,7 +67,7 @@ abstract class BleProfileService : LifecycleService(), BleManagerCallbacks {
|
||||
* @return bluetooth device
|
||||
*/
|
||||
protected val bluetoothDevice: BluetoothDevice by lazy {
|
||||
bluetoothDeviceHolder.device ?: throw UnsupportedOperationException(
|
||||
bluetoothDeviceHolder.device ?: throw IllegalArgumentException(
|
||||
"No device address at EXTRA_DEVICE_ADDRESS key"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,16 +22,29 @@
|
||||
package no.nordicsemi.android.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
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() {
|
||||
|
||||
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() {
|
||||
// when user has disconnected from the sensor, we have to cancel the notification that we've created some milliseconds before using unbindService
|
||||
cancelNotification()
|
||||
stopForegroundService()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@@ -87,24 +100,30 @@ abstract class ForegroundBleService<T : BatteryManager<out BatteryManagerCallbac
|
||||
* @param defaults
|
||||
*/
|
||||
private fun createNotification(messageResId: Int, defaults: Int): Notification {
|
||||
TODO()
|
||||
// final Intent parentIntent = new Intent(this, FeaturesActivity.class);
|
||||
// parentIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
// final Intent targetIntent = new Intent(this, CSCActivity.class);
|
||||
//
|
||||
// final Intent disconnect = new Intent(ACTION_DISCONNECT);
|
||||
// final PendingIntent disconnectAction = PendingIntent.getBroadcast(this, DISCONNECT_REQ, disconnect, PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
//
|
||||
// // both activities above have launchMode="singleTask" in the AndroidManifest.xml file, so if the task is already running, it will be resumed
|
||||
// final PendingIntent pendingIntent = PendingIntent.getActivities(this, OPEN_ACTIVITY_REQ, new Intent[]{parentIntent, targetIntent}, PendingIntent.FLAG_UPDATE_CURRENT);
|
||||
// final NotificationCompat.Builder builder = new NotificationCompat.Builder(this, ToolboxApplication.CONNECTED_DEVICE_CHANNEL);
|
||||
// builder.setContentIntent(pendingIntent);
|
||||
// builder.setContentTitle(getString(R.string.app_name)).setContentText(getString(messageResId, getDeviceName()));
|
||||
// builder.setSmallIcon(R.drawable.ic_stat_notify_csc);
|
||||
// builder.setShowWhen(defaults != 0).setDefaults(defaults).setAutoCancel(true).setOngoing(true);
|
||||
// builder.addAction(new NotificationCompat.Action(R.drawable.ic_action_bluetooth, getString(R.string.csc_notification_action_disconnect), disconnectAction));
|
||||
//
|
||||
// return builder.build();
|
||||
createNotificationChannel(CHANNEL_ID)
|
||||
|
||||
val intent: Intent? = packageManager.getLaunchIntentForPackage(packageName)
|
||||
val pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.app_name))
|
||||
.setContentText(getString(messageResId, manager.bluetoothDevice?.name ?: "Device"))
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentIntent(pendingIntent)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createNotificationChannel(channelName: String) {
|
||||
val channel = NotificationChannel(
|
||||
channelName,
|
||||
getString(R.string.channel_connected_devices_title),
|
||||
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.BluetoothDevice
|
||||
@@ -4,4 +4,6 @@
|
||||
<string name="csc_bonding">Bonding with the device…</string>
|
||||
<string name="csc_bonded">The device is now bonded.</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>
|
||||
|
||||
@@ -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
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
object NordicColors {
|
||||
val Primary = Color(0xFF00A9CE)
|
||||
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 AlmostWhite = Color(0xFFDADADA)
|
||||
val NordicBlue = Color(0xFF00A9CE)
|
||||
val NordicBlueDark = Color(0xFF0090B0)
|
||||
val NordicSky = Color(0xFF6AD1E3)
|
||||
val NordicBlueLate = Color(0xFF0033A0)
|
||||
val NordicLake = Color(0xFF0077C8)
|
||||
val NordicLightGray = Color(0xFFD9E1E2)
|
||||
val NordicMediumGray = Color(0xFF768692)
|
||||
val NordicDarkGray = Color(0xFF333F48)
|
||||
val NordicGrass = Color(0xFFD0DF00)
|
||||
val NordicSun = Color(0xFFFFCD00)
|
||||
val NordicRed = Color(0xFFEE2F4E)
|
||||
val NordicFall = Color(0xFFF58220)
|
||||
val NordicLake = Color(0xFF008CD2)
|
||||
|
||||
val NordicDarkGray = ThemedColor(Color(0xFF333F48), Color(0xFFCCCBC8))
|
||||
|
||||
// val NordicGray4 = ThemedColor(Color(0xFFD1D1D6), Color(0xFF3A3A3C))
|
||||
val NordicGray4 = ThemedColor(Color.White, Color(0xFF3A3A3C))
|
||||
|
||||
val NordicGray5 = ThemedColor(Color(0xFFE5E5EA), Color(0xFF2C2C2E))
|
||||
val NordicLightGray = NeutralColor(Color(0xFF929CA2))
|
||||
val NordicMediumGray = NeutralColor(Color(0xFF929CA2))
|
||||
|
||||
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.lightColors
|
||||
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
|
||||
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) {
|
||||
DarkColorPalette
|
||||
darkColorPalette
|
||||
} else {
|
||||
LightColorPalette
|
||||
lightColorPalette
|
||||
}
|
||||
|
||||
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 |