Skip to main content

Individual training

Training sessions are the main feature of Rook Training SDK they notify realtime info of heart rate, effort zones, calories and steps (only on RookMotions sensors).

In this tutorial you will learn how to use the RMTrainer class.


Contents

  1. The UI
  2. RMTrainer
  3. SessionType
  4. The ViewModels
    1. Sensor connection
    2. Training type
    3. Training controls
    4. Releasing resources
    5. Factory
  5. Integrate ViewModel and UI
  6. Running the app
  7. Next steps

Coding the UI

This section covers 2 activities:

  • TrainingSoloActivity - Will display training measurements.
  • TrainingTypeActivity - After selecting a training type will navigate to TrainingSoloActivity.

The file structure should look like below:

your.package.name/
|-- ui/
|-- trainingtype/
|-- TrainingTypeActivity
|-- training/
|-- solo/
|-- TrainingSoloActivity
|-- TrainingKeys

Create resources.

On res/values/styles add back button styles

<?xml version="1.0" encoding="utf-8"?>
<resources>

<style name="Widget.RookMotion.Button.Back" parent="Widget.MaterialComponents.Button.TextButton">
<item name="android:text">@string/back</item>
<item name="android:textColor">@color/black</item>
<item name="icon">@drawable/ic_arrow_back</item>
<item name="iconTint">@color/black</item>
<item name="rippleColor">@color/black_50</item>
<item name="android:insetTop">0dp</item>
<item name="android:insetBottom">0dp</item>
</style>

<style name="Widget.RookMotion.Button.Back.White">
<item name="android:textColor">@color/white</item>
<item name="iconTint">@color/white</item>
<item name="rippleColor">@color/white_50</item>
</style>

<string name="back">Back</string>
</resources>

On res/values/colors add zone and button colors

<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Your app colors -->

<color name="black">#FF000000</color>
<color name="black_50">#50000000</color>
<color name="white">#FFFFFFFF</color>
<color name="white_50">#50FFFFFF</color>

<color name="zone_0">#FF000000</color>
<color name="zone_1">#FFA2A2A2</color>
<color name="zone_2">#FF0091EA</color>
<color name="zone_3">#FF00C853</color>
<color name="zone_4">#FFFFAB00</color>
<color name="zone_5">#FFD50000</color>
</resources>

Create the UI for each activity.

The activity_training_solo.xml file will show the measurements and play/pause/stop buttons

<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
tools:context=".ui.training.solo.TrainingSoloActivity">

<LinearLayout
android:id="@+id/base"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/zone_0"
android:orientation="vertical"
android:padding="20dp">

<com.google.android.material.button.MaterialButton
android:id="@+id/back"
style="@style/Widget.RookMotion.Button.Back.White"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>

<LinearLayout
android:id="@+id/choose_sensor"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="10dp">

<ImageView
android:id="@+id/sensor_state"
android:layout_width="25dp"
android:layout_height="25dp"
android:contentDescription="@null"
android:src="@drawable/ic_sensor_off"
app:tint="@color/white"/>

<TextView
android:id="@+id/sensor_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="10dp"
android:layout_weight="1"
android:text="@string/choose_a_sensor"
android:textAllCaps="true"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
android:textColor="@color/white"/>

<ImageView
android:layout_width="25dp"
android:layout_height="25dp"
android:contentDescription="@null"
android:src="@drawable/ic_arrow_forward"
app:tint="@color/white"/>
</LinearLayout>

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="10dp">

<ImageView
android:id="@+id/training_type_icon"
android:layout_width="25dp"
android:layout_height="25dp"
android:contentDescription="@null"
android:src="@drawable/ic_training_type"
app:tint="@color/white"/>

<TextView
android:id="@+id/training_type"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="10dp"
android:layout_weight="1"
android:text="@string/training_type"
android:textAllCaps="true"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
android:textColor="@color/white"/>
</LinearLayout>

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/time"
android:textAlignment="center"
android:textAllCaps="true"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
android:textColor="@color/white"/>

<TextView
android:id="@+id/duration"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:text="00:00:00"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textColor="@color/white"
android:textSize="18sp"
android:textStyle="bold"/>

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/heart_rate"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
android:textColor="@color/white"/>

<TextView
android:id="@+id/heart_rate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textColor="@color/white"
android:textSize="18sp"
tools:text="150"/>

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/calories"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
android:textColor="@color/white"/>

