Skip to main content

Step Tracking Dashboard

This recipe builds a complete step tracking dashboard that registers the user with SmartWalking, connects to the platform health source (Health Connect on Android, HealthKit on iOS), displays daily steps with a goal ring, renders a weekly bar chart, and supports manual and automatic background syncing.

AFCore 1.7.1

What You Will Build

  • A setup flow that registers the user and connects the health platform.
  • A dashboard showing today's steps, a daily goal progress ring, and a weekly bar chart.
  • A sync event indicator and manual sync button.
  • Automatic background sync via SmartWalking's auto-sync.

Prerequisites

  • AFCore is installed and initialized.
  • The member is authenticated.
  • The member has the "smartwalking" program entitlement (check via AFCore.programs().getAvailablePrograms()).
  • Android: Health Connect app installed on the device. Health Connect intent filters declared in AndroidManifest.xml (see Installation).
  • iOS: HealthKit entitlement added, NSHealthShareUsageDescription in Info.plist, Background Modes includes "Background fetch" (see Installation).

Step 1: SmartWalking Setup Flow

Before showing the dashboard, check whether the user is already registered. If not, run the setup flow.

This ViewModel is shared by both Jetpack Compose and Android XML implementations.

import android.content.Context
import android.provider.Settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.advantahealth.api.AFCore
import com.advantahealth.api.smartwalking.model.SmartWalkingProfile
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

data class SetupState(
val isLoading: Boolean = true,
val isRegistered: Boolean = false,
val profile: SmartWalkingProfile? = null,
val errorMessage: String? = null
)

class SmartWalkingSetupViewModel : ViewModel() {

private val _state = MutableStateFlow(SetupState())
val state = _state.asStateFlow()

fun checkRegistration() {
viewModelScope.launch {
try {
val profile = AFCore.smartWalking().getRegisteredProfile()
if (profile != null && profile.isConnected) {
_state.update { it.copy(
isLoading = false,
isRegistered = true,
profile = profile
)}
} else {
_state.update { it.copy(isLoading = false, isRegistered = false) }
}
} catch (e: Throwable) {
_state.update { it.copy(
isLoading = false,
errorMessage = "Failed to check registration: ${e.message}"
)}
}
}
}

fun connectHealthConnect(context: Context) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, errorMessage = null) }
try {
val deviceId = Settings.Secure.getString(
context.contentResolver,
Settings.Secure.ANDROID_ID
)

// This single call handles:
// 1. Health Connect permission request
// 2. User registration with the server
// 3. Enabling auto-sync
val profile = AFCore.smartWalking()
.connectGoogleHealthConnect(context, deviceId)

if (profile.isConnected) {
_state.update { it.copy(
isLoading = false,
isRegistered = true,
profile = profile
)}
} else {
_state.update { it.copy(
isLoading = false,
errorMessage = "Health Connect permissions were denied. " +
"Please grant access in Settings."
)}
}
} catch (e: Throwable) {
_state.update { it.copy(
isLoading = false,
errorMessage = "Connection failed: ${e.message}"
)}
}
}
}
}

Step 2: Dashboard ViewModel

The dashboard ViewModel fetches the daily step goal, retrieves step data from the Activities API (server-side data synced by SmartWalking), and observes sync events.

This ViewModel is shared by both Jetpack Compose and Android XML implementations.

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.advantahealth.api.AFCore
import com.advantahealth.api.smartwalking.model.SmartWalkingEvent
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.datetime.*

data class DashboardState(
val todaySteps: Long = 0,
val dailyGoal: Int = 10_000,
val weeklyData: List<DaySteps> = emptyList(),
val lastEvent: SmartWalkingEvent = SmartWalkingEvent.Idle,
val isLoading: Boolean = true,
val errorMessage: String? = null
)

data class DaySteps(
val dayOfWeek: String,
val steps: Long,
val date: LocalDate
)

