Add HRS service

This commit is contained in:
Sylwester Zieliński
2021-09-27 13:50:48 +02:00
parent 3ef57bf5fd
commit 7a171a1402
112 changed files with 1837 additions and 635 deletions

View File

@@ -51,8 +51,10 @@ dependencies {
//Hilt requires to implement every module in the main app module //Hilt requires to implement every module in the main app module
//https://github.com/google/dagger/issues/2123 //https://github.com/google/dagger/issues/2123
implementation project(":feature_csc") implementation project(":feature_csc")
implementation project(":lib_theme") implementation project(":feature_hrs")
implementation project(':feature_scanner') implementation project(':feature_scanner')
implementation project(":lib_theme")
implementation project(":lib_utils")
implementation libs.nordic.ble.common implementation libs.nordic.ble.common

View File

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

View File

@@ -1,86 +1,105 @@
package no.nordicsemi.android.nrftoolbox package no.nordicsemi.android.nrftoolbox
import androidx.annotation.DrawableRes import androidx.activity.OnBackPressedCallback
import androidx.annotation.StringRes import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.TopAppBar import androidx.compose.material.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.draw.clip import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.graphics.Color import androidx.compose.runtime.SideEffect
import androidx.compose.ui.layout.ContentScale import androidx.compose.runtime.collectAsState
import androidx.compose.ui.res.painterResource import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import no.nordicsemi.android.csc.CSCRoute import no.nordicsemi.android.csc.view.CscScreen
import no.nordicsemi.android.hrs.view.HRSScreen
import no.nordicsemi.android.scanner.view.BluetoothNotAvailableScreen
import no.nordicsemi.android.scanner.view.BluetoothNotEnabledScreen
import no.nordicsemi.android.scanner.view.RequestPermissionScreen
import no.nordicsemi.android.scanner.view.ScanDeviceScreen
import no.nordicsemi.android.scanner.view.ScanDeviceScreenResult
import no.nordicsemi.android.utils.exhaustive
@Composable @Composable
fun HomeScreen() { fun HomeScreen() {
val navController = rememberNavController() val navController = rememberNavController()
NavHost(navController = navController, startDestination = "home") { val viewModel = hiltViewModel<NavigationViewModel>()
composable("home") { HomeView(navController) } val continueAction: () -> Unit = { viewModel.finish() }
composable("csc-route") { CSCRoute() } val state = viewModel.state.collectAsState().value
BackHandler { viewModel.navigateUp() }
NavHost(navController = navController, startDestination = NavDestination.HOME.id) {
composable(NavDestination.HOME.id) { HomeView { viewModel.navigate(it) } }
composable(NavDestination.CSC.id) { CscScreen { viewModel.navigateUp() } }
composable(NavDestination.HRS.id) { HRSScreen { viewModel.navigateUp() } }
composable(NavDestination.REQUEST_PERMISSION.id) { RequestPermissionScreen(continueAction) }
composable(NavDestination.BLUETOOTH_NOT_AVAILABLE.id) { BluetoothNotAvailableScreen() }
composable(NavDestination.BLUETOOTH_NOT_ENABLED.id) {
BluetoothNotEnabledScreen(continueAction)
}
composable(NavDestination.DEVICE_NOT_CONNECTED.id) {
ScanDeviceScreen {
when (it) {
ScanDeviceScreenResult.SUCCESS -> viewModel.finish()
ScanDeviceScreenResult.CANCEL -> viewModel.navigateUp()
}.exhaustive
}
}
}
LaunchedEffect(state) {
navController.navigate(state.id)
} }
} }
@Composable @Composable
fun HomeView(navHostController: NavController) { fun HomeView(callback: (NavDestination) -> Unit) {
Column { Column {
TopAppBar(title = { Text(text = stringResource(id = R.string.app_name)) }) TopAppBar(title = { Text(text = stringResource(id = R.string.app_name)) })
FeatureButton(R.drawable.ic_csc, R.string.csc_module) { navHostController.navigate("csc-route") } FeatureButton(R.drawable.ic_csc, R.string.csc_module) { callback(NavDestination.CSC) }
FeatureButton(R.drawable.ic_hrs, R.string.hrs_module) { callback(NavDestination.HRS) }
} }
} }
@Composable @Composable
fun FeatureButton(@DrawableRes iconId: Int, @StringRes nameId: Int, onClick: () -> Unit) { private fun BackHandler(enabled: Boolean = true, onBack: () -> Unit) {
Button( val currentOnBack = rememberUpdatedState(onBack)
modifier = Modifier.fillMaxWidth(), val backCallback = remember {
onClick = { onClick() }, object : OnBackPressedCallback(enabled) {
colors = ButtonDefaults.buttonColors(backgroundColor = Color.Transparent) override fun handleOnBackPressed() {
) { currentOnBack.value()
Image(
painter = painterResource(iconId),
contentDescription = stringResource(id = nameId),
contentScale = ContentScale.Crop,
modifier = Modifier
.size(64.dp)
.clip(CircleShape)
.background(Color.White)
)
Row(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Text(
text = stringResource(id = nameId),
modifier = Modifier.padding(16.dp),
)
} }
} }
} }
SideEffect {
backCallback.isEnabled = enabled
}
val backDispatcher = checkNotNull(LocalOnBackPressedDispatcherOwner.current) {
"No OnBackPressedDispatcherOwner was provided via LocalOnBackPressedDispatcherOwner"
}.onBackPressedDispatcher
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner, backDispatcher) {
backDispatcher.addCallback(lifecycleOwner, backCallback)
onDispose {
backCallback.remove()
}
}
}
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
fun DefaultPreview() { fun DefaultPreview() {
HomeView(rememberNavController()) HomeView { }
} }

View File

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

View File

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

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -1,5 +1,4 @@
<resources> <resources>
<string name="app_name">nRF Toolbox</string>
<string name="csc_module">CSC</string> <string name="csc_module">CSC</string>
<string name="hrs_module">HRS</string>
</resources> </resources>

View File