<TextView
android:id="@+id/calories"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textColor="@color/white"
android:textSize="18sp"
tools:text="1500"/>

<TextView
android:id="@+id/steps_types"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/steps"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
android:textColor="@color/white"
android:visibility="gone"/>

<TextView
android:id="@+id/steps"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textColor="@color/white"
android:textSize="18sp"
android:visibility="gone"
tools:text="2388"/>

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/heart_effort"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
android:textColor="@color/white"/>

<TextView
android:id="@+id/effort"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textColor="@color/white"
android:textSize="18sp"
tools:text="90%"/>

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/battery_level"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"
android:textColor="@color/white"/>

<TextView
android:id="@+id/battery_level"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textColor="@color/white"
android:textSize="18sp"
tools:text="90"/>

<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:orientation="horizontal">

<ImageView
android:id="@+id/stop"
android:layout_width="75dp"
android:layout_height="75dp"
android:layout_alignParentStart="true"
android:layout_centerVertical="true"
android:layout_marginStart="25dp"
android:contentDescription="@null"
android:src="@drawable/ic_stop"
android:visibility="gone"
app:tint="@color/white"/>

<ImageView
android:id="@+id/pause"
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_centerInParent="true"
android:contentDescription="@null"
android:src="@drawable/ic_pause"
android:visibility="gone"
app:tint="@color/white"/>

<ImageView
android:id="@+id/play"
android:layout_width="100dp"
android:layout_height="100dp"
android:layout_centerInParent="true"
android:contentDescription="@null"
android:src="@drawable/ic_play"
app:tint="@color/white"/>
</RelativeLayout>
</LinearLayout>
</ScrollView>

The TrainingKeys has constants that will be used to pass parameters from TrainingTypeActivity to TrainingSoloActivity.

package com.rookmotion.rooktraining.ui.training

object TrainingKeys {
const val TRAINING_TYPE_SELECTED = "training_type_selected"
}

Finally, make TrainingSoloActivity portrait orientation only.


<activity
android:name=".ui.training.solo.TrainingSoloActivity"
android:exported="false"
android:screenOrientation="portrait"
tools:ignore="LockedOrientationActivity"/>
  • This is required to reduce the tutorial length, the training logic will be implemented in a ViewModel to keep state between configuration changes.

RMTrainer

To use the RMTrainer class you need to extend it and implement the callbacks. It can be extended in an activity or fragment but in order to maintain the state it will be implemented in a wrapper class TrainerImp which will expose the state in StateFlows then, that class will be provided to a TrainingSoloViewModel in the next section.

IMPORTANT:

  • RMTrainer and by extension TrainerImp can only do one training if you want to start another training you will need to create another TrainerImp instance.

Got to your.package.name/ui/training and create a TrainerImp class with a Context and an CoroutineScope as constructor parameters. This class must extend the RMTrainer class.

package com.rookmotion.rooktraining.ui.training

import android.content.Context
import com.rookmotion.app.sdk.persistence.entities.training.HeartRateDerivedData
import com.rookmotion.app.sdk.rmsensor.BluetoothAdapterState
import com.rookmotion.app.sdk.rmsensor.RMBandContact
import com.rookmotion.app.sdk.rmsensor.SensorConnectionState
import com.rookmotion.app.sdk.rmtrainer.RMTrainer
import kotlinx.coroutines.CoroutineScope

class TrainerImp(context: Context, private val scope: CoroutineScope) : RMTrainer(context) {

override fun onAdapterStateChanged(state: BluetoothAdapterState?) {
TODO("Not yet implemented")
}

override fun onConnectionStateChanged(state: SensorConnectionState?) {
TODO("Not yet implemented")
}

override fun onTimeReceived(sessionSeconds: Float) {
TODO("Not yet implemented")
}

override fun onHrReceived(hrData: HeartRateDerivedData?) {
TODO("Not yet implemented")
}

override fun onHrValidated(isValid: Boolean) {
TODO("Not yet implemented")
}

override fun onStepReceived(currentSteps: Long, cadence: Float) {
TODO("Not yet implemented")
}

override fun onBatteryLevelReceived(level: Int) {
TODO("Not yet implemented")
}

override fun onBandContactReceived(bandContact: RMBandContact?) {
TODO("Not yet implemented")
}
}

