Skip to content

Commit

Permalink
Merge pull request #290 from breanatate/ExerciseGoals
Browse files Browse the repository at this point in the history
Adds support for Exercise Goals
  • Loading branch information
breanatate authored Aug 26, 2024
2 parents efa7d41 + a077b30 commit 73a4343
Show file tree
Hide file tree
Showing 15 changed files with 484 additions and 70 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ sealed class Screen(
object Exercise : Screen("exercise")
object ExerciseNotAvailable : Screen("exerciseNotAvailable")
object PreparingExercise : Screen("preparingExercise")
object Goals : Screen(route = "goals")
object Summary : Screen("summaryScreen") {
fun buildRoute(summary: SummaryScreenState): String {
return "$route/${summary.averageHeartRate}/${summary.totalDistance}/${summary.totalCalories}/${summary.elapsedTime}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,9 @@ fun supportsCalorieGoal(capabilities: ExerciseTypeCapabilities): Boolean {
fun supportsDistanceMilestone(capabilities: ExerciseTypeCapabilities): Boolean {
val supported = capabilities.supportedMilestones[DataType.DISTANCE_TOTAL]
return supported != null && ComparisonType.GREATER_THAN_OR_EQUAL in supported
}

fun supportsDurationMilestone(capabilities: ExerciseTypeCapabilities) : Boolean{
val supported = capabilities.supportedGoals[DataType.ACTIVE_EXERCISE_DURATION_TOTAL]
return supported != null && ComparisonType.GREATER_THAN_OR_EQUAL in supported
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,16 @@ import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.callbackFlow
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration

/**
* Entry point for [HealthServicesClient] APIs, wrapping them in coroutine-friendly APIs.
*/
@SuppressLint("RestrictedApi")
@Singleton
class ExerciseClientManager @Inject constructor(
val healthServicesClient: HealthServicesClient,
val logger: ExerciseLogger
healthServicesClient: HealthServicesClient,
private val logger: ExerciseLogger
) {
val exerciseClient: ExerciseClient = healthServicesClient.exerciseClient

Expand All @@ -66,6 +67,12 @@ class ExerciseClientManager @Inject constructor(
}
}

private var thresholds = Thresholds(0.0, Duration.ZERO)

fun updateGoals(newThresholds: Thresholds){
thresholds = newThresholds.copy()
}

suspend fun startExercise() {
logger.log("Starting exercise")
// Types for which we want to receive metrics. Only ask for ones that are supported.
Expand All @@ -82,7 +89,7 @@ class ExerciseClientManager @Inject constructor(
DataType.CALORIES_TOTAL,
DataType.DISTANCE_TOTAL,
).intersect(capabilities.supportedDataTypes)
val exerciseGoals = mutableListOf<ExerciseGoal<Double>>()
val exerciseGoals = mutableListOf<ExerciseGoal<*>>()
if (supportsCalorieGoal(capabilities)) {
// Create a one-time goal.
exerciseGoals.add(
Expand All @@ -96,20 +103,33 @@ class ExerciseClientManager @Inject constructor(
)
}

if (supportsDistanceMilestone(capabilities)) {
// Create a milestone goal. To make a milestone for every kilometer, set the initial
// threshold to 1km and the period to 1km.
// Set a distance goal if it's supported by the exercise and the user has entered one
if (supportsDistanceMilestone(capabilities) && thresholds.distanceIsSet) {
exerciseGoals.add(
ExerciseGoal.createMilestone(
ExerciseGoal.createOneTimeGoal(
condition = DataTypeCondition(
dataType = DataType.DISTANCE_TOTAL,
threshold = DISTANCE_THRESHOLD,
threshold = thresholds.distance * 1000, //our app uses kilometers
comparisonType = ComparisonType.GREATER_THAN_OR_EQUAL
)
)
)
}

// Set a duration goal if it's supported by the exercise and the user has entered one
if (supportsDurationMilestone(capabilities) && thresholds.durationIsSet) {
exerciseGoals.add(
ExerciseGoal.createOneTimeGoal(
DataTypeCondition(
dataType = DataType.ACTIVE_EXERCISE_DURATION_TOTAL,
threshold = thresholds.duration.inWholeSeconds,
comparisonType = ComparisonType.GREATER_THAN_OR_EQUAL
), period = DISTANCE_THRESHOLD
)
)
)
}


val supportsAutoPauseAndResume = capabilities.supportsAutoPauseAndResume

val config = ExerciseConfig(
Expand Down Expand Up @@ -205,10 +225,16 @@ class ExerciseClientManager @Inject constructor(

private companion object {
const val CALORIES_THRESHOLD = 250.0
const val DISTANCE_THRESHOLD = 1_000.0 // meters
}
}

data class Thresholds(
var distance: Double,
var duration: Duration,
var durationIsSet: Boolean = duration!= Duration.ZERO,
var distanceIsSet: Boolean = distance!=0.0,
)


sealed class ExerciseMessage {
class ExerciseUpdateMessage(val exerciseUpdate: ExerciseUpdate) : ExerciseMessage()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:OptIn(ExperimentalHorologistApi::class)

package com.example.exercisesamplecompose.presentation

import ExerciseGoalsRoute
import ExerciseGoalsScreen
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.navigation.NavHostController
Expand All @@ -25,6 +26,7 @@ import androidx.navigation.navArgument
import androidx.wear.compose.navigation.SwipeDismissableNavHost
import androidx.wear.compose.navigation.composable
import androidx.wear.compose.navigation.currentBackStackEntryAsState
import com.example.exercisesamplecompose.app.Screen
import com.example.exercisesamplecompose.app.Screen.Exercise
import com.example.exercisesamplecompose.app.Screen.ExerciseNotAvailable
import com.example.exercisesamplecompose.app.Screen.PreparingExercise
Expand All @@ -34,7 +36,6 @@ import com.example.exercisesamplecompose.presentation.dialogs.ExerciseNotAvailab
import com.example.exercisesamplecompose.presentation.exercise.ExerciseRoute
import com.example.exercisesamplecompose.presentation.preparing.PreparingExerciseRoute
import com.example.exercisesamplecompose.presentation.summary.SummaryRoute
import com.google.android.horologist.annotations.ExperimentalHorologistApi
import com.google.android.horologist.compose.ambient.AmbientAware
import com.google.android.horologist.compose.ambient.AmbientState
import com.google.android.horologist.compose.layout.AppScaffold
Expand Down Expand Up @@ -83,7 +84,8 @@ fun ExerciseSampleApp(
}
}
},
onFinishActivity = onFinishActivity
onFinishActivity = onFinishActivity,
onGoals = { navController.navigate(Screen.Goals.route) }
)
}

Expand Down Expand Up @@ -119,6 +121,9 @@ fun ExerciseSampleApp(
}
)
}
composable(Screen.Goals.route) {
ExerciseGoalsRoute(onSet = { navController.popBackStack() })
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.example.exercisesamplecompose.presentation.dialogs

import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SportsScore
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.wear.compose.material.Icon
import androidx.wear.compose.material.dialog.DialogDefaults
import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices
import com.example.exercisesamplecompose.R
import com.google.android.horologist.compose.material.Confirmation

@Composable
fun ExerciseGoalMet(
showDialog: Boolean,
) {
Confirmation(modifier = Modifier
.fillMaxSize()
.fillMaxWidth(),
icon = {
Icon(
Icons.Default.SportsScore,
contentDescription = stringResource(id = R.string.goal_achieved)
)
},
title = stringResource(id = R.string.goal_achieved),
showDialog = showDialog,
durationMillis = DialogDefaults.LongDurationMillis,
onTimeout = {})
}

@WearPreviewDevices
@Composable
fun ExerciseGoalMetPreview() {
ExerciseGoalMet(true)
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

package com.example.exercisesamplecompose.presentation.exercise

import android.util.Log
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
Expand Down Expand Up @@ -47,6 +48,7 @@ import com.example.exercisesamplecompose.presentation.component.ResumeButton
import com.example.exercisesamplecompose.presentation.component.StartButton
import com.example.exercisesamplecompose.presentation.component.StopButton
import com.example.exercisesamplecompose.presentation.component.formatElapsedTime
import com.example.exercisesamplecompose.presentation.dialogs.ExerciseGoalMet
import com.example.exercisesamplecompose.presentation.summary.SummaryScreenState
import com.example.exercisesamplecompose.presentation.theme.ThemePreview
import com.example.exercisesamplecompose.service.ExerciseServiceState
Expand Down Expand Up @@ -95,7 +97,7 @@ fun ExerciseRoute(
}

/**
* Shows an error that occured when starting an exercise
* Shows an error that occurred when starting an exercise
*/
@Composable
fun ErrorStartingExerciseScreen(
Expand Down Expand Up @@ -162,6 +164,14 @@ fun ExerciseScreen(
}
}
}
//If we meet an exercise goal, show our exercise met dialog.
//This approach is for the sample, and doesn't guarantee processing of this event in all cases,
//such as the user exiting the app while this is in-progress. Consider alternatives to exposing
//state in a production app.
uiState.exerciseState?.exerciseGoal?.let {
Log.d("ExerciseGoalMet", "Showing exercise goal met dialog")
ExerciseGoalMet(it.isNotEmpty())
}
}

@Composable
Expand Down
Loading

0 comments on commit 73a4343

Please sign in to comment.