@@ -4,7 +4,6 @@ apply plugin: 'kotlin-parcelize'
dependencies { dependencies {
implementation project(":lib_service") implementation project(":lib_service")
implementation project(":lib_theme") implementation project(":lib_theme")
implementation project(':feature_scanner')
implementation project(":lib_utils") implementation project(":lib_utils")
implementation libs.nordic.ble.common implementation libs.nordic.ble.common

View File

@@ -1,12 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="no.nordicsemi.android.csc"> package="no.nordicsemi.android.csc">
<uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
tools:ignore="CoarseFineLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<application> <application>

View File

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

View File

@@ -4,10 +4,10 @@ import android.bluetooth.BluetoothDevice
import android.os.Parcelable import android.os.Parcelable
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
sealed class CSCServiceEvent : Parcelable internal sealed class CSCServiceEvent : Parcelable
@Parcelize @Parcelize
data class OnDistanceChangedEvent( internal data class OnDistanceChangedEvent(
val bluetoothDevice: BluetoothDevice, val bluetoothDevice: BluetoothDevice,
val speed: Float, val speed: Float,
val distance: Float, val distance: Float,
@@ -15,14 +15,14 @@ data class OnDistanceChangedEvent(
) : CSCServiceEvent() ) : CSCServiceEvent()
@Parcelize @Parcelize
data class CrankDataChanged( internal data class CrankDataChanged(
val bluetoothDevice: BluetoothDevice, val bluetoothDevice: BluetoothDevice,
val crankCadence: Int, val crankCadence: Int,
val gearRatio: Float val gearRatio: Float
) : CSCServiceEvent() ) : CSCServiceEvent()
@Parcelize @Parcelize
data class OnBatteryLevelChanged( internal data class OnBatteryLevelChanged(
val device: BluetoothDevice, val device: BluetoothDevice,
val batteryLevel: Int val batteryLevel: Int
) : CSCServiceEvent() ) : CSCServiceEvent()

View File

@@ -9,7 +9,7 @@ import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class CSCDataReadBroadcast @Inject constructor() : BluetoothDataReadBroadcast<CSCServiceEvent>() { internal class CSCDataReadBroadcast @Inject constructor() : BluetoothDataReadBroadcast<CSCServiceEvent>() {
private val _wheelSize = MutableSharedFlow<Int>( private val _wheelSize = MutableSharedFlow<Int>(
replay = 1, replay = 1,

View File

@@ -35,6 +35,12 @@ import no.nordicsemi.android.log.LogContract
import no.nordicsemi.android.service.BatteryManager import no.nordicsemi.android.service.BatteryManager
import java.util.* import java.util.*
/** Cycling Speed and Cadence service UUID. */
private val CYCLING_SPEED_AND_CADENCE_SERVICE_UUID = UUID.fromString("00001816-0000-1000-8000-00805f9b34fb")
/** Cycling Speed and Cadence Measurement characteristic UUID. */
private val CSC_MEASUREMENT_CHARACTERISTIC_UUID = UUID.fromString("00002A5B-0000-1000-8000-00805f9b34fb")
internal class CSCManager(context: Context) : BatteryManager<CSCManagerCallbacks>(context) { internal class CSCManager(context: Context) : BatteryManager<CSCManagerCallbacks>(context) {
private var cscMeasurementCharacteristic: BluetoothGattCharacteristic? = null private var cscMeasurementCharacteristic: BluetoothGattCharacteristic? = null
@@ -114,14 +120,4 @@ internal class CSCManager(context: Context) : BatteryManager<CSCManagerCallbacks
override fun onServicesInvalidated() {} override fun onServicesInvalidated() {}
} }
companion object {
/** Cycling Speed and Cadence service UUID. */
val CYCLING_SPEED_AND_CADENCE_SERVICE_UUID =
UUID.fromString("00001816-0000-1000-8000-00805f9b34fb")
/** Cycling Speed and Cadence Measurement characteristic UUID. */
private val CSC_MEASUREMENT_CHARACTERISTIC_UUID =
UUID.fromString("00002A5B-0000-1000-8000-00805f9b34fb")
}
} }

View File

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

View File

@@ -1,7 +1,5 @@
package no.nordicsemi.android.csc.view package no.nordicsemi.android.csc.view
import android.bluetooth.BluetoothDevice
internal sealed class CSCViewEvent internal sealed class CSCViewEvent
internal object OnShowEditWheelSizeDialogButtonClick : CSCViewEvent() internal object OnShowEditWheelSizeDialogButtonClick : CSCViewEvent()
@@ -11,9 +9,3 @@ internal data class OnWheelSizeSelected(val wheelSize: Int, val wheelSizeDisplay
internal data class OnSelectedSpeedUnitSelected(val selectedSpeedUnit: SpeedUnit) : CSCViewEvent() internal data class OnSelectedSpeedUnitSelected(val selectedSpeedUnit: SpeedUnit) : CSCViewEvent()
internal object OnDisconnectButtonClick : CSCViewEvent() internal object OnDisconnectButtonClick : CSCViewEvent()
internal object OnConnectButtonClick : CSCViewEvent()
internal object OnMovedToScannerScreen : CSCViewEvent()
internal data class OnBluetoothDeviceSelected(val device: BluetoothDevice) : CSCViewEvent()

View File

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

View File

@@ -1,143 +1,52 @@
package no.nordicsemi.android.csc.view package no.nordicsemi.android.csc.view
import android.bluetooth.BluetoothDevice
import android.content.Intent import android.content.Intent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.Button
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.TopAppBar import androidx.compose.material.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import no.nordicsemi.android.csc.R import no.nordicsemi.android.csc.R
import no.nordicsemi.android.csc.service.CSCService import no.nordicsemi.android.csc.service.CSCService
import no.nordicsemi.android.csc.viewmodel.CSCViewState
import no.nordicsemi.android.csc.viewmodel.CscViewModel import no.nordicsemi.android.csc.viewmodel.CscViewModel
import no.nordicsemi.android.utils.exhaustive
import no.nordicsemi.android.utils.isServiceRunning import no.nordicsemi.android.utils.isServiceRunning
@Composable @Composable
internal fun CscScreen(navController: NavController, viewModel: CscViewModel = hiltViewModel()) { fun CscScreen(finishAction: () -> Unit) {
val viewModel: CscViewModel = hiltViewModel()
val secondScreenResult = navController.currentBackStackEntry
?.savedStateHandle
?.getLiveData<BluetoothDevice>("result")?.observeAsState()
secondScreenResult?.value?.let {
viewModel.onEvent(OnBluetoothDeviceSelected(it))
navController.currentBackStackEntry
?.savedStateHandle
?.set("result", null)
}
val state = viewModel.state.collectAsState().value val state = viewModel.state.collectAsState().value
CSCView(navController, state) { viewModel.onEvent(it) } val context = LocalContext.current
LaunchedEffect(state.isScreenActive) {
if (!state.isScreenActive) {
finishAction()
}
if (context.isServiceRunning(CSCService::class.java.name)) {
val intent = Intent(context, CSCService::class.java)
context.stopService(intent)
}
}
LaunchedEffect("start-service") {
if (!context.isServiceRunning(CSCService::class.java.name)) {
val intent = Intent(context, CSCService::class.java)
context.startService(intent)
}
}
CSCView(state) { viewModel.onEvent(it) }
} }
@Composable @Composable
private fun CSCView(navController: NavController, state: CSCViewState, onEvent: (CSCViewEvent) -> Unit) { private fun CSCView(state: CSCViewState, onEvent: (CSCViewEvent) -> Unit) {
Column { Column {
TopAppBar(title = { Text(text = stringResource(id = R.string.csc_title)) }) TopAppBar(title = { Text(text = stringResource(id = R.string.csc_title)) })
when (state) { ContentView(state) { onEvent(it) }
is CSCViewConnectedState -> ConnectedView(state) { onEvent(it) }
is CSCViewNotConnectedState -> NotConnectedScreen(navController, state) {
onEvent(it)
}
}.exhaustive
} }
} }
@Composable
private fun NotConnectedScreen(
navController: NavController,
state: CSCViewNotConnectedState,
onEvent: (CSCViewEvent) -> Unit
) {
if (state.showScannerDialog) {
navController.navigate("scanner-destination")
onEvent(OnMovedToScannerScreen)
}
if (LocalContext.current.isServiceRunning(CSCService::class.java.name)) {
val intent = Intent(LocalContext.current, CSCService::class.java)
LocalContext.current.stopService(intent)
}
NotConnectedView(onEvent)
LocalContext.current.stopService(Intent(LocalContext.current, CSCService::class.java))
}
@Composable
private fun NotConnectedView(
onEvent: (CSCViewEvent) -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = stringResource(id = R.string.csc_no_connection))
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { onEvent(OnConnectButtonClick) }) {
Text(text = stringResource(id = R.string.csc_connect))
}
}
}
@Composable
private fun ConnectedView(state: CSCViewConnectedState, onEvent: (CSCViewEvent) -> Unit) {
if (state.showDialog) {
SelectWheelSizeDialog { onEvent(it) }
}
if (!LocalContext.current.isServiceRunning(CSCService::class.java.name)) {
val intent = Intent(LocalContext.current, CSCService::class.java)
LocalContext.current.startService(intent)
}
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
WheelSizeView(state, onEvent)
SpeedUnitRadioGroup(state.selectedSpeedUnit) { onEvent(it) }
SensorsReadingView(state = state)
Button(onClick = { onEvent(OnDisconnectButtonClick) }) {
Text(text = stringResource(id = R.string.csc_disconnect))
}
}
}
@Preview
@Composable
private fun NotConnectedPreview() {
NotConnectedView { }
}
@Preview
@Composable
private fun ConnectedPreview() {
ConnectedView(CSCViewConnectedState()) { }
}

View File

@@ -1,18 +1,29 @@
package no.nordicsemi.android.csc.view package no.nordicsemi.android.csc.view
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.Card
import androidx.compose.material.TabRowDefaults.Divider
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import no.nordicsemi.android.csc.R import no.nordicsemi.android.csc.R
import no.nordicsemi.android.theme.Background import no.nordicsemi.android.theme.NordicColors
import no.nordicsemi.android.theme.NordicColors.NordicLightGray
import no.nordicsemi.android.theme.TestTheme import no.nordicsemi.android.theme.TestTheme
@Composable @Composable
@@ -27,13 +38,47 @@ private fun SelectWheelSizeView(onEvent: (OnWheelSizeSelected) -> Unit) {
val wheelEntries = stringArrayResource(R.array.wheel_entries) val wheelEntries = stringArrayResource(R.array.wheel_entries)
val wheelValues = stringArrayResource(R.array.wheel_values) val wheelValues = stringArrayResource(R.array.wheel_values)
Box(Modifier.padding(16.dp)) { Card(
Column(modifier = Background.whiteRoundedCorners()) { modifier = Modifier.height(300.dp),
Text(text = "Wheel size") backgroundColor = NordicColors.NordicGray4.value(),
shape = RoundedCornerShape(10.dp),
elevation = 0.dp
) {
Column {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Wheel size",
fontSize = 28.sp,
fontWeight = FontWeight.Bold
)
}
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
wheelEntries.forEachIndexed { i, entry -> wheelEntries.forEachIndexed { i, entry ->
Text(text = entry, modifier = Modifier.clickable { Spacer(modifier = Modifier.height(4.dp))
Text(
text = entry,
fontSize = 16.sp,
modifier = Modifier
.fillMaxWidth()
.clickable {
onEvent(OnWheelSizeSelected(wheelValues[i].toInt(), entry)) onEvent(OnWheelSizeSelected(wheelValues[i].toInt(), entry))
}) }
)
if (i != wheelEntries.size - 1) {
Spacer(modifier = Modifier.height(4.dp))
Divider(color = NordicLightGray.value(), thickness = 1.dp/2)
}
}
} }
} }
} }