Create the StateFlows to manage the states.

    private val _adapterState = MutableStateFlow(BluetoothAdapterState.NONE)
val adapterState = _adapterState.asStateFlow()

private val _connectionState = MutableStateFlow<SensorConnectionState>(SensorConnectionState.None)
val connectionState = _connectionState.asStateFlow()

private val _time = MutableStateFlow(0F)
val time = _time.asStateFlow()

private val _heartRate = MutableStateFlow(HeartRateDerivedData())
val heartRate = _heartRate.asStateFlow()

private val _hrValid = MutableStateFlow(true)
val hrValid = _hrValid.asStateFlow()

private val _steps = MutableStateFlow(0L)
val steps = _steps.asStateFlow()

private val _batteryLevel = MutableStateFlow(0)
val batteryLevel = _batteryLevel.asStateFlow()

private val _bandContactState = MutableStateFlow(RMBandContact.UNKNOWN)
val bandContactState = _bandContactState.asStateFlow()

Implement the RMTrainer callbacks

  • onAdapterStateChanged - Called every time the bluetooth is turned off/on (or is in the process of doing so).
  • onConnectionStateChanged - Called every time the sensor is disconnected, disconnecting, connected, connecting or if the connection attempt failed.
  • onTimeReceived - Called every second with the seconds passed since the training started.
  • onHrReceived - Called every time a new HR data calculation is done.
  • onHrValidated - Called every time the sensor sends HR, you can use it to verify if the HR received is valid ( greater than 0).
  • onStepReceived - Called every time a new step calculation is done.
  • onBatteryLevelReceived - Called every second with the current connected sensor battery level.
  • onBandContactReceived - Called every time the sensor sends HR, you can use it to verify if the band has contact/no contact/contact with measurements/ no contact with measurements , if the current connected sensor is not a band you can ignore these events.
  • onHrReceived, onHrValidated and onStepReceived callbacks are only called when the training is active (started and not paused).
override fun onAdapterStateChanged(state: BluetoothAdapterState?) {
scope.launch { state?.let { _adapterState.emit(it) } }
}

override fun onConnectionStateChanged(state: SensorConnectionState?) {
scope.launch { state?.let { _connectionState.emit(it) } }
}

override fun onTimeReceived(sessionSeconds: Float) {
scope.launch { _time.emit(sessionSeconds) }
}

override fun onHrReceived(hrData: HeartRateDerivedData?) {
scope.launch { hrData?.let { _heartRate.emit(it) } }
}

override fun onHrValidated(isValid: Boolean) {
scope.launch { _hrValid.emit(isValid) }
}

override fun onStepReceived(currentSteps: Long, cadence: Float) {
scope.launch { _steps.emit(currentSteps) }
}

override fun onBatteryLevelReceived(level: Int) {
scope.launch { _batteryLevel.emit(level) }
}

override fun onBandContactReceived(bandContact: RMBandContact?) {
scope.launch { bandContact?.let { _bandContactState.emit(it) } }
}

Finally, create a onDestroyImp function to cancel any pending job, this will be called when the TrainingSoloActivity is destroyed.

fun onDestroyImp() {
scope.cancel()
}

Session type

Training sessions are classified in two categories:

  • Classic - Only heart rate measurements
  • With steps - Heart rate and steps measurements (steps detection are exclusive to RookMotion sensors).

Got to your.package.name/ui/training and create a SessionType sealed class.

package com.rookmotion.rooktraining.ui.training

import com.rookmotion.app.sdk.persistence.entities.training.RMTrainingType
import com.rookmotion.kotlin.sdk.domain.enums.StepsType

sealed class SessionType {
object Unknown : SessionType()
class Classic(val trainingType: RMTrainingType) : SessionType()
class WithSteps(val trainingType: RMTrainingType, val stepsType: StepsType) : SessionType()
}

The available steps types are:

enum class StepsType {
JUMPS,
STEPS,
RUN,
TRAMPOLINE,
BOOTS,
ROPE,
NONE
}

Using an approach like this will help you to render the correct UI elements.


Coding the ViewModels

This section covers one view model:

  • TrainingSoloViewModel - Used in TrainingSoloActivity to handle all measurements, sensor and training states.

The file structure should look like below:

your.package.name/
|-- state/
|-- TrainingSoloViewModel

Create an TrainingSoloViewModel class extending from ViewModel with an TrainerImp as constructor parameter.

