Skip to main content

Training summary

After a training session you should show a summary to the user with a detail of their calories, heart rate, etc.

In this tutorial you will learn how to retrieve training information, summaries and records from Database while the training is being uploaded.


Contents

  1. The UI
  2. The Repositories
  3. The ViewModels
  4. Integrate ViewModel and UI
  5. Running the app
  6. Next steps

Coding the UI

This section covers 2 activities:

  • HomeActivity - The home UI of the app.
  • TrainingTypeActivity - Will display a list of training types.

The file structure should look like below:

your.package.name/
|-- ui/
|-- training/
|-- summary/
|-- TrainingSummaryActivity

Create the UI for each activity.

The activity_training_summary.xml file will show basic information, summaries and the amount of individual records.

<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.training.summary.TrainingSummaryActivity">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="20dp"
android:text="@string/training_summary"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline6"/>

<include
android:id="@+id/progress"
layout="@layout/ui_progress"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="20dp"/>

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:text="@string/basic_information"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textSize="18sp"/>

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/training_name"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"/>

<TextView
android:id="@+id/training_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
tools:text="Individual training"/>

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/training_type"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"/>

<TextView
android:id="@+id/training_type"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
tools:text="Running"/>

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/start_date"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"/>

<TextView
android:id="@+id/start_date"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
tools:text="March 16, 2022 10:56 am"/>

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/end_date"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"/>

<TextView
android:id="@+id/end_date"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
tools:text="March 16, 2022 11:56 am"/>

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:text="@string/summaries"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textSize="18sp"/>

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/duration"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"/>

<TextView
android:id="@+id/duration"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
tools:text="1500"/>

<TextView
android:id="@+id/steps_types"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/steps"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"/>

<TextView
android:id="@+id/steps"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
tools:text="2388"/>

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/calories"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"/>

<TextView
android:id="@+id/calories"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
tools:text="1500"/>

<TextView
android:id="@+id/effort"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
tools:text="@string/effort_min_avg_max_placeholder"/>

<TextView
android:id="@+id/heart_rate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
tools:text="@string/hr_min_avg_max_placeholder"/>

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:text="@string/individual_records"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.MaterialComponents.Subtitle1"
android:textSize="18sp"/>

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/number_of_hr_records"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"/>

<TextView
android:id="@+id/heart_rate_records"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
tools:text="1500"/>

<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/number_of_steps_records"
android:textAppearance="@style/TextAppearance.MaterialComponents.Caption"/>

<TextView
android:id="@+id/steps_records"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:textAppearance="@style/TextAppearance.MaterialComponents.Body1"
tools:text="1500"/>
</LinearLayout>
</ScrollView>

The TrainingSummaryKeys has constants that will be used to pass parameters from TrainingSoloActivity to TrainingSummaryActivity.

package com.rookmotion.rooktraining.ui.training

object TrainingKeys {
const val TRAINING_TYPE_SELECTED = "training_type_selected"
}

Finally, make TrainingSummaryActivity portrait orientation only.


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

Coding the repositories

The file structure should look like below:

your.package.name/
|-- data/
|-- repository/
|-- TrainingSummaryRepository

Create an TrainingSummaryRepository class with an RM instance as a constructor parameter.

package com.rookmotion.rooktraining.data.repository

import com.rookmotion.app.sdk.RM

class TrainingSummaryRepository(private val rm: RM) {
}

To get training information from Database call getTrainingInformationFromDatabase, and provide the start timestamp in the format YYYY-MM-DD HH:MM:SS.

There's also a getTrainingTypesFromDatabase function if you only want to get local training types.

fun getTrainingInformation(
start: String,
onSuccess: (RMTrainingModel) -> Unit,
onError: (String) -> Unit
) {
rm.getTrainingInformationFromDatabase(start) { rmResponse, rmTraining ->
if (rmResponse.isSuccess && rmTraining != null) {
onSuccess(rmTraining)
} else {
onError(rmResponse.message)
}
}
}