View File

@@ -1,55 +1,52 @@
package no.nordicsemi.android.csc.view package no.nordicsemi.android.csc.view
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.material.Text import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import no.nordicsemi.android.csc.R import no.nordicsemi.android.csc.R
import no.nordicsemi.android.theme.Background import no.nordicsemi.android.csc.viewmodel.CSCViewState
import no.nordicsemi.android.theme.NordicColors
import no.nordicsemi.android.theme.view.BatteryLevelView
import no.nordicsemi.android.theme.view.KeyValueField
@Composable @Composable
internal fun SensorsReadingView(state: CSCViewConnectedState) { internal fun SensorsReadingView(state: CSCViewState) {
Column { Card(
Column(modifier = Background.whiteRoundedCorners()) { backgroundColor = NordicColors.NordicGray4.value(),
shape = RoundedCornerShape(10.dp),
elevation = 0.dp
) {
Column(modifier = Modifier.padding(16.dp)) {
KeyValueField(stringResource(id = R.string.scs_field_speed), state.displaySpeed()) KeyValueField(stringResource(id = R.string.scs_field_speed), state.displaySpeed())
Spacer(modifier = Modifier.height(4.dp))
KeyValueField(stringResource(id = R.string.scs_field_cadence), state.displayCadence()) KeyValueField(stringResource(id = R.string.scs_field_cadence), state.displayCadence())
Spacer(modifier = Modifier.height(4.dp))
KeyValueField(stringResource(id = R.string.scs_field_distance), state.displayDistance()) KeyValueField(stringResource(id = R.string.scs_field_distance), state.displayDistance())
Spacer(modifier = Modifier.height(4.dp))
KeyValueField( KeyValueField(
stringResource(id = R.string.scs_field_total_distance), stringResource(id = R.string.scs_field_total_distance),
state.displayTotalDistance() state.displayTotalDistance()
) )
KeyValueField(stringResource(id = R.string.scs_field_gear_ratio), state.displaySpeed()) Spacer(modifier = Modifier.height(4.dp))
KeyValueField(stringResource(id = R.string.scs_field_gear_ratio), state.displayGearRatio())
}
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Column(modifier = Background.whiteRoundedCorners()) { BatteryLevelView(state.batteryLevel)
KeyValueField(stringResource(id = R.string.scs_field_battery), state.displayBatteryLever())
}
}
}
@Composable
private fun KeyValueField(key: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = key)
Text(text = value)
}
} }
@Preview @Preview
@Composable @Composable
private fun Preview() { private fun Preview() {
SensorsReadingView(CSCViewConnectedState()) SensorsReadingView(CSCViewState())
} }

View File

@@ -2,8 +2,10 @@ package no.nordicsemi.android.csc.view
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.RadioButton import androidx.compose.material.RadioButton
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -16,7 +18,7 @@ internal fun SpeedUnitRadioGroup(
onEvent: (OnSelectedSpeedUnitSelected) -> Unit onEvent: (OnSelectedSpeedUnitSelected) -> Unit
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(16.dp), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly horizontalArrangement = Arrangement.SpaceEvenly
) { ) {
SpeedUnitRadioButton(currentUnit, SpeedUnit.KM_H, onEvent) SpeedUnitRadioButton(currentUnit, SpeedUnit.KM_H, onEvent)
@@ -36,6 +38,7 @@ internal fun SpeedUnitRadioButton(
selected = (selectedUnit == displayedUnit), selected = (selectedUnit == displayedUnit),
onClick = { onEvent(OnSelectedSpeedUnitSelected(displayedUnit)) } onClick = { onEvent(OnSelectedSpeedUnitSelected(displayedUnit)) }
) )
Spacer(modifier = Modifier.width(4.dp))
Text(text = createSpeedUnitLabel(displayedUnit)) Text(text = createSpeedUnitLabel(displayedUnit))
} }
} }