package com.rookmotion.rooktraining.state

import androidx.lifecycle.ViewModel
import com.rookmotion.rooktraining.ui.training.TrainerImp

class TrainingSoloViewModel(val trainer: TrainerImp) : ViewModel() {
}

Create a mutable nullable BluetoothPeripheral property, this will hold a reference to the last sensor the user attempted to connect with.

private var connectingWith: BluetoothPeripheral? = null

Create 2 getters isTrainingNotStarted and isTrainingStarted

val isTrainingNotStarted get() = !trainer.isTrainingActive && !trainer.isTrainingPaused
val isTrainingStarted get() = trainer.isTrainingActive || trainer.isTrainingPaused
  • To be considered 'started' a training must be active or paused

Create a StateFlow to hold the SessionType state.

private val _sessionType = MutableStateFlow<SessionType>(SessionType.Unknown)
val sessionType = _sessionType.asStateFlow()

Sensor connection

To connect to a sensor call connectSensor or connectSensorWithTimeout they have their own pros and cons. In the RookMotion App we use connectSensorWithTimeout as it has the fastest connection time. However, you'll need to check if the sensor is cached.

Independently of the connection function you choose the process is the same:

  1. Get the BluetoothPeripheral instance of the provided mac
  2. Verify that the sensor is not null and is cached (if you use connectSensor verifiying cache is optional).
  3. Assign sensor to connectingWith.
  4. Check for a connected sensor if there's one disconnect from it and wait 3 seconds.
  5. Create the connection request. In the previous tutorial you already linked and saved as last used the sensor, pass false in the second parameter so the SDK won't try to link the sensor again.
  • This function will return true if the connection request was created.
fun connect(mac: String): Boolean {
val sensor = trainer.getPeripheralFromMac(mac)

return if (sensor != null && !sensor.isUncached) {
connectingWith = sensor

viewModelScope.launch {

if (trainer.connectedSensor != null) {
trainer.cancelConnection(trainer.connectedSensor)
delay(3000)
}

trainer.connectSensorWithTimeout(sensor, false)
}

true
} else {
false
}
}

A description of connectSensor and connectSensorWithTimeout (extracted from the Javadoc).

public final void connectSensor(BluetoothPeripheral sensor, boolean link)

Attempts to connect to a sensor then links the sensor to the user whether the connection attempt was successful or not.
This function will result in longer connection times, but will take of caching.

Considerations:
1. This attempt will not time out. To cancel you'll need to call cancelConnection(BluetoothPeripheral).
2. It is not necessary to scan before calling this function.
3. It is not necessary to make sure the sensor is cached, although you can is still verify with BluetoothPeripheral.isUncached().

Params:
sensor – BluetoothPeripheral instance.
link – Should attempt to link sensor to user? You can pass false if the sensor has already been linked and saved as last used.
public final void connectSensorWithTimeout(BluetoothPeripheral sensor, boolean link)

Attempts to connect to a sensor then links the sensor to the user whether the connection attempt was successful or not.
This function will result in shorter connection times, but you'll need to verify the sensor is cached before connecting.

Considerations:
1. Make sure the device is cached with BluetoothPeripheral.isUncached(), if cached connect.
2. If the device is un-cached you must scan with startDiscovery(long), then repeat step 1.
3. This attempt will time out in 30 seconds on most devices (5 seconds for samsung devices).

Params:
sensor – BluetoothPeripheral instance.
link – Should attempt to link sensor to user? You can pass false if the sensor has already been linked and saved as last used.

To disconnect from a sensor call cancelConnection and provide a sensor (this can also cancel a connection request) or finishSensorConnection to disconnect from current connected sensor. We recommend using cancelConnection as it offers more flexibility.

fun cancelConnection() {
if (connectingWith != null) {
trainer.cancelConnection(connectingWith)
}
}
  • connectingWith knows which sensor the last connection request was made to. Later you'll use it to cancel a connection request.

Set training type

To set a training type to the training session call setTrainingType it will return the StepsType of the session. Then use it to emit the current training session's SessionType.

fun initTrainingType(trainingType: RMTrainingType) {
val stepsType = trainer.setTrainingType(trainingType)

if (stepsType != StepsType.NONE) {
trainer.enableStepsReading(true)
_sessionType.tryEmit(SessionType.WithSteps(trainingType, stepsType))
} else {
trainer.enableStepsReading(false)
_sessionType.tryEmit(SessionType.Classic(trainingType))
}
}