To get training summaries from Database call getSummariesFromDatabase, and provide the start timestamp in the format YYYY-MM-DD HH:MM:SS.

fun getHeartRateRecords(
start: String,
onSuccess: (List<HeartRateDerivedData>) -> Unit,
onError: (String) -> Unit
) {
rm.getHeartRateRecordsFromDatabase(start) { rmResponse, records ->
if (rmResponse.isSuccess && records != null) {
onSuccess(records)
} else {
onError(rmResponse.message)
}
}
}

fun getStepsRecords(
start: String,
onSuccess: (List<StepsDerivedData>) -> Unit,
onError: (String) -> Unit
) {
rm.getStepsRecordsFromDatabase(start) { rmResponse, records ->
if (rmResponse.isSuccess && records != null) {
onSuccess(records)
} else {
onError(rmResponse.message)
}
}
}

To get training heart rate records from Database call getHeartRateRecordsFromDatabase, and provide the start timestamp in the format YYYY-MM-DD HH:MM:SS.

fun getHeartRateRecords(
start: String,
onSuccess: (List<HeartRateDerivedData>) -> Unit,
onError: (String) -> Unit
) {
rm.getHeartRateRecordsFromDatabase(start) { rmResponse, records ->
if (rmResponse.isSuccess && records != null) {
onSuccess(records)
} else {
onError(rmResponse.message)
}
}
}

To get training steps records from Database call getStepsRecords, and provide the start timestamp in the format YYYY-MM-DD HH:MM:SS.

fun getStepsRecords(
start: String,
onSuccess: (List<StepsDerivedData>) -> Unit,
onError: (String) -> Unit
) {
rm.getStepsRecordsFromDatabase(start) { rmResponse, records ->
if (rmResponse.isSuccess && records != null) {
onSuccess(records)
} else {
onError(rmResponse.message)
}
}
}

Finally, create an instance of the repositories in your.package.name/rm/RMServiceLocator

class RMServiceLocator(context: Context) {

// Other configuration

val trainingSummaryRepository by lazy { TrainingSummaryRepository(rm) }
}

Coding the ViewModels

This section covers one view model:

  • TrainingSummaryRepository - Used in TrainingSummaryActivity to handle all summary states.

The file structure should look like below:

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

Create an TrainingSummaryViewModel class extending from ViewModel with an TrainingSummaryRepository instance as a constructor parameter.

package com.rookmotion.rooktraining.state

import androidx.lifecycle.ViewModel
import com.rookmotion.rooktraining.data.repository.TrainingSummaryRepository

class TrainingSummaryViewModel(
private val trainingSummaryRepository: TrainingSummaryRepository
) : ViewModel() {
}

Create a livedata object to hold trainingType state.

private val _trainingSummaryState = MutableLiveData<TrainingSummaryState>()
val trainingSummaryState: LiveData<TrainingSummaryState> get() = _trainingSummaryState

The training data will be retrieved in succession:

  1. Get training information
  2. Get summaries
  3. Get heart rate records
  4. Get steps records
  5. Emit the state
fun getTraining(start: String) {
_trainingSummaryState.value = TrainingSummaryState.Loading

trainingSummaryRepository.getTrainingInformation(
start = start,
onSuccess = { getSummaries(start, it) },
onError = { _trainingSummaryState.value = TrainingSummaryState.Error(it) }
)
}

private fun getSummaries(start: String, training: RMTrainingModel) {
trainingSummaryRepository.getSummaries(
start = start,
onSuccess = { getHeartRateRecords(start, training, it) },
onError = { _trainingSummaryState.value = TrainingSummaryState.Error(it) }
)
}

private fun getHeartRateRecords(start: String, training: RMTrainingModel, summary: RMSummary) {
trainingSummaryRepository.getHeartRateRecords(
start = start,
onSuccess = { getStepsRecords(start, training, summary, it) },
onError = { _trainingSummaryState.value = TrainingSummaryState.Error(it) }
)
}

