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
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:
- Get training information
- Get summaries
- Get heart rate records
- Get steps records
- 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
andend
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:
- id - Database primary key, you'll never need to used it, ignore.
- start - The start timestamp of the training this record belongs to (foreign key).
- timestamp - The timestamp in which this record was calculated/created.
- The properties
start
andtimestamp
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.
Next steps
Congratulations! You successfully retrieved a summary of a finished training.