fun getStepsType(): StepsType {
return if (_sessionType.value is SessionType.WithSteps) {
(_sessionType.value as SessionType.WithSteps).stepsType
} else {
StepsType.NONE
}
}
  • If the StepsType is not NONE you MUST call enableStepsReading so this training session ca obtain steps data from the sensor (RookMotion sensors only).
  • You MUST set the training type before starting the training

Training controls

Start training:

fun startTraining() {
trainer.startTraining()
}
  • startTraining can only be called once.

Pause / Resume training:

fun pauseTraining() {
trainer.pauseTraining()
}

fun resumeTraining() {
trainer.resumeTraining()
}

Before canceling a training you should disconnect the sensor, once cancelTraining is called all training, summaries and records are deleted FOREVER.

fun cancelTraining() {
cancelConnection()
trainer.cancelTraining()
}

Before finishing a training you should disconnect the sensor, this function will return the timestamp at which the training started, this will be used in the next tutorial to show a summary screen.art

fun finishTraining(): String {
cancelConnection()
trainer.finishAndUploadTraining() { rmResponse, result ->
if (rmResponse.isSuccess) {
Timber.i("Training uploaded summaries and uuid: ${result.trainingUUID} are available")
} else {
Timber.i("Training not uploaded only summaries are available")
}
}

return trainer.startTime
}
  • You can call finishTraining if you don't want to upload the training. You can upload later calling uploadPendingTrainings on the RM class.

Releasing resources

Call this function when TrainingSoloActivity is being destroyed, to release bluetooth resources, and cancel any pending jobs.

fun releaseTrainingResources() {
cancelConnection()
trainer.pauseTraining()
trainer.onDestroyImp()
trainer.onDestroy()
}
  • Don't worry about the call to pauseTraining it won't do anything an already canceled or finished training, it is called to cover the case when the app crashes for any error, if that happens the training will be paused will be available to be continued (Continuing pending trainings will be explored in another tutorial).

Factory

Finally, add the view models to the RMApplicationViewModelFactory's create function.

override fun <T : ViewModel> create(modelClass: Class<T>): T {

// Other conditions

if (modelClass.isAssignableFrom(TrainingSoloViewModel::class.java)) {
return TrainingSoloViewModel(
trainer = TrainerImp(
context = application.applicationContext,
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
)
) as T
}

throw IllegalArgumentException("Unknown ViewModel class")
}

Integrating ViewModel and UI

TrainingTypeActivity

In the goToTraining function add a call to startActivity providing the selected training type, TrainingTypeMapper will convert the training type to a json string.

private fun goToTraining(trainingType: RMTrainingType) {
val intent = Intent(this, TrainingSoloActivity::class.java).apply {
putExtra(TrainingKeys.TRAINING_TYPE_SELECTED, TrainingTypeMapper.toJson(trainingType))
}

startActivity(intent)
}

TrainingSoloViewModel

Create an TrainingSoloViewModel instance and an ActivityResultLauncher instance this will be used to launch and retrieve the result of SensorScannerActivity.

class TrainingSoloViewModel : AppCompatActivity() {

private lateinit var sensorsLauncher: ActivityResultLauncher<Intent>

private val trainingSoloViewModel by viewModels<TrainingSoloViewModel> {
RMApplicationViewModelFactory(application as RookTrainingApplication)
}
}

Some phones will kill the app when is on background or if the screen is blocked, due to energy limitations, to avoid this you should ask the user to disable battery optimizations.

This can be done using our BatteryManager which works the same as PermissionsManager.

private val batteryManager = BatteryManager()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityTrainingSoloBinding.inflate(layoutInflater)
setContentView(binding.root)

batteryManager.registerIgnoreOptimizationsListener(
componentActivity = this,
onSuccess = { toastShort(getString(R.string.thanks)) },
onFailure = { Timber.w("User rejected disabling battery optimizations") }
)

if (!batteryManager.isIgnoringBatteryOptimizations(this)) {
batteryManager.requestIgnoreOptimizations(packageName)
}
}
  • BatteryManager is available on our sdk-utils library.

Override onDestroy to release resources when the activity is destroyed.

override fun onDestroy() {
if (!isChangingConfigurations) {
batteryManager.onDestroy()
trainingSoloViewModel.releaseTrainingResources()
}

super.onDestroy()
}