private fun getStepsRecords(
start: String,
training: RMTrainingModel,
summary: RMSummary,
heartRateRecords: List<HeartRateDerivedData>,

) {
trainingSummaryRepository.getStepsRecords(
start = start,
onSuccess = {
_trainingSummaryState.value = TrainingSummaryState.Success(
training = training,
summary = summary,
heartRateRecords = heartRateRecords,
stepsRecords = it,
)
},
onError = { _trainingSummaryState.value = TrainingSummaryState.Error(it) }
)
}

The trainingSummaryState uses a sealed class like the following:

sealed class TrainingSummaryState {
object None : TrainingSummaryState()
object Loading : TrainingSummaryState()
class Error(val message: String) : TrainingSummaryState()
class Success(
val training: RMTrainingModel,
val summary: RMSummary,
val heartRateRecords: List<HeartRateDerivedData> = emptyList(),
val stepsRecords: List<StepsDerivedData> = emptyList()
) : TrainingSummaryState()
}

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

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

// Other conditions

if (modelClass.isAssignableFrom(TrainingSummaryViewModel::class.java)) {
return TrainingSummaryViewModel(rmServiceLocator.trainingSummaryRepository) as T
}

throw IllegalArgumentException("Unknown ViewModel class")
}

Integrating ViewModel and UI

TrainingSoloActivity

Go to the finishTraining function from previous tutorial, create and launch an intent to TrainingSummaryActivity providing the start, training and steps type

private fun finishTraining() {
val startTime = trainingSoloViewModel.finishTraining()
val trainingType = trainingSoloViewModel.trainer.trainingType
val stepsType = trainingSoloViewModel.getStepsType()

val intent = Intent(this, TrainingSummaryActivity::class.java).apply {
putExtra(TrainingSummaryKeys.START, startTime)
putExtra(TrainingSummaryKeys.TRAINING_TYPE, trainingType.trainingName)
putExtra(TrainingSummaryKeys.STEPS_TYPE, stepsType.name)
}

startActivity(intent)
finish()
}

TrainingSummaryActivity

Create an TrainingSummaryViewModel instance

class TrainingSummaryActivity : AppCompatActivity() {
private val trainingSummaryViewModel by viewModels<TrainingSummaryViewModel> {
RMViewModelFactory(rmLocator)
}
}

In the onCreate function call the init functions

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

initExtras()
initState()
}

In initExtras retrieve the start, training and steps types, display the trainingType use stepsType to show or hide steps information, finally use start to call getTraining.

private fun initExtras() {
val start = intent.extras?.getString(TrainingSummaryKeys.START) ?: ""
val trainingType = intent.extras?.getString(TrainingSummaryKeys.TRAINING_TYPE) ?: ""
val stepsType = StepsType.valueOf(
intent.getStringExtra(TrainingSummaryKeys.STEPS_TYPE) ?: "NONE"
)

binding.trainingType.text = trainingType

if (stepsType != StepsType.NONE) {
binding.stepsTypes.text = stepsType.name

binding.stepsTypes.isVisible = true
binding.steps.isVisible = true
} else {
binding.stepsTypes.isVisible = false
binding.steps.isVisible = false
}

trainingSummaryViewModel.getTraining(start)
}

Consume the trainingSummaryState livedata and implement all cases:

  • None - Log.
  • Loading - Show progress.
  • Success - Hide progress, fill training, summary and records info.
  • Error - Hide progress, show error.
