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

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