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,
NSHealthShareUsageDescriptioninInfo.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.
- Jetpack Compose
- Android XML
- SwiftUI
- UIKit
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}"
)}
}
}
}
}
Same
SmartWalkingSetupViewModelas the Jetpack Compose tab.
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
)
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}"
)}
}
}
}
}
This ViewModel is shared by both SwiftUI and UIKit implementations.
import AFCore
import UIKit
struct SetupState {
var isLoading: Bool = true
var isRegistered: Bool = false
var profile: SmartWalkingProfile?
var errorMessage: String?
}
@MainActor
class SmartWalkingSetupViewModel: ObservableObject {
@Published var state = SetupState()
func checkRegistration() async {
do {
let profile = try await AFCore.shared.smartWalking().getRegisteredProfile()
if let profile = profile, profile.isConnected {
state = SetupState(isLoading: false, isRegistered: true, profile: profile)
} else {
state = SetupState(isLoading: false, isRegistered: false)
}
} catch {
state = SetupState(
isLoading: false,
errorMessage: "Failed to check registration: \(error.localizedDescription)"
)
}
}
func connectAppleHealthKit() async {
state.isLoading = true
state.errorMessage = nil
do {
let deviceId = UIDevice.current.identifierForVendor?.uuidString
?? UUID().uuidString
// This single call handles:
// 1. HealthKit permission request
// 2. User registration with the server
// 3. Enabling auto-sync
let profile = try await AFCore.shared.smartWalking()
.connectAppleHealthKit(deviceId: deviceId)
if profile.isConnected {
state = SetupState(isLoading: false, isRegistered: true, profile: profile)
} else {
state = SetupState(
isLoading: false,
errorMessage: "HealthKit permissions were denied. " +
"Please grant access in Settings > Health > Data Access."
)
}
} catch {
state = SetupState(
isLoading: false,
errorMessage: "Connection failed: \(error.localizedDescription)"
)
}
}
}
Same
SmartWalkingSetupViewModelas the SwiftUI tab.
import AFCore
import UIKit
struct SetupState {
var isLoading: Bool = true
var isRegistered: Bool = false
var profile: SmartWalkingProfile?
var errorMessage: String?
}
@MainActor
class SmartWalkingSetupViewModel: ObservableObject {
@Published var state = SetupState()
func checkRegistration() async {
do {
let profile = try await AFCore.shared.smartWalking().getRegisteredProfile()
if let profile = profile, profile.isConnected {
state = SetupState(isLoading: false, isRegistered: true, profile: profile)
} else {
state = SetupState(isLoading: false, isRegistered: false)
}
} catch {
state = SetupState(
isLoading: false,
errorMessage: "Failed to check registration: \(error.localizedDescription)"
)
}
}
func connectAppleHealthKit() async {
state.isLoading = true
state.errorMessage = nil
do {
let deviceId = UIDevice.current.identifierForVendor?.uuidString
?? UUID().uuidString
let profile = try await AFCore.shared.smartWalking()
.connectAppleHealthKit(deviceId: deviceId)
if profile.isConnected {
state = SetupState(isLoading: false, isRegistered: true, profile: profile)
} else {
state = SetupState(
isLoading: false,
errorMessage: "HealthKit permissions were denied. " +
"Please grant access in Settings > Health > Data Access."
)
}
} catch {
state = SetupState(
isLoading: false,
errorMessage: "Connection failed: \(error.localizedDescription)"
)
}
}
}
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.
- Jetpack Compose
- Android XML
- SwiftUI
- UIKit
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}") }
}
}
}
}
Same
StepDashboardViewModelas the Jetpack Compose tab.
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) { }
}
private suspend fun fetchStepData() {
try {
val today = Clock.System.now()
.toLocalDateTime(TimeZone.currentSystemDefault()).date
val activities = AFCore.activities()
.get(month = today.monthNumber, year = today.year)
val todaySteps = activities
.filterNotNull()
.filter { it.activityDate == today.toString() }
.sumOf { it.steps?.toLong() ?: 0L }
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}") }
}
}
}
}
This ViewModel is shared by both SwiftUI and UIKit implementations.
import AFCore
import Foundation
struct DashboardState {
var todaySteps: Int64 = 0
var dailyGoal: Int32 = 10_000
var weeklyData: [DaySteps] = []
var isSyncing: Bool = false
var isLoading: Bool = true
var errorMessage: String?
}
struct DaySteps: Identifiable {
let id = UUID()
let dayOfWeek: String
let steps: Int64
let date: Date
}
@MainActor
class StepDashboardViewModel: ObservableObject {
@Published var state = DashboardState()
private var syncSubscription: (any Kotlinx_coroutines_coreDisposableHandle)?
func load() async {
observeSyncEvents()
await withTaskGroup(of: Void.self) { group in
group.addTask { await self.fetchDailyGoal() }
group.addTask { await self.fetchStepData() }
}
}
private func fetchDailyGoal() async {
do {
let goal = try await AFCore.shared.smartWalking().getDailyStepGoal()
state.dailyGoal = goal
} catch { }
}
private func fetchStepData() async {
do {
let calendar = Calendar.current
let now = Date()
let month = Int32(calendar.component(.month, from: now))
let year = Int32(calendar.component(.year, from: now))
// Fetch activities from the server (synced by SmartWalking)
let activities = try await AFCore.shared.activities()
.get(month: month, year: year)
let todayString = Self.dateFormatter.string(from: now)
let todaySteps = activities.compactMap { $0 }
.filter { $0.activityDate == todayString }
.compactMap { $0.steps?.int64Value }
.reduce(0, +)
// Weekly data (last 7 days)
let dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
var weekly: [DaySteps] = []
for offset in (0...6) {
guard let date = calendar.date(byAdding: .day, value: offset - 6, to: now) else { continue }
let dateString = Self.dateFormatter.string(from: date)
let steps = activities.compactMap { $0 }
.filter { $0.activityDate == dateString }
.compactMap { $0.steps?.int64Value }
.reduce(0, +)
let weekdayIndex = (calendar.component(.weekday, from: date) + 5) % 7
weekly.append(DaySteps(
dayOfWeek: dayNames[weekdayIndex], steps: steps, date: date
))
}
state.todaySteps = todaySteps
state.weeklyData = weekly
state.isLoading = false
} catch {
state.isLoading = false
state.errorMessage = "Failed to fetch steps: \(error.localizedDescription)"
}
}
private func observeSyncEvents() {
syncSubscription = AFCore.shared.smartWalking().subscribeToEvents(
onEvent: { [weak self] event in
Task { @MainActor in
self?.state.isSyncing = event is SmartWalkingEvent.SyncStarted
if event is SmartWalkingEvent.Idle {
await self?.fetchStepData()
}
}
},
onError: { _ in }
)
}
func manualSync() async {
do {
let result = try await AFCore.shared.smartWalking().syncSteps()
if !result.status { state.errorMessage = result.statusMessage }
} catch {
state.errorMessage = "Sync failed: \(error.localizedDescription)"
}
}
private static let dateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
return f
}()
deinit { syncSubscription?.close() }
}
Same
StepDashboardViewModelas the SwiftUI tab.
import AFCore
import Foundation
struct DashboardState {
var todaySteps: Int64 = 0
var dailyGoal: Int32 = 10_000
var weeklyData: [DaySteps] = []
var isSyncing: Bool = false
var isLoading: Bool = true
var errorMessage: String?
}
struct DaySteps: Identifiable {
let id = UUID()
let dayOfWeek: String
let steps: Int64
let date: Date
}
@MainActor
class StepDashboardViewModel: ObservableObject {
@Published var state = DashboardState()
private var syncSubscription: (any Kotlinx_coroutines_coreDisposableHandle)?
func load() async {
observeSyncEvents()
await withTaskGroup(of: Void.self) { group in
group.addTask { await self.fetchDailyGoal() }
group.addTask { await self.fetchStepData() }
}
}
private func fetchDailyGoal() async {
do {
let goal = try await AFCore.shared.smartWalking().getDailyStepGoal()
state.dailyGoal = goal
} catch { }
}
private func fetchStepData() async {
do {
let calendar = Calendar.current
let now = Date()
let month = Int32(calendar.component(.month, from: now))
let year = Int32(calendar.component(.year, from: now))
let activities = try await AFCore.shared.activities()
.get(month: month, year: year)
let todayString = Self.dateFormatter.string(from: now)
let todaySteps = activities.compactMap { $0 }
.filter { $0.activityDate == todayString }
.compactMap { $0.steps?.int64Value }
.reduce(0, +)
let dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
var weekly: [DaySteps] = []
for offset in (0...6) {
guard let date = calendar.date(byAdding: .day, value: offset - 6, to: now) else { continue }
let dateString = Self.dateFormatter.string(from: date)
let steps = activities.compactMap { $0 }
.filter { $0.activityDate == dateString }
.compactMap { $0.steps?.int64Value }
.reduce(0, +)
let weekdayIndex = (calendar.component(.weekday, from: date) + 5) % 7
weekly.append(DaySteps(
dayOfWeek: dayNames[weekdayIndex], steps: steps, date: date
))
}
state.todaySteps = todaySteps
state.weeklyData = weekly
state.isLoading = false
} catch {
state.isLoading = false
state.errorMessage = "Failed to fetch steps: \(error.localizedDescription)"
}
}
private func observeSyncEvents() {
syncSubscription = AFCore.shared.smartWalking().subscribeToEvents(
onEvent: { [weak self] event in
Task { @MainActor in
self?.state.isSyncing = event is SmartWalkingEvent.SyncStarted
if event is SmartWalkingEvent.Idle {
await self?.fetchStepData()
}
}
},
onError: { _ in }
)
}
func manualSync() async {
do {
let result = try await AFCore.shared.smartWalking().syncSteps()
if !result.status { state.errorMessage = result.statusMessage }
} catch {
state.errorMessage = "Sync failed: \(error.localizedDescription)"
}
}
private static let dateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd"
return f
}()
deinit { syncSubscription?.close() }
}
Step 3: Build the Dashboard UI
- Jetpack Compose
- Android XML
- SwiftUI
- UIKit
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)
}
}
}
}
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Steps"
android:textSize="28sp"
android:textStyle="bold" />
<ProgressBar
android:id="@+id/syncProgress"
android:layout_width="24dp"
android:layout_height="24dp"
android:visibility="gone" />
<ImageButton
android:id="@+id/syncButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Sync now"
android:src="@android:drawable/ic_popup_sync" />
</LinearLayout>
<TextView
android:id="@+id/stepsCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="32dp"
android:textSize="36sp"
android:textStyle="bold" />
<TextView
android:id="@+id/goalLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="32dp"
android:text="This Week"
android:textSize="18sp"
android:textStyle="bold" />
<LinearLayout
android:id="@+id/weeklyChart"
android:layout_width="match_parent"
android:layout_height="160dp"
android:layout_marginTop="12dp"
android:gravity="bottom"
android:orientation="horizontal" />
<TextView
android:id="@+id/errorMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:textColor="@android:color/holo_red_dark"
android:visibility="gone" />
</LinearLayout>
</ScrollView>
// StepDashboardFragment.kt
class StepDashboardFragment : Fragment(R.layout.fragment_step_dashboard) {
private val viewModel: StepDashboardViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val stepsCount = view.findViewById<TextView>(R.id.stepsCount)
val goalLabel = view.findViewById<TextView>(R.id.goalLabel)
val syncButton = view.findViewById<ImageButton>(R.id.syncButton)
val syncProgress = view.findViewById<ProgressBar>(R.id.syncProgress)
val errorMessage = view.findViewById<TextView>(R.id.errorMessage)
syncButton.setOnClickListener { viewModel.manualSync() }
viewLifecycleOwner.lifecycleScope.launch {
viewModel.state.collect { state ->
stepsCount.text = "%,d".format(state.todaySteps)
goalLabel.text = "of %,d".format(state.dailyGoal)
val isSyncing = state.lastEvent is SmartWalkingEvent.SyncStarted
syncButton.visibility = if (isSyncing) View.GONE else View.VISIBLE
syncProgress.visibility = if (isSyncing) View.VISIBLE else View.GONE
if (state.errorMessage != null) {
errorMessage.text = state.errorMessage
errorMessage.visibility = View.VISIBLE
} else {
errorMessage.visibility = View.GONE
}
}
}
viewModel.load()
}
}
import SwiftUI
struct StepDashboardScreen: View {
@StateObject private var viewModel = StepDashboardViewModel()
var body: some View {
ScrollView {
VStack(spacing: 24) {
HStack {
Text("Steps").font(.largeTitle.bold())
Spacer()
SyncIndicator(
isSyncing: viewModel.state.isSyncing,
onSync: { Task { await viewModel.manualSync() } }
)
}
StepGoalRing(
currentSteps: viewModel.state.todaySteps,
goalSteps: viewModel.state.dailyGoal
)
.frame(height: 220)
VStack(alignment: .leading, spacing: 12) {
Text("This Week").font(.title3.bold())
WeeklyBarChart(
data: viewModel.state.weeklyData,
goal: Int(viewModel.state.dailyGoal)
)
.frame(height: 160)
}
if let error = viewModel.state.errorMessage {
Text(error).font(.caption).foregroundStyle(.red)
}
}
.padding()
}
.task { await viewModel.load() }
}
}
struct StepGoalRing: View {
let currentSteps: Int64
let goalSteps: Int32
private var progress: Double {
guard goalSteps > 0 else { return 0 }
return min(Double(currentSteps) / Double(goalSteps), 1.0)
}
var body: some View {
ZStack {
Circle().stroke(Color.gray.opacity(0.2), lineWidth: 16)
Circle()
.trim(from: 0, to: progress)
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 16, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.easeInOut(duration: 0.8), value: progress)
VStack(spacing: 4) {
Text("\(currentSteps)")
.font(.system(size: 36, weight: .bold, design: .rounded))
Text("of \(goalSteps)")
.font(.subheadline).foregroundStyle(.secondary)
}
}
.padding(16)
}
}
struct SyncIndicator: View {
let isSyncing: Bool
let onSync: () -> Void
var body: some View {
if isSyncing {
HStack(spacing: 6) {
ProgressView().scaleEffect(0.8)
Text("Syncing...").font(.caption).foregroundStyle(.secondary)
}
} else {
Button(action: onSync) {
Image(systemName: "arrow.clockwise").font(.title3)
}
}
}
}
struct WeeklyBarChart: View {
let data: [DaySteps]
let goal: Int
private var maxSteps: Int64 {
max(data.map(\.steps).max() ?? 0, Int64(goal))
}
var body: some View {
VStack(spacing: 8) {
HStack(alignment: .bottom, spacing: 8) {
ForEach(data) { day in
VStack(spacing: 4) {
if day.steps > 0 {
Text("\(day.steps)")
.font(.system(size: 9)).foregroundStyle(.secondary)
}
RoundedRectangle(cornerRadius: 4)
.fill(day.steps >= Int64(goal) ? Color.green : Color.accentColor)
.frame(height: maxSteps > 0
? max(CGFloat(day.steps) / CGFloat(maxSteps) * 120, 2)
: 2)
}
.frame(maxWidth: .infinity)
}
}
HStack(spacing: 8) {
ForEach(data) { day in
Text(day.dayOfWeek)
.font(.caption2).foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
}
}
}
}
}
import AFCore
import Combine
import UIKit
class StepDashboardViewController: UIViewController {
private let viewModel = StepDashboardViewModel()
private var cancellables = Set<AnyCancellable>()
private let stepsLabel = UILabel()
private let goalLabel = UILabel()
private let syncButton = UIButton(type: .system)
private let syncSpinner = UIActivityIndicatorView(style: .medium)
private let weeklyStack = UIStackView()
private let errorLabel = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
setupLayout()
bindViewModel()
Task { await viewModel.load() }
}
private func setupLayout() {
let titleLabel = UILabel()
titleLabel.text = "Steps"
titleLabel.font = .systemFont(ofSize: 28, weight: .bold)
syncButton.setImage(UIImage(systemName: "arrow.clockwise"), for: .normal)
syncButton.addAction(UIAction { [weak self] _ in
Task { await self?.viewModel.manualSync() }
}, for: .touchUpInside)
let headerStack = UIStackView(arrangedSubviews: [titleLabel, UIView(), syncSpinner, syncButton])
headerStack.spacing = 8
headerStack.alignment = .center
stepsLabel.font = .systemFont(ofSize: 36, weight: .bold)
stepsLabel.textAlignment = .center
goalLabel.font = .preferredFont(forTextStyle: .body)
goalLabel.textColor = .secondaryLabel
goalLabel.textAlignment = .center
let weekTitle = UILabel()
weekTitle.text = "This Week"
weekTitle.font = .systemFont(ofSize: 18, weight: .bold)
weeklyStack.distribution = .fillEqually
weeklyStack.alignment = .bottom
weeklyStack.spacing = 4
errorLabel.font = .preferredFont(forTextStyle: .caption1)
errorLabel.textColor = .systemRed
errorLabel.numberOfLines = 0
errorLabel.isHidden = true
let mainStack = UIStackView(arrangedSubviews: [
headerStack, stepsLabel, goalLabel, weekTitle, weeklyStack, errorLabel
])
mainStack.axis = .vertical
mainStack.spacing = 16
mainStack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(mainStack)
NSLayoutConstraint.activate([
mainStack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
mainStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
mainStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
weeklyStack.heightAnchor.constraint(equalToConstant: 160)
])
}
private func bindViewModel() {
viewModel.$state
.receive(on: DispatchQueue.main)
.sink { [weak self] state in self?.updateUI(state) }
.store(in: &cancellables)
}
private func updateUI(_ state: DashboardState) {
stepsLabel.text = NumberFormatter.localizedString(
from: NSNumber(value: state.todaySteps), number: .decimal)
goalLabel.text = "of \(NumberFormatter.localizedString(
from: NSNumber(value: state.dailyGoal), number: .decimal))"
syncButton.isHidden = state.isSyncing
if state.isSyncing { syncSpinner.startAnimating() } else { syncSpinner.stopAnimating() }
errorLabel.text = state.errorMessage
errorLabel.isHidden = state.errorMessage == nil
// Update weekly bars
weeklyStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
let maxSteps = max(state.weeklyData.map(\.steps).max() ?? 0, Int64(state.dailyGoal))
for day in state.weeklyData {
let col = UIStackView()
col.axis = .vertical
col.spacing = 4
col.alignment = .center
let bar = UIView()
bar.backgroundColor = day.steps >= Int64(state.dailyGoal)
? .systemGreen : .systemBlue
bar.layer.cornerRadius = 4
let fraction = maxSteps > 0 ? CGFloat(day.steps) / CGFloat(maxSteps) : 0
bar.heightAnchor.constraint(equalToConstant: max(fraction * 120, 2)).isActive = true
bar.widthAnchor.constraint(equalToConstant: 24).isActive = true
let label = UILabel()
label.text = day.dayOfWeek
label.font = .systemFont(ofSize: 10)
label.textColor = .secondaryLabel
col.addArrangedSubview(bar)
col.addArrangedSubview(label)
weeklyStack.addArrangedSubview(col)
}
}
}
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.
- Jetpack Compose
- Android XML
- SwiftUI
- UIKit
@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)
}
}
}
// StepTrackingActivity.kt
class StepTrackingActivity : AppCompatActivity() {
private val setupViewModel: SmartWalkingSetupViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_step_tracking)
lifecycleScope.launch {
setupViewModel.state.collect { state ->
when {
state.isLoading -> showFragment(LoadingFragment())
state.isRegistered -> showFragment(StepDashboardFragment())
else -> {
val setupFragment = SmartWalkingSetupFragment().apply {
onConnectClick = { setupViewModel.connectHealthConnect(this@StepTrackingActivity) }
}
showFragment(setupFragment)
}
}
}
}
setupViewModel.checkRegistration()
}
private fun showFragment(fragment: Fragment) {
supportFragmentManager.beginTransaction()
.replace(R.id.container, fragment)
.commit()
}
}
struct StepTrackingFlow: View {
@StateObject private var setupViewModel = SmartWalkingSetupViewModel()
var body: some View {
Group {
if setupViewModel.state.isLoading {
ProgressView()
} else if setupViewModel.state.isRegistered {
StepDashboardScreen()
} else {
SmartWalkingSetupScreen(
errorMessage: setupViewModel.state.errorMessage,
onConnect: {
Task { await setupViewModel.connectAppleHealthKit() }
}
)
}
}
.task { await setupViewModel.checkRegistration() }
}
}
struct SmartWalkingSetupScreen: View {
let errorMessage: String?
let onConnect: () -> Void
var body: some View {
VStack(spacing: 24) {
Spacer()
Text("Track Your Steps").font(.title.bold())
Text("Connect Apple Health to automatically sync your daily step count.")
.multilineTextAlignment(.center).foregroundStyle(.secondary)
Button(action: onConnect) {
Text("Connect Apple Health").frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent).controlSize(.large)
if let error = errorMessage {
Text(error).font(.caption).foregroundStyle(.red)
}
Spacer()
}
.padding(32)
}
}
import AFCore
import Combine
import UIKit
class StepTrackingViewController: UIViewController {
private let setupViewModel = SmartWalkingSetupViewModel()
private var cancellables = Set<AnyCancellable>()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
setupViewModel.$state
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self else { return }
if state.isLoading {
self.showChild(LoadingViewController())
} else if state.isRegistered {
self.showChild(StepDashboardViewController())
} else {
let setupVC = SmartWalkingSetupViewController(
errorMessage: state.errorMessage,
onConnect: { [weak self] in
Task { await self?.setupViewModel.connectAppleHealthKit() }
}
)
self.showChild(setupVC)
}
}
.store(in: &cancellables)
Task { await setupViewModel.checkRegistration() }
}
private func showChild(_ child: UIViewController) {
children.forEach {
$0.willMove(toParent: nil)
$0.view.removeFromSuperview()
$0.removeFromParent()
}
addChild(child)
child.view.frame = view.bounds
child.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(child.view)
child.didMove(toParent: self)
}
}
class SmartWalkingSetupViewController: UIViewController {
private let errorMessage: String?
private let onConnect: () -> Void
init(errorMessage: String?, onConnect: @escaping () -> Void) {
self.errorMessage = errorMessage
self.onConnect = onConnect
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
let titleLabel = UILabel()
titleLabel.text = "Track Your Steps"
titleLabel.font = .systemFont(ofSize: 24, weight: .bold)
titleLabel.textAlignment = .center
let descLabel = UILabel()
descLabel.text = "Connect Apple Health to automatically sync your daily step count."
descLabel.textAlignment = .center
descLabel.numberOfLines = 0
descLabel.textColor = .secondaryLabel
let connectButton = UIButton(type: .system)
connectButton.setTitle("Connect Apple Health", for: .normal)
connectButton.addAction(UIAction { [weak self] _ in
self?.onConnect()
}, for: .touchUpInside)
let stack = UIStackView(arrangedSubviews: [titleLabel, descLabel, connectButton])
stack.axis = .vertical
stack.spacing = 24
stack.alignment = .center
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.centerXAnchor.constraint(equalTo: view.centerXAnchor),
stack.centerYAnchor.constraint(equalTo: view.centerYAnchor),
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32),
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32)
])
if let error = errorMessage {
let errorLabel = UILabel()
errorLabel.text = error
errorLabel.textColor = .systemRed
errorLabel.font = .preferredFont(forTextStyle: .caption1)
errorLabel.numberOfLines = 0
stack.addArrangedSubview(errorLabel)
}
}
}
class LoadingViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let spinner = UIActivityIndicatorView(style: .large)
spinner.startAnimating()
spinner.center = view.center
spinner.autoresizingMask = [.flexibleTopMargin, .flexibleBottomMargin,
.flexibleLeftMargin, .flexibleRightMargin]
view.addSubview(spinner)
}
}
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
| Aspect | Android | iOS |
|---|---|---|
| Health source | Google Health Connect | Apple HealthKit |
| Setup method | connectGoogleHealthConnect(context, deviceId) | connectAppleHealthKit(deviceId) |
| Device ID | Settings.Secure.ANDROID_ID | UIDevice.identifierForVendor |
| Background sync | WorkManager periodic task (~hourly) | HealthKit background delivery observer |
| Permission UI | Health Connect permission dialog | HealthKit authorization sheet |
Next Steps
- SmartWalking Guide -- Deep dive into sync lifecycle, diagnostics, and data sources.
- Automatic Gym Check-In -- Complement step tracking with geofence-based gym visit detection.