Override onBackPressed to show an stop training dialog if there is an active training session.

override fun onBackPressed() {
if (trainingSoloViewModel.isTrainingStarted) {
showStopTrainingDialog()
} else {
super.onBackPressed()
}
}

Add the init functions calls to onCreate

    override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityTrainingSoloBinding.inflate(layoutInflater)
setContentView(binding.root)

batteryManager.registerIgnoreOptimizationsListener(
componentActivity = this,
onSuccess = { toastShort(getString(R.string.thanks)) },
onFailure = { Timber.w("User rejected disabling battery optimizations") }
)

if (!batteryManager.isIgnoringBatteryOptimizations(this)) {
batteryManager.requestIgnoreOptimizations(packageName)
}

initExtras()
initSensorConnectionListeners()
initTrainingListeners()
initActions()
}

Create the initExtras function to get the selected training type.

It's very important to check for isTrainingNotStarted to avoid calling the init logic once the user is training.

private fun initExtras() {
val trainingTypeJson = intent.extras?.getString(TrainingKeys.TRAINING_TYPE_SELECTED) ?: ""

if (trainingSoloViewModel.isTrainingNotStarted) {
val trainingType = TrainingTypeMapper.fromJson(trainingTypeJson)

if (trainingType != null) {
trainingSoloViewModel.initTrainingType(trainingType)
}
} else {
Timber.i("initExtras: training is active, skipped")
}
}

Create the initSensorConnectionListeners function to consume the connectionState StateFlow

private fun initSensorConnectionListeners() {
lifecycleScope.launchWhenResumed {
trainingSoloViewModel.trainer.connectionState.collect {
when (it) {
is SensorConnectionState.Connecting -> {
val label = getString(
R.string.connecting_with_sensor,
it.sensor.name ?: getString(R.string.unknown_sensor)
)

binding.sensorState.setImageResource(R.drawable.ic_bluetooth_connecting)
binding.sensorName.text = label
}
is SensorConnectionState.ConnectionFailed -> {
binding.sensorState.setImageResource(R.drawable.ic_sensor_off)
binding.sensorName.setText(R.string.could_not_connect)
}
is SensorConnectionState.Connected -> {
binding.sensorState.setImageResource(R.drawable.ic_sensor)
binding.sensorName.text =
it.sensor.name ?: getString(R.string.unknown_sensor)
}
is SensorConnectionState.Disconnecting -> {
Timber.i("Disconnecting from sensor ${it.sensor.name}")
}
is SensorConnectionState.Disconnected -> {
binding.sensorState.setImageResource(R.drawable.ic_sensor_off)
binding.sensorName.setText(R.string.sensor_connection_lost)
}
SensorConnectionState.None -> Timber.i("SensorConnectionState.None")
}
}
}
}

Then initialize the sensorsLauncher. When the user selects a sensor on the SensorScannerActivity it will return the selected sensor's mac, and you will be able to call the connect function.

private fun initSensorConnectionListeners() {

// Consume connectionState

sensorsLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode == RESULT_OK && it.data != null) {

val mac = it.data?.getStringExtra(ScannerKeys.SENSOR_SELECTED)?.uppercase() ?: ""
val success = trainingSoloViewModel.connect(mac)

if (success) {
Timber.i("Connection request created successfully")
} else {
Timber.i("Connection request not created")
}
}
}
}

Create the initTrainingListeners to consume the sessionType, time, heartRate,hrValid,steps,batteryLevel and bandContactState states.