class StepDashboardViewModel : ViewModel() {

private val _state = MutableStateFlow(DashboardState())
val state = _state.asStateFlow()

fun load() {
viewModelScope.launch { fetchDailyGoal() }
viewModelScope.launch { fetchStepData() }
viewModelScope.launch { observeSyncEvents() }
}

private suspend fun fetchDailyGoal() {
try {
val goal = AFCore.smartWalking().getDailyStepGoal()
_state.update { it.copy(dailyGoal = goal) }
} catch (_: Throwable) {
// Use default 10,000
}
}

private suspend fun fetchStepData() {
try {
val today = Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault()).date

// Fetch activities from the server (synced by SmartWalking)
val activities = AFCore.activities()
.get(month = today.monthNumber, year = today.year)

// Today's steps
val todaySteps = activities
.filterNotNull()
.filter { it.activityDate == today.toString() }
.sumOf { it.steps?.toLong() ?: 0L }

// Weekly data (last 7 days)
val dayNames = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
val startOfWeek = today.minus(6, DateTimeUnit.DAY)
val weeklySteps = (0..6).map { offset ->
val date = startOfWeek.plus(offset, DateTimeUnit.DAY)
val steps = activities
.filterNotNull()
.filter { it.activityDate == date.toString() }
.sumOf { it.steps?.toLong() ?: 0L }
DaySteps(
dayOfWeek = dayNames[date.dayOfWeek.ordinal],
steps = steps,
date = date
)
}

_state.update { it.copy(
todaySteps = todaySteps,
weeklyData = weeklySteps,
isLoading = false
)}
} catch (e: Throwable) {
_state.update { it.copy(
isLoading = false,
errorMessage = "Failed to fetch steps: ${e.message}"
)}
}
}

private suspend fun observeSyncEvents() {
AFCore.smartWalking().syncEvents.collect { event ->
_state.update { it.copy(lastEvent = event) }
if (event is SmartWalkingEvent.Idle) {
fetchStepData()
}
}
}

fun manualSync() {
viewModelScope.launch {
try {
val result = AFCore.smartWalking().syncSteps()
if (!result.status) {
_state.update { it.copy(errorMessage = result.statusMessage) }
}
} catch (e: Throwable) {
_state.update { it.copy(errorMessage = "Sync failed: ${e.message}") }
}
}
}
}

Step 3: Build the Dashboard UI

import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.advantahealth.api.smartwalking.model.SmartWalkingEvent

@Composable
fun StepDashboardScreen(viewModel: StepDashboardViewModel = viewModel()) {
val state by viewModel.state.collectAsState()

LaunchedEffect(Unit) { viewModel.load() }

Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Steps", style = MaterialTheme.typography.headlineLarge)
SyncIndicator(
isSyncing = state.lastEvent is SmartWalkingEvent.SyncStarted,
onSyncTap = { viewModel.manualSync() }
)
}

Spacer(modifier = Modifier.height(32.dp))

Box(
modifier = Modifier.fillMaxWidth().height(220.dp),
contentAlignment = Alignment.Center
) {
StepGoalRing(currentSteps = state.todaySteps, goalSteps = state.dailyGoal)
}

Spacer(modifier = Modifier.height(32.dp))

Text("This Week", style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 12.dp))

WeeklyBarChart(
data = state.weeklyData,
goal = state.dailyGoal,
modifier = Modifier.fillMaxWidth().height(160.dp)
)

state.errorMessage?.let { error ->
Spacer(modifier = Modifier.height(16.dp))
Text(error, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall)
}
}
}

@Composable
fun StepGoalRing(currentSteps: Long, goalSteps: Int) {
val progress = (currentSteps.toFloat() / goalSteps).coerceIn(0f, 1f)
val animatedProgress by animateFloatAsState(targetValue = progress, label = "goalProgress")
val primaryColor = MaterialTheme.colorScheme.primary
val trackColor = MaterialTheme.colorScheme.surfaceVariant

Box(contentAlignment = Alignment.Center) {
Canvas(modifier = Modifier.size(200.dp)) {
val strokeWidth = 16.dp.toPx()
val arcSize = Size(size.width - strokeWidth, size.height - strokeWidth)
val topLeft = Offset(strokeWidth / 2, strokeWidth / 2)

drawArc(color = trackColor, startAngle = -90f, sweepAngle = 360f,
useCenter = false, topLeft = topLeft, size = arcSize,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round))

drawArc(color = primaryColor, startAngle = -90f,
sweepAngle = 360f * animatedProgress,
useCenter = false, topLeft = topLeft, size = arcSize,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round))
}

Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("%,d".format(currentSteps), fontSize = 36.sp, fontWeight = FontWeight.Bold)
Text("of %,d".format(goalSteps), style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}