private fun initState() {
trainingSummaryViewModel.trainingSummaryState.observe(this) {
when (it) {
TrainingSummaryViewModel.TrainingSummaryState.None -> Timber.i("trainingSummaryState.None")
TrainingSummaryViewModel.TrainingSummaryState.Loading -> {
binding.progress.root.isVisible = true
}
is TrainingSummaryViewModel.TrainingSummaryState.Success -> {

showTrainingInfo(it.training)
showSummaryInfo(it.summary)
showRecordsInfo(it.heartRateRecords, it.stepsRecords)

binding.progress.root.isVisible = false
}
is TrainingSummaryViewModel.TrainingSummaryState.Error -> {
binding.root.snackLong(it.message, getString(R.string.retry)) {
trainingSummaryViewModel.getTraining(
start = intent.extras?.getString(TrainingSummaryKeys.START) ?: ""
)
}

binding.progress.root.isVisible = false
}
}
}
}

Use deviceType to display if this is an individual training (app) or a remote training (remote).

  • The properties start and end are in UTC you'll need to adjust them to your users time zone. More about UTC in Getting familiar tutorial.
private fun showTrainingInfo(training: RMTrainingModel) {
binding.trainingName.text =
if (training.deviceType == "app") getString(R.string.individual_training)
else getString(R.string.remote_training)

binding.startDate.text = UTCConverter.getDateTime(training.start, true)?.toString()
binding.endDate.text = UTCConverter.getDateTime(training.end, true)?.toString()
}

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

RMSummary contains total, min, avg, max and per-zone values to display useful insights.

  • The property start is in UTC you'll need to adjust it to your users time zone. More about UTC in Getting familiar tutorial.
private fun showSummaryInfo(summary: RMSummary) {
binding.duration.text = TimeFormatter.formatSecondsToHHMMSS(summary.totalTime)
binding.steps.text = summary.totalSteps.toString()
binding.calories.text = summary.totalCalories.roundToInt().toString()
binding.effort.text = getString(
R.string.effort_min_avg_max_placeholder,
summary.minEffort.roundToInt().toString(),
summary.avgEffort.roundToInt().toString(),
summary.maxEffort.roundToInt().toString()
)
binding.heartRate.text = getString(
R.string.hr_min_avg_max_placeholder,
summary.minHeartRate.roundToInt().toString(),
summary.avgHeartRate.roundToInt().toString(),
summary.maxHeartRate.roundToInt().toString()
)
}

A list of all properties in RMSummary:

String start

float totalTime
float zoneZeroTime
float zoneOneTime
float zoneTwoTime
float zoneThreeTime
float zoneFourTime
float zoneFiveTime

float totalCalories
float zoneOneCalories
float zoneTwoCalories
float zoneThreeCalories
float zoneFourCalories
float zoneFiveCalories

long totalSteps
long zoneOneSteps
long zoneTwoSteps
long zoneThreeSteps
long zoneFourSteps
long zoneFiveSteps

float zoneOneCadence
float zoneTwoCadence
float zoneThreeCadence
float zoneFourCadence
float zoneFiveCadence

float minHeartRate
float maxHeartRate
float avgHeartRate

float avgEffort
float minEffort
float maxEffort

float avgCadence
float maxCadence
float minCadence

Each HeartRateDerivedData and StepsDerivedData represent a single record. Their properties are exclusive of each type of record except for:

  1. id - Database primary key, you'll never need to used it, ignore.
  2. start - The start timestamp of the training this record belongs to (foreign key).
  3. timestamp - The timestamp in which this record was calculated/created.
  • The properties start and timestamp are in UTC you'll need to adjust them to your users time zone. More about UTC in Getting familiar tutorial.
private fun showRecordsInfo(heartRateRecords: List<HeartRateDerivedData>, stepsRecords: List<StepsDerivedData>) {
binding.heartRateRecords.text = heartRateRecords.size.toString()
binding.stepsRecords.text = stepsRecords.size.toString()
}

Running the app

Wrapping up

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

Finish a training and be redirected to the summary screen.

Show training's basic info, summaries and amount of records.

TrainingSummaryActivity


Next steps

Congratulations! You successfully retrieved a summary of a finished training.