private fun initTrainingListeners() {
lifecycleScope.launchWhenResumed {
trainingSoloViewModel.sessionType.collect {
when (it) {
SessionType.Unknown -> Timber.i("sessionType: None")
is SessionType.Classic -> {
binding.trainingTypeIcon.setImageResource(R.drawable.ic_training_type)
binding.trainingType.text = it.trainingType.trainingName
binding.stepsTypes.isVisible = false
binding.steps.isVisible = false
}
is SessionType.WithSteps -> {
binding.trainingTypeIcon.setImageResource(R.drawable.ic_training_type_steps)
binding.trainingType.text = it.trainingType.trainingName
binding.stepsTypes.isVisible = true
binding.steps.isVisible = true
}
}
}
}

lifecycleScope.launchWhenResumed {
trainingSoloViewModel.trainer.time.collect {
binding.duration.text = TimeFormatter.formatSecondsToHHMMSS(it)
}
}

lifecycleScope.launchWhenResumed {
trainingSoloViewModel.trainer.heartRate.collect {
if (it.calories > 0F) {
manageHeartRateData(it)
}
}
}

lifecycleScope.launchWhenResumed {
trainingSoloViewModel.trainer.hrValid.collect {
Timber.i("Heart rate is grater than zero? $it")
}
}

lifecycleScope.launchWhenResumed {
trainingSoloViewModel.trainer.steps.collect {
val label = AppResources.suffixOfStepsType(
context = this@TrainingSoloActivity,
stepsType = trainingSoloViewModel.getStepsType()
)

binding.steps.text = "$it $label"
}
}

lifecycleScope.launchWhenResumed {
trainingSoloViewModel.trainer.batteryLevel.collect {
binding.batteryLevel.text = "$it%"
}
}

lifecycleScope.launchWhenResumed {
trainingSoloViewModel.trainer.bandContactState.collect {
Timber.i("Band contact: ${it.name}")
}
}
}

The TimeUtils object is available in our sdk-utils library.

For the suffixOfStepsType function go to your.package.name/AppResources and paste the following:

package com.rookmotion.rooktraining.utils

import android.content.Context
import com.rookmotion.kotlin.sdk.domain.enums.StepsType

object AppResources {

fun suffixOfStepsType(context: Context, stepsType: StepsType): String {
return when (stepsType) {
StepsType.TRAMPOLINE, StepsType.BOOTS, StepsType.JUMPS -> context.getString(R.string.jumps)
StepsType.STEPS, StepsType.RUN, StepsType.ROPE -> context.getString(R.string.steps)
StepsType.NONE -> ""
}
}
}

There are 5 effort zones each one represented by a color

EffortZones

Using this information you can paint the background and status bar based on the HeartRateDerivedData effort value.

private fun manageHeartRateData(data: HeartRateDerivedData) {

val effort = data.effort.roundToInt()

binding.heartRate.text = "${data.heartRate.roundToInt()}"
binding.calories.text = "${data.calories.roundToInt()}"
binding.effort.text = "$effort"

when {
effort >= 90 -> {
setStatusBarColor(R.color.zone_5)
binding.base.setBackgroundColor(ContextCompat.getColor(this, R.color.zone_5))
}
effort >= 80 -> {
setStatusBarColor(R.color.zone_4)
binding.base.setBackgroundColor(ContextCompat.getColor(this, R.color.zone_4))
}
effort >= 70 -> {
setStatusBarColor(R.color.zone_3)
binding.base.setBackgroundColor(ContextCompat.getColor(this, R.color.zone_3))
}
effort >= 60 -> {
setStatusBarColor(R.color.zone_2)
binding.base.setBackgroundColor(ContextCompat.getColor(this, R.color.zone_2))
}
effort >= 50 -> {
setStatusBarColor(R.color.zone_1)
binding.base.setBackgroundColor(ContextCompat.getColor(this, R.color.zone_1))
}
else -> {
setStatusBarColor(R.color.zone_0)
binding.base.setBackgroundColor(ContextCompat.getColor(this, R.color.zone_0))
}
}
}

Go to your.package.name/utils/RMExtensions and create an extension function to update the status bar color.

fun Activity.setStatusBarColor(colorResourceId: Int) {
window?.statusBarColor = ContextCompat.getColor(this, colorResourceId)
}

Create the initActions function to add the back and chooseSensor click listeners.

private fun initActions() {
binding.back.setOnClickListener { onBackPressed() }

binding.chooseSensor.setOnClickListener { goToSelectSensor() }
}
  • The play button will call resumeTraining when the training is paused otherwise startTraining.
  • The pause button will call resumeTraining.
  • The pause button will show a stop training dialog.
private fun initActions() {

// Other buttons

binding.play.setOnClickListener {
if (trainingSoloViewModel.trainer.isTrainingPaused) {
trainingSoloViewModel.resumeTraining()
} else {
trainingSoloViewModel.startTraining()
}

binding.play.isVisible = false
binding.pause.isVisible = true
binding.stop.isVisible = true
}

binding.pause.setOnClickListener {
trainingSoloViewModel.pauseTraining()

binding.play.isVisible = true
binding.pause.isVisible = false
}

binding.stop.setOnClickListener { showStopTrainingDialog() }

updateControlButtons()
}