View File

@@ -12,9 +12,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import no.nordicsemi.android.csc.R import no.nordicsemi.android.csc.R
import no.nordicsemi.android.csc.viewmodel.CSCViewState
@Composable @Composable
internal fun WheelSizeView(state: CSCViewConnectedState, onEvent: (CSCViewEvent) -> Unit) { internal fun WheelSizeView(state: CSCViewState, onEvent: (CSCViewEvent) -> Unit) {
OutlinedTextField( OutlinedTextField(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
value = state.wheelSize, value = state.wheelSize,
@@ -35,5 +36,5 @@ private fun EditIcon(onEvent: (CSCViewEvent) -> Unit) {
@Preview @Preview
@Composable @Composable
private fun WheelSizeViewPreview() { private fun WheelSizeViewPreview() {
WheelSizeView(CSCViewConnectedState()) { } WheelSizeView(CSCViewState()) { }
} }

View File

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

View File

@@ -13,28 +13,20 @@ import no.nordicsemi.android.csc.events.CrankDataChanged
import no.nordicsemi.android.csc.events.OnBatteryLevelChanged import no.nordicsemi.android.csc.events.OnBatteryLevelChanged
import no.nordicsemi.android.csc.events.OnDistanceChangedEvent import no.nordicsemi.android.csc.events.OnDistanceChangedEvent
import no.nordicsemi.android.csc.service.CSCDataReadBroadcast import no.nordicsemi.android.csc.service.CSCDataReadBroadcast
import no.nordicsemi.android.csc.view.CSCViewConnectedState
import no.nordicsemi.android.csc.view.CSCViewEvent import no.nordicsemi.android.csc.view.CSCViewEvent
import no.nordicsemi.android.csc.view.CSCViewNotConnectedState
import no.nordicsemi.android.csc.view.CSCViewState
import no.nordicsemi.android.csc.view.OnBluetoothDeviceSelected
import no.nordicsemi.android.csc.view.OnConnectButtonClick
import no.nordicsemi.android.csc.view.OnDisconnectButtonClick import no.nordicsemi.android.csc.view.OnDisconnectButtonClick
import no.nordicsemi.android.csc.view.OnMovedToScannerScreen
import no.nordicsemi.android.csc.view.OnSelectedSpeedUnitSelected import no.nordicsemi.android.csc.view.OnSelectedSpeedUnitSelected
import no.nordicsemi.android.csc.view.OnShowEditWheelSizeDialogButtonClick import no.nordicsemi.android.csc.view.OnShowEditWheelSizeDialogButtonClick
import no.nordicsemi.android.csc.view.OnWheelSizeSelected import no.nordicsemi.android.csc.view.OnWheelSizeSelected
import no.nordicsemi.android.scanner.tools.SelectedBluetoothDeviceHolder
import no.nordicsemi.android.utils.exhaustive import no.nordicsemi.android.utils.exhaustive
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
internal class CscViewModel @Inject constructor( internal class CscViewModel @Inject constructor(
private val localBroadcast: CSCDataReadBroadcast, private val localBroadcast: CSCDataReadBroadcast
private val deviceHolder: SelectedBluetoothDeviceHolder
) : ViewModel() { ) : ViewModel() {
val state = MutableStateFlow(createInitialState()) val state = MutableStateFlow(CSCViewState())
init { init {
localBroadcast.events.onEach { localBroadcast.events.onEach {
@@ -42,10 +34,6 @@ internal class CscViewModel @Inject constructor(
}.launchIn(viewModelScope) }.launchIn(viewModelScope)
} }
private fun createInitialState(): CSCViewState {
return deviceHolder.device?.let { CSCViewConnectedState() } ?: CSCViewNotConnectedState()
}
private fun consumeEvent(event: CSCServiceEvent) { private fun consumeEvent(event: CSCServiceEvent) {
val newValue = when (event) { val newValue = when (event) {
is CrankDataChanged -> createNewState(event) is CrankDataChanged -> createNewState(event)
@@ -55,21 +43,21 @@ internal class CscViewModel @Inject constructor(
state.value = newValue state.value = newValue
} }
private fun createNewState(event: CrankDataChanged): CSCViewConnectedState { private fun createNewState(event: CrankDataChanged): CSCViewState {
return state.value.ensureConnectedState().copy( return state.value.copy(
cadence = event.crankCadence, cadence = event.crankCadence,
gearRatio = event.gearRatio gearRatio = event.gearRatio
) )
} }
private fun createNewState(event: OnBatteryLevelChanged): CSCViewConnectedState { private fun createNewState(event: OnBatteryLevelChanged): CSCViewState {
return state.value.ensureConnectedState().copy( return state.value.copy(
batteryLevel = event.batteryLevel batteryLevel = event.batteryLevel
) )
} }
private fun createNewState(event: OnDistanceChangedEvent): CSCViewConnectedState { private fun createNewState(event: OnDistanceChangedEvent): CSCViewState {
return state.value.ensureConnectedState().copy( return state.value.copy(
speed = event.speed, speed = event.speed,
distance = event.distance, distance = event.distance,
totalDistance = event.totalDistance totalDistance = event.totalDistance
@@ -82,41 +70,26 @@ internal class CscViewModel @Inject constructor(
OnShowEditWheelSizeDialogButtonClick -> onShowDialogEvent() OnShowEditWheelSizeDialogButtonClick -> onShowDialogEvent()
is OnWheelSizeSelected -> onWheelSizeChanged(event) is OnWheelSizeSelected -> onWheelSizeChanged(event)
OnDisconnectButtonClick -> onDisconnectButtonClick() OnDisconnectButtonClick -> onDisconnectButtonClick()
OnConnectButtonClick -> onConnectButtonClick()
OnMovedToScannerScreen -> onOnMovedToScannerScreen()
is OnBluetoothDeviceSelected -> onBluetoothDeviceSelected()
}.exhaustive }.exhaustive
} }
private fun onSelectedSpeedUnit(event: OnSelectedSpeedUnitSelected) { private fun onSelectedSpeedUnit(event: OnSelectedSpeedUnitSelected) {
state.tryEmit(state.value.ensureConnectedState().copy(selectedSpeedUnit = event.selectedSpeedUnit)) state.tryEmit(state.value.copy(selectedSpeedUnit = event.selectedSpeedUnit))
} }
private fun onShowDialogEvent() { private fun onShowDialogEvent() {
state.tryEmit(state.value.ensureConnectedState().copy(showDialog = true)) state.tryEmit(state.value.copy(showDialog = true))
} }
private fun onWheelSizeChanged(event: OnWheelSizeSelected) { private fun onWheelSizeChanged(event: OnWheelSizeSelected) {
localBroadcast.setWheelSize(event.wheelSize) localBroadcast.setWheelSize(event.wheelSize)
state.tryEmit(state.value.ensureConnectedState().copy( state.tryEmit(state.value.copy(
showDialog = false, showDialog = false,
wheelSize = event.wheelSizeDisplayInfo wheelSize = event.wheelSizeDisplayInfo
)) ))
} }
private fun onDisconnectButtonClick() { private fun onDisconnectButtonClick() {
state.tryEmit(CSCViewNotConnectedState()) state.tryEmit(state.value.copy(isScreenActive = false))
}
private fun onConnectButtonClick() {
state.tryEmit(state.value.ensureDisconnectedState().copy(showScannerDialog = true))
}
private fun onOnMovedToScannerScreen() {
state.tryEmit(state.value.ensureDisconnectedState().copy(showScannerDialog = false))
}
private fun onBluetoothDeviceSelected() {
state.tryEmit(CSCViewConnectedState())
} }
} }

View File

@@ -2,16 +2,11 @@
<resources> <resources>
<string name="csc_title">Cyclic and speed cadence</string> <string name="csc_title">Cyclic and speed cadence</string>
<string name="csc_disconnect">Disconnect</string>
<string name="csc_no_connection">No device connected</string>
<string name="csc_connect">Connect</string>
<string name="scs_field_speed">Speed</string> <string name="scs_field_speed">Speed</string>
<string name="scs_field_cadence">Cadence</string> <string name="scs_field_cadence">Cadence</string>
<string name="scs_field_distance">Distance</string> <string name="scs_field_distance">Distance</string>
<string name="scs_field_total_distance">Total Distance</string> <string name="scs_field_total_distance">Total Distance</string>
<string name="scs_field_gear_ratio">Gear Ratio</string> <string name="scs_field_gear_ratio">Gear Ratio</string>
<string name="scs_field_battery">Battery</string>
<string name="scs_field_wheel_size">Wheel size</string> <string name="scs_field_wheel_size">Wheel size</string>

View File

@@ -1,9 +1,9 @@
package no.nordicsemi.android.csc package no.nordicsemi.android.csc
import androidx.annotation.FloatRange
import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import org.junit.Assert.*
/** /**
* Example local unit test, which will execute on the development machine (host). * Example local unit test, which will execute on the development machine (host).
* *
@@ -12,6 +12,12 @@ import org.junit.Assert.*
class ExampleUnitTest { class ExampleUnitTest {
@Test @Test
fun addition_isCorrect() { fun addition_isCorrect() {
println("red: ${colorToHex(0f)}")
println("green: ${colorToHex(169f)}")
println("blue: ${colorToHex(206f)}")
assertEquals(4, 2 + 2) assertEquals(4, 2 + 2)
} }
private fun colorToHex(@FloatRange(from = 0.0, to = 1.0) value: Float) = Integer.toHexString((0xFF * value).toInt())
} }

28
feature_hrs/build.gradle Normal file
View 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
}

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
package no.nordicsemi.android.hrs.view
sealed class HRSScreenViewEvent
object DisconnectEvent : HRSScreenViewEvent()

View File

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

View File

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

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

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="hrs_title">HRS</string>
</resources>

View File

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

View File

@@ -4,6 +4,7 @@ apply plugin: 'kotlin-parcelize'
dependencies { dependencies {
implementation project(":lib_utils") implementation project(":lib_utils")
implementation project(":lib_theme") implementation project(":lib_theme")
implementation project(":lib_service")
implementation libs.material implementation libs.material
implementation libs.google.permissions implementation libs.google.permissions

View File

@@ -7,7 +7,8 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import no.nordicsemi.android.scanner.tools.SelectedBluetoothDeviceHolder import no.nordicsemi.android.scanner.tools.PermissionHelper
import no.nordicsemi.android.service.SelectedBluetoothDeviceHolder
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@@ -24,7 +25,16 @@ internal object HiltModule {
fun createSelectedBluetoothDeviceHolder( fun createSelectedBluetoothDeviceHolder(
@ApplicationContext context: Context, @ApplicationContext context: Context,
bluetoothAdapter: BluetoothAdapter? bluetoothAdapter: BluetoothAdapter?
): SelectedBluetoothDeviceHolder { ): no.nordicsemi.android.service.SelectedBluetoothDeviceHolder {
return SelectedBluetoothDeviceHolder(context, bluetoothAdapter) return no.nordicsemi.android.service.SelectedBluetoothDeviceHolder(
context,
bluetoothAdapter
)
}
@Singleton
@Provides
fun createPermissionHelper(@ApplicationContext context: Context): PermissionHelper {
return PermissionHelper(context)
} }
} }

View File

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

View File

@@ -2,14 +2,10 @@ package no.nordicsemi.android.scanner.tools
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Inject import javax.inject.Inject
@SuppressLint("MissingPermission") @SuppressLint("MissingPermission")
internal class NordicBleScanner @Inject constructor(private val bleAdapter: BluetoothAdapter?) { class NordicBleScanner @Inject constructor(private val bleAdapter: BluetoothAdapter?) {
val scannerResult = MutableStateFlow<ScanningResult>(DeviceListResult())
fun getBluetoothStatus(): ScannerStatus { fun getBluetoothStatus(): ScannerStatus {
return when { return when {
@@ -19,15 +15,3 @@ internal class NordicBleScanner @Inject constructor(private val bleAdapter: Blue
} }
} }
} }
sealed class ScanningResult
data class DeviceListResult(val devices: List<BluetoothDevice> = emptyList()) : ScanningResult()
object ScanningErrorResult : ScanningResult()
private fun <T> MutableList<T>.addIfNotExist(value: T) {
if (!contains(value)) {
add(value)
}
}

View File

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

View File

@@ -1,5 +1,5 @@
package no.nordicsemi.android.scanner.tools package no.nordicsemi.android.scanner.tools
internal enum class ScannerStatus { enum class ScannerStatus {
PERMISSION_REQUIRED, ENABLED, DISABLED, NOT_AVAILABLE ENABLED, DISABLED, NOT_AVAILABLE
} }

View File

@@ -5,18 +5,42 @@ import android.bluetooth.BluetoothAdapter
import android.content.Intent import android.content.Intent
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material.Button import androidx.compose.material.Button
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import no.nordicsemi.android.scanner.R
@Composable @Composable
internal fun BluetoothNotAvailableScreen() { fun BluetoothNotAvailableScreen() {
Text("Bluetooth not available.") Column {
TopAppBar(title = { Text(text = stringResource(id = R.string.scanner__request_permission)) })
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(stringResource(R.string.scanner__bluetooth_not_available))
}
}
} }
@Composable @Composable
internal fun BluetoothNotEnabledScreen(finish: () -> Unit) { fun BluetoothNotEnabledScreen(finish: () -> Unit) {
val contract = ActivityResultContracts.StartActivityForResult() val contract = ActivityResultContracts.StartActivityForResult()
val launcher = rememberLauncherForActivityResult(contract = contract, onResult = { val launcher = rememberLauncherForActivityResult(contract = contract, onResult = {
if (it.resultCode == Activity.RESULT_OK) { if (it.resultCode == Activity.RESULT_OK) {
@@ -25,10 +49,30 @@ internal fun BluetoothNotEnabledScreen(finish: () -> Unit) {
}) })
Column { Column {
Text(text = "Bluetooth not enabled.") TopAppBar(title = { Text(text = stringResource(id = R.string.scanner__request_permission)) })
Text(text = "To enable Bluetooth please open settings.") Column(
Button(onClick = { launcher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) }) { modifier = Modifier
Text(text = "Bluetooth not available.") .fillMaxWidth()
.fillMaxHeight(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
textAlign = TextAlign.Center,
text = stringResource(id = R.string.scanner__bluetooth_not_enabled)
)
Spacer(Modifier.height(16.dp))
Text(
textAlign = TextAlign.Center,
text = stringResource(id = R.string.scanner__bluetooth_open_settings_info)
)
Spacer(Modifier.height(32.dp))
Button(
colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.secondary),
onClick = { launcher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) }
) {
Text(text = stringResource(id = R.string.scanner__bluetooth_open_settings))
}
} }
} }
} }

View File

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

View File

@@ -4,9 +4,18 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.provider.Settings import android.provider.Settings
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.Button import androidx.compose.material.Button
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
@@ -14,6 +23,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat.startActivity import androidx.core.content.ContextCompat.startActivity
@@ -24,12 +34,14 @@ import no.nordicsemi.android.scanner.R
@OptIn(ExperimentalPermissionsApi::class) @OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
internal fun RequestPermissionScreen(finish: () -> Unit) { fun RequestPermissionScreen(finish: () -> Unit) {
val permissionsState = rememberMultiplePermissionsState(listOf( val permissionsState = rememberMultiplePermissionsState(listOf(
android.Manifest.permission.ACCESS_FINE_LOCATION, android.Manifest.permission.BLUETOOTH_CONNECT
// android.Manifest.permission.BLUETOOTH_SCAN,
// android.Manifest.permission.BLUETOOTH_CONNECT
)) ))
Column {
TopAppBar(title = { Text(text = stringResource(id = R.string.scanner__request_permission)) })
PermissionsRequired( PermissionsRequired(
multiplePermissionsState = permissionsState, multiplePermissionsState = permissionsState,
permissionsNotGrantedContent = { PermissionNotGranted { permissionsState.launchMultiplePermissionRequest() } }, permissionsNotGrantedContent = { PermissionNotGranted { permissionsState.launchMultiplePermissionRequest() } },
@@ -38,6 +50,7 @@ internal fun RequestPermissionScreen(finish: () -> Unit) {
finish() finish()
} }
} }
}
@Composable @Composable
private fun PermissionNotGranted(onClick: () -> Unit) { private fun PermissionNotGranted(onClick: () -> Unit) {
@@ -45,7 +58,9 @@ private fun PermissionNotGranted(onClick: () -> Unit) {
if (doNotShowRationale.value) { if (doNotShowRationale.value) {
Column( Column(
modifier = Modifier.fillMaxWidth().fillMaxHeight(), modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
@@ -53,18 +68,21 @@ private fun PermissionNotGranted(onClick: () -> Unit) {
} }
} else { } else {
Column( Column(
modifier = Modifier.fillMaxWidth().fillMaxHeight(), modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.padding(16.dp),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text(stringResource(id = R.string.scanner__permission_rationale)) Text(textAlign = TextAlign.Center, text = stringResource(id = R.string.scanner__permission_rationale))
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(16.dp))
Row { Row {
Button(onClick = { onClick() }) { Button(modifier = Modifier.width(100.dp), onClick = { onClick() }) {
Text(stringResource(id = R.string.scanner__button_ok)) Text(stringResource(id = R.string.scanner__button_ok))
} }
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(16.dp))
Button(onClick = { doNotShowRationale.value = true }) { Button(modifier = Modifier.width(100.dp), onClick = { doNotShowRationale.value = true }) {
Text(stringResource(id = R.string.scanner__button_nope)) Text(stringResource(id = R.string.scanner__button_nope))
} }
} }
@@ -76,7 +94,9 @@ private fun PermissionNotGranted(onClick: () -> Unit) {
private fun PermissionNotAvailable() { private fun PermissionNotAvailable() {
val context = LocalContext.current val context = LocalContext.current
Column( Column(
modifier = Modifier.fillMaxWidth().fillMaxHeight(), modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {

View File

@@ -1,14 +1,11 @@
package no.nordicsemi.android.scanner.view package no.nordicsemi.android.scanner.view
import android.app.Activity import android.app.Activity
import android.bluetooth.BluetoothDevice
import android.bluetooth.le.ScanResult
import android.companion.AssociationRequest import android.companion.AssociationRequest
import android.companion.BluetoothLeDeviceFilter import android.companion.BluetoothLeDeviceFilter
import android.companion.CompanionDeviceManager import android.companion.CompanionDeviceManager
import android.content.Context import android.content.Context
import android.content.IntentSender import android.content.IntentSender
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.IntentSenderRequest import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -16,11 +13,28 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavController
@Composable @Composable
fun ScanDeviceScreen(navController: NavController,) { fun ScanDeviceScreen(finish: (ScanDeviceScreenResult) -> Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val deviceManager =
LocalContext.current.getSystemService(Context.COMPANION_DEVICE_SERVICE) as CompanionDeviceManager
val contract = ActivityResultContracts.StartIntentSenderForResult()
val launcher = rememberLauncherForActivityResult(contract = contract) {
val result = if (it.resultCode == Activity.RESULT_OK) {
ScanDeviceScreenResult.SUCCESS
} else {
ScanDeviceScreenResult.CANCEL
}
finish(result)
}
val hasBeenInvoked = remember { mutableStateOf(false) }
if (hasBeenInvoked.value) {
return
}
hasBeenInvoked.value = true
val deviceFilter = BluetoothLeDeviceFilter.Builder() val deviceFilter = BluetoothLeDeviceFilter.Builder()
.build() .build()
@@ -28,31 +42,6 @@ fun ScanDeviceScreen(navController: NavController,) {
.addDeviceFilter(deviceFilter) .addDeviceFilter(deviceFilter)
.build() .build()
val deviceManager =
LocalContext.current.getSystemService(Context.COMPANION_DEVICE_SERVICE) as CompanionDeviceManager
val contract = ActivityResultContracts.StartIntentSenderForResult()
val launcher = rememberLauncherForActivityResult(contract = contract, onResult = {
if (it.resultCode == Activity.RESULT_OK) {
//Sometimes result is ScanResult & sometimes BluetoothDevice
val device: BluetoothDevice = try {
it.data?.getParcelableExtra(CompanionDeviceManager.EXTRA_DEVICE)!!
} catch (e: Exception) {
(it.data?.getParcelableExtra<ScanResult>(CompanionDeviceManager.EXTRA_DEVICE))!!.device
}
navController.previousBackStackEntry
?.savedStateHandle
?.set("result", device)
}
navController.popBackStack()
})
val hasBeenInvoked = remember { mutableStateOf(false) }
if (hasBeenInvoked.value) {
return
}
hasBeenInvoked.value = true
deviceManager.associate(pairingRequest, deviceManager.associate(pairingRequest,
object : CompanionDeviceManager.Callback() { object : CompanionDeviceManager.Callback() {
override fun onDeviceFound(chooserLauncher: IntentSender) { override fun onDeviceFound(chooserLauncher: IntentSender) {
@@ -62,8 +51,10 @@ fun ScanDeviceScreen(navController: NavController,) {
override fun onFailure(error: CharSequence?) { override fun onFailure(error: CharSequence?) {
} }
}, null) }, null
} else { )
TODO("VERSION.SDK_INT < O")
} }
enum class ScanDeviceScreenResult {
SUCCESS, CANCEL
} }

View File

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

View File

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

View File

@@ -4,12 +4,23 @@
<string name="scanner__permission_rationale">The location permission is required when using Bluetooth LE, because surrounding devices can expose user\'s location. Please grant the permission.</string> <string name="scanner__permission_rationale">The location permission is required when using Bluetooth LE, because surrounding devices can expose user\'s location. Please grant the permission.</string>
<string name="scanner__permission_denied">Location permission denied. Please, grant us access on the Settings screen.</string> <string name="scanner__permission_denied">Location permission denied. Please, grant us access on the Settings screen.</string>
<string name="scanner__button_ok">OK</string>
<string name="scanner__button_nope">Nope</string>
<string name="scanner__open_settings">Open settings</string> <string name="scanner__open_settings">Open settings</string>
<string name="scanner__feature_not_available">Feature not available</string> <string name="scanner__feature_not_available">Feature not available</string>
<string name="scanner__list_of_devices">List of devices</string> <string name="scanner__list_of_devices">List of devices</string>
<string name="scanner__error">Scanning failed due to technical reason.</string> <string name="scanner__error">Scanning failed due to technical reason.</string>
<string name="scanner__no_name">Name: NONE</string> <string name="scanner__no_name">Name: NONE</string>
<string name="csc_no_connection">No device connected</string>
<string name="csc_connect">Connect</string>
<string name="scanner__button_ok">Grant</string>
<string name="scanner__button_nope">Deny</string>
<string name="scanner__request_permission">Request permission</string>
<string name="scanner__bluetooth_not_available">Bluetooth not available.</string>
<string name="scanner__bluetooth_not_enabled">Bluetooth not enabled.</string>
<string name="scanner__bluetooth_open_settings_info">To enable Bluetooth please open settings.</string>
<string name="scanner__bluetooth_open_settings">Open settings</string>
</resources> </resources>

View File

@@ -2,7 +2,7 @@ apply from: rootProject.file("library.gradle")
apply plugin: 'kotlin-parcelize' apply plugin: 'kotlin-parcelize'
dependencies { dependencies {
implementation project(":feature_scanner") implementation project(":lib_theme")
implementation libs.nordic.ble.common implementation libs.nordic.ble.common
implementation libs.nordic.log implementation libs.nordic.log

View File

@@ -2,4 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="no.nordicsemi.android.service"> package="no.nordicsemi.android.service">
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
</manifest> </manifest>

View File

@@ -42,7 +42,6 @@ import no.nordicsemi.android.ble.BleManagerCallbacks
import no.nordicsemi.android.ble.utils.ILogger import no.nordicsemi.android.ble.utils.ILogger
import no.nordicsemi.android.log.ILogSession import no.nordicsemi.android.log.ILogSession
import no.nordicsemi.android.log.Logger import no.nordicsemi.android.log.Logger
import no.nordicsemi.android.scanner.tools.SelectedBluetoothDeviceHolder
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@@ -68,7 +67,7 @@ abstract class BleProfileService : LifecycleService(), BleManagerCallbacks {
* @return bluetooth device * @return bluetooth device
*/ */
protected val bluetoothDevice: BluetoothDevice by lazy { protected val bluetoothDevice: BluetoothDevice by lazy {
bluetoothDeviceHolder.device ?: throw UnsupportedOperationException( bluetoothDeviceHolder.device ?: throw IllegalArgumentException(
"No device address at EXTRA_DEVICE_ADDRESS key" "No device address at EXTRA_DEVICE_ADDRESS key"
) )
} }

View File

@@ -22,16 +22,29 @@
package no.nordicsemi.android.service package no.nordicsemi.android.service
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.os.Build import android.os.Build
import androidx.core.app.NotificationCompat
private const val CHANNEL_ID = "FOREGROUND_BLE_SERVICE"
abstract class ForegroundBleService<T : BatteryManager<out BatteryManagerCallbacks>> : BleProfileService() { abstract class ForegroundBleService<T : BatteryManager<out BatteryManagerCallbacks>> : BleProfileService() {
protected abstract val manager: T protected abstract val manager: T
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val result = super.onStartCommand(intent, flags, startId)
startForegroundService()
return result
}
override fun onDestroy() { override fun onDestroy() {
// when user has disconnected from the sensor, we have to cancel the notification that we've created some milliseconds before using unbindService // when user has disconnected from the sensor, we have to cancel the notification that we've created some milliseconds before using unbindService
cancelNotification() cancelNotification()
stopForegroundService()
super.onDestroy() super.onDestroy()
} }
@@ -87,24 +100,30 @@ abstract class ForegroundBleService<T : BatteryManager<out BatteryManagerCallbac
* @param defaults * @param defaults
*/ */
private fun createNotification(messageResId: Int, defaults: Int): Notification { private fun createNotification(messageResId: Int, defaults: Int): Notification {
TODO() createNotificationChannel(CHANNEL_ID)
// final Intent parentIntent = new Intent(this, FeaturesActivity.class);
// parentIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); val intent: Intent? = packageManager.getLaunchIntentForPackage(packageName)
// final Intent targetIntent = new Intent(this, CSCActivity.class); val pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE)
//
// final Intent disconnect = new Intent(ACTION_DISCONNECT); return NotificationCompat.Builder(this, CHANNEL_ID)
// final PendingIntent disconnectAction = PendingIntent.getBroadcast(this, DISCONNECT_REQ, disconnect, PendingIntent.FLAG_UPDATE_CURRENT); .setContentTitle(getString(R.string.app_name))
// .setContentText(getString(messageResId, manager.bluetoothDevice?.name ?: "Device"))
// // both activities above have launchMode="singleTask" in the AndroidManifest.xml file, so if the task is already running, it will be resumed .setSmallIcon(R.mipmap.ic_launcher)
// final PendingIntent pendingIntent = PendingIntent.getActivities(this, OPEN_ACTIVITY_REQ, new Intent[]{parentIntent, targetIntent}, PendingIntent.FLAG_UPDATE_CURRENT); .setContentIntent(pendingIntent)
// final NotificationCompat.Builder builder = new NotificationCompat.Builder(this, ToolboxApplication.CONNECTED_DEVICE_CHANNEL); .build()
// builder.setContentIntent(pendingIntent); }
// builder.setContentTitle(getString(R.string.app_name)).setContentText(getString(messageResId, getDeviceName()));
// builder.setSmallIcon(R.drawable.ic_stat_notify_csc); private fun createNotificationChannel(channelName: String) {
// builder.setShowWhen(defaults != 0).setDefaults(defaults).setAutoCancel(true).setOngoing(true); val channel = NotificationChannel(
// builder.addAction(new NotificationCompat.Action(R.drawable.ic_action_bluetooth, getString(R.string.csc_notification_action_disconnect), disconnectAction)); channelName,
// getString(R.string.channel_connected_devices_title),
// return builder.build(); NotificationManager.IMPORTANCE_LOW
)
channel.description = getString(R.string.channel_connected_devices_description)
channel.setShowBadge(false)
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
} }
/** /**

View File

@@ -1,4 +1,4 @@
package no.nordicsemi.android.scanner.tools package no.nordicsemi.android.service
import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice import android.bluetooth.BluetoothDevice

View File

@@ -4,4 +4,6 @@
<string name="csc_bonding">Bonding with the device&#8230;</string> <string name="csc_bonding">Bonding with the device&#8230;</string>
<string name="csc_bonded">The device is now bonded.</string> <string name="csc_bonded">The device is now bonded.</string>
<string name="csc_notification_connected_message">%s is connected.</string> <string name="csc_notification_connected_message">%s is connected.</string>
<string name="channel_connected_devices_title">Background connections</string>
<string name="channel_connected_devices_description">Shows a notification when a device is connected in background.</string>
</resources> </resources>

View File

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

View File

@@ -1,26 +1,67 @@
package no.nordicsemi.android.theme package no.nordicsemi.android.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
object NordicColors { object NordicColors {
val Primary = Color(0xFF00A9CE) val AlmostWhite = Color(0xFFDADADA)
val PrimaryLight = Color(0xFF5fdbff)
val PrimaryDark = Color(0xFF007a9d)
val Secondary = Color(0xFF0077c8)
val SecondaryLight = Color(0xFF57c0e2)
val SecondaryDark = Color(0xFF004c97)
val Text = Color(0xFF00A9CE)
val NordicBlue = Color(0xFF00A9CE) val NordicBlue = Color(0xFF00A9CE)
val NordicBlueDark = Color(0xFF0090B0) val NordicLake = Color(0xFF008CD2)
val NordicSky = Color(0xFF6AD1E3)
val NordicBlueLate = Color(0xFF0033A0) val NordicDarkGray = ThemedColor(Color(0xFF333F48), Color(0xFFCCCBC8))
val NordicLake = Color(0xFF0077C8)
val NordicLightGray = Color(0xFFD9E1E2) // val NordicGray4 = ThemedColor(Color(0xFFD1D1D6), Color(0xFF3A3A3C))
val NordicMediumGray = Color(0xFF768692) val NordicGray4 = ThemedColor(Color.White, Color(0xFF3A3A3C))
val NordicDarkGray = Color(0xFF333F48)
val NordicGrass = Color(0xFFD0DF00) val NordicGray5 = ThemedColor(Color(0xFFE5E5EA), Color(0xFF2C2C2E))
val NordicSun = Color(0xFFFFCD00) val NordicLightGray = NeutralColor(Color(0xFF929CA2))
val NordicRed = Color(0xFFEE2F4E) val NordicMediumGray = NeutralColor(Color(0xFF929CA2))
val NordicFall = Color(0xFFF58220)
val NordicFall = ThemedColor(Color(0xFFF99535), Color(0xFFFF9F0A))
val NordicGreen = ThemedColor(Color(0xFF3ED052), Color(0xFF32D74B))
val NordicOrange = ThemedColor(Color(0xFFDF9B16), Color(0xFFFF9F0A))
val NordicRed = ThemedColor(Color(0xFFD03E51), Color(0xFFFF453A))
val NordicSky = NeutralColor(Color(0xFF6AD1E3))
val NordicYellow = ThemedColor(Color(0xFFF9EE35), Color(0xFFFFD60A))
val TableViewBackground = NeutralColor(Color(0xFFF2F2F6))
val TableViewSeparator = NeutralColor(Color(0xFFD2D2D6))
val Primary = ThemedColor(Color(0xFF00A9CE), Color(0xFF212121))
val PrimaryVariant = ThemedColor(Color(0xFF008CD2), Color.Black)
val Secondary = ThemedColor(Color(0xFF00A9CE), Color(0xFF008CD2))
val SecondaryVariant = ThemedColor(Color(0xFF008CD2), Color(0xFF008CD2))
val OnPrimary = ThemedColor(Color.White, Color.White)
val OnSecondary = ThemedColor(Color.White, Color.White)
val OnBackground = ThemedColor(Color.Black, Color.White)
val OnSurface = ThemedColor(Color.Black, Color.White)
val Background = ThemedColor(Color(0xFFDADADA), Color.Black)
val Surface = ThemedColor(Color(0xFFDADADA), Color.Black)
}
sealed class NordicColor {
@Composable
abstract fun value(): Color
}
data class ThemedColor(val light: Color, val dark: Color): NordicColor() {
@Composable
override fun value(): Color {
return if (isSystemInDarkTheme()) {
dark
} else {
light
}
}
}
data class NeutralColor(val color: Color): NordicColor() {
@Composable
override fun value(): Color {
return color
}
} }

View File

@@ -5,41 +5,40 @@ import androidx.compose.material.MaterialTheme
import androidx.compose.material.darkColors import androidx.compose.material.darkColors
import androidx.compose.material.lightColors import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
//TODO
private val DarkColorPalette = darkColors(
primary = NordicColors.Primary,
primaryVariant = NordicColors.PrimaryDark,
secondary = NordicColors.Secondary,
secondaryVariant = NordicColors.SecondaryDark,
onSecondary = Color.White,
onPrimary = Color.White,
onBackground = Color.Black,
onSurface = Color.Black,
background = Color.White,
surface = Color.White,
)
private val LightColorPalette = lightColors(
primary = NordicColors.Primary,
primaryVariant = NordicColors.PrimaryDark,
secondary = NordicColors.Secondary,
secondaryVariant = NordicColors.SecondaryDark,
onSecondary = Color.White,
onPrimary = Color.White,
onBackground = Color.Black,
onSurface = Color.Black,
background = Color.White,
surface = Color.White,
)
@Composable @Composable
fun TestTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) { fun TestTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable() () -> Unit) {
val darkColorPalette = darkColors(
primary = NordicColors.Primary.value(),
primaryVariant = NordicColors.PrimaryVariant.value(),
secondary = NordicColors.Secondary.value(),
secondaryVariant = NordicColors.SecondaryVariant.value(),
onSecondary = NordicColors.OnSecondary.value(),
onPrimary = NordicColors.OnPrimary.value(),
onBackground = NordicColors.OnBackground.value(),
onSurface = NordicColors.OnSurface.value(),
background = NordicColors.Background.value(),
surface = NordicColors.Surface.value(),
)
val lightColorPalette = lightColors(
primary = NordicColors.Primary.value(),
primaryVariant = NordicColors.PrimaryVariant.value(),
secondary = NordicColors.Secondary.value(),
secondaryVariant = NordicColors.SecondaryVariant.value(),
onSecondary = NordicColors.OnSecondary.value(),
onPrimary = NordicColors.OnPrimary.value(),
onBackground = NordicColors.OnBackground.value(),
onSurface = NordicColors.OnSurface.value(),
background = NordicColors.Background.value(),
surface = NordicColors.Surface.value(),
)
val colors = if (darkTheme) { val colors = if (darkTheme) {
DarkColorPalette darkColorPalette
} else { } else {
LightColorPalette lightColorPalette
} }
MaterialTheme( MaterialTheme(

View File

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

View File

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Some files were not shown because too many files have changed in this diff Show More