@Composable
fun SyncIndicator(isSyncing: Boolean, onSyncTap: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (isSyncing) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(modifier = Modifier.width(8.dp))
Text("Syncing...", style = MaterialTheme.typography.bodySmall)
} else {
IconButton(onClick = onSyncTap) {
Icon(Icons.Default.Refresh, contentDescription = "Sync now")
}
}
}
}

@Composable
fun WeeklyBarChart(data: List<DaySteps>, goal: Int, modifier: Modifier = Modifier) {
val primaryColor = MaterialTheme.colorScheme.primary
val goalColor = MaterialTheme.colorScheme.tertiary
val maxSteps = maxOf(data.maxOfOrNull { it.steps } ?: 0, goal.toLong())

Column(modifier = modifier) {
Row(
modifier = Modifier.fillMaxWidth().weight(1f),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.Bottom
) {
data.forEach { day ->
val heightFraction = if (maxSteps > 0)
(day.steps.toFloat() / maxSteps).coerceIn(0f, 1f) else 0f
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.weight(1f)
) {
if (day.steps > 0) {
Text("%,d".format(day.steps),
style = MaterialTheme.typography.labelSmall,
fontSize = 9.sp, textAlign = TextAlign.Center)
}
Spacer(modifier = Modifier.height(4.dp))
Surface(
modifier = Modifier
.fillMaxWidth(0.5f)
.fillMaxHeight(heightFraction.coerceAtLeast(0.02f)),
shape = MaterialTheme.shapes.small,
color = if (day.steps >= goal) goalColor else primaryColor
) {}
}
}
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
data.forEach { day ->
Text(day.dayOfWeek, style = MaterialTheme.typography.labelSmall,
modifier = Modifier.weight(1f), textAlign = TextAlign.Center)
}
}
}
}

Step 4: Combine Setup and Dashboard

Wire the setup and dashboard together so new users see the registration flow and returning users go straight to the dashboard.

@Composable
fun StepTrackingFlow() {
val setupViewModel: SmartWalkingSetupViewModel = viewModel()
val setupState by setupViewModel.state.collectAsState()

LaunchedEffect(Unit) { setupViewModel.checkRegistration() }

when {
setupState.isLoading -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
setupState.isRegistered -> StepDashboardScreen()
else -> {
SmartWalkingSetupScreen(
errorMessage = setupState.errorMessage,
onConnect = { context -> setupViewModel.connectHealthConnect(context) }
)
}
}
}

@Composable
fun SmartWalkingSetupScreen(errorMessage: String?, onConnect: (Context) -> Unit) {
val context = LocalContext.current

Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("Track Your Steps", style = MaterialTheme.typography.headlineMedium)
Spacer(modifier = Modifier.height(12.dp))
Text(
"Connect Health Connect to automatically sync your daily step count.",
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(32.dp))
Button(onClick = { onConnect(context) }) { Text("Connect Health Connect") }
errorMessage?.let { error ->
Spacer(modifier = Modifier.height(16.dp))
Text(error, color = MaterialTheme.colorScheme.error)
}
}
}

Background Sync

After calling connectGoogleHealthConnect() or connectAppleHealthKit(), auto-sync is enabled automatically. The SDK handles background syncing:

  • Android: A WorkManager periodic task runs approximately every hour when the device has network connectivity and the battery is not critically low.
  • iOS: A HealthKit background delivery observer fires when new step data is available (approximately hourly).

You do not need to call enableAutoSync() manually -- the connect methods do this for you. If you ever need to toggle it:

// Disable
AFCore.smartWalking().disableAutoSync()

// Re-enable
AFCore.smartWalking().enableAutoSync()

Platform Differences

AspectAndroidiOS
Health sourceGoogle Health ConnectApple HealthKit
Setup methodconnectGoogleHealthConnect(context, deviceId)connectAppleHealthKit(deviceId)
Device IDSettings.Secure.ANDROID_IDUIDevice.identifierForVendor
Background syncWorkManager periodic task (~hourly)HealthKit background delivery observer
Permission UIHealth Connect permission dialogHealthKit authorization sheet

Next Steps