updateControlButtons will update the controls state if the activity is recreated.

private fun updateControlButtons() {
if (trainingSoloViewModel.trainer.isTrainingActive) {
binding.play.isVisible = false
binding.pause.isVisible = true
} else {
binding.play.isVisible = true
binding.pause.isVisible = false
}

if (trainingSoloViewModel.isTrainingStarted) {
binding.stop.isVisible = true
}
}

Implement the goToSelectSensor function to:

  1. Obtain the current connected sensor with getConnectedSensor
  2. Check the current connectionState if it's connecting cancel the connection (to avoid problems where some devices can't connect and scan at the same time)
  3. Launch the SensorScannerActivity providing the connected sensor mac and name.
private fun goToSelectSensor() {
val connected: BluetoothPeripheral? = trainingSoloViewModel.trainer.connectedSensor

if (trainingSoloViewModel.trainer.connectionState.value is SensorConnectionState.Connecting) {
trainingSoloViewModel.cancelConnection()
}

sensorsLauncher.launch(
Intent(this, SensorScannerActivity::class.java).apply {
putExtra(ScannerKeys.SENSOR_CONNECTED_MAC, connected?.address)
putExtra(ScannerKeys.SENSOR_CONNECTED_NAME, connected?.name)
}
)
}

showStopTrainingDialog will display an alert dialog with 3 options:

  • Finish training - Call to finishTraining
  • Cancel training - Call to trainingSoloViewModel.cancelTraining then finish the activity.
  • Dismiss - Hides dialog.
private fun showStopTrainingDialog() {
Dialogs.showStopTrainingDialog(
this,
cancelTraining = {
trainingSoloViewModel.cancelTraining()
finish()
},
finishTraining = { finishTraining() }
)
}

Alert dialog code in your.package.name/utils/Dialogs:

package com.rookmotion.rooktraining.utils

import android.content.Context
import androidx.appcompat.app.AlertDialog
import com.rookmotion.rooktraining.R

object Dialogs {
fun showStopTrainingDialog(
context: Context,
cancelTraining: () -> Unit,
finishTraining: () -> Unit
) {
AlertDialog.Builder(context)
.setTitle(R.string.what_do_you_want_to_do)
.setMessage(R.string.theres_a_training_running)
.setNeutralButton(R.string.dismiss) { dialog, _ ->
dialog.dismiss()
}
.setNegativeButton(R.string.cancel_training) { dialog, _ ->
dialog.dismiss()
cancelTraining()
}
.setPositiveButton(R.string.finish_training) { dialog, _ ->
dialog.dismiss()
finishTraining()
}
.show()
}
}

Finally, implement the finishTraining function to:

  1. Call trainingSoloViewModel.finishTraining
  2. Get the current training and steps type (will be used in next tutorial to show a summary screen).
  3. Finish the activity.
private fun finishTraining() {
val startTime = trainingSoloViewModel.finishTraining()
val trainingType = trainingSoloViewModel.trainer.trainingType
val stepsType = trainingSoloViewModel.getStepsType()

finish()
}

Running the app

Wrapping up

At the end of this tutorial your app should be able to:

Select a training type and navigate to the training screen.

TrainingSoloActivity

Go to the scanner screen, select a sensor and connect to it.

TrainingSoloActivity

Start / Pause / Resume / Cancel and Finish the training

TrainingSoloActivity

TrainingSoloActivity

When finishing TrainingSoloActivity you should see a log like:

I/BluetoothPeripheral: force disconnect 'RkHRam 0805297' (FB:6B:F4:5C:4E:4E)
I/BluetoothPeripheral: disconnected 'RkHRam 0805297' on request

Indicating that the sensor was disconnected.

If you finish the training you'll see the log in TrainingSoloViewModel's uploadTraining function indicating if the upload was successful:

I/TrainingSoloViewModel: Training uploaded summaries and uuid: 192ae100-83a2-4ae3-9817-b39ab1f22983 are available

If you cancel the training you should see a log like:

I/RMTrainer: Summaries deleted
I/RMTrainer: Heart Rate deleted
I/RMTrainer: Training canceled

Next steps

Congratulations! You successfully created an individual training, go to the next tutorial to learn how to Show summaries of a finished training.