Skip to main content

Automatic Gym Check-In

This recipe builds a complete automatic gym check-in feature. When a member arrives at a gym, the app detects their presence via geofencing, waits for a dwell period to confirm they are actually working out, and automatically records the visit -- all without the member opening the app. The SDK handles visit submission internally; your app only needs to observe geofence events for UI updates.

AFCore 1.7.1

What You Will Build

  • A permission flow using AFPermissions to request foreground and background location.
  • Geofence monitoring that tracks the member's facilities as monitored regions.
  • An event handler that listens for enter, dwell, and exit transitions.
  • A UI that shows real-time check-in status.

Prerequisites

  • AFCore is installed and initialized.
  • The member is authenticated.
  • Location usage descriptions are configured in Info.plist (iOS) and permissions are declared in AndroidManifest.xml (Android). See the installation guides for iOS and Android.
  • Background Modes are enabled on iOS (Location updates).
  • Android: Call AFPermissions.registerLifecycle(activity) in your foreground Activity's onCreate().

Step 1: Request Location Permissions

Geofencing requires fine location on both platforms, plus background location for monitoring while the app is not in the foreground. Use AFPermissions to handle the platform-specific flows.

On Android, you must request FINE_LOCATION first, then BACKGROUND_LOCATION (enforced by the OS on Android 11+). On iOS, AFPermissions internally sequences When-In-Use then Always when you request BACKGROUND_LOCATION.

Register AFPermissions in your Activity, then use it in Compose:

// MainActivity.kt
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
AFPermissions.registerLifecycle(this)
setContent { GymCheckInScreen() }
}
}
// LocationPermissionGate.kt
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.advantahealth.api.AFPermissions
import com.advantahealth.api.Permission
import com.advantahealth.api.PermissionStatus

@Composable
fun LocationPermissionGate(
onAllGranted: () -> Unit,
content: @Composable () -> Unit
) {
var fineGranted by remember { mutableStateOf(false) }
var backgroundGranted by remember { mutableStateOf(false) }
var checked by remember { mutableStateOf(false) }

LaunchedEffect(Unit) {
val fineStatus = AFPermissions.getPermissionStatus(Permission.FINE_LOCATION)
fineGranted = fineStatus == PermissionStatus.GRANTED

val bgStatus = AFPermissions.getPermissionStatus(Permission.BACKGROUND_LOCATION)
backgroundGranted = bgStatus == PermissionStatus.GRANTED

if (!fineGranted) {
val result = AFPermissions.requestPermission(Permission.FINE_LOCATION)
fineGranted = result == PermissionStatus.GRANTED
}
if (fineGranted && !backgroundGranted) {
val result = AFPermissions.requestPermission(Permission.BACKGROUND_LOCATION)
backgroundGranted = result == PermissionStatus.GRANTED
}
checked = true
if (fineGranted && backgroundGranted) onAllGranted()
}

when {
!checked -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
fineGranted && backgroundGranted -> content()
else -> {
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
if (fineGranted) "Background Location Required"
else "Location Access Required",
style = MaterialTheme.typography.titleMedium
)
Spacer(modifier = Modifier.height(8.dp))
Text(
if (fineGranted) "Automatic gym check-in needs 'Always' location access."
else "We need your location to automatically check you in at the gym.",
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { AFPermissions.openAppSettings() }) {
Text("Open Settings")
}
}
}
}
}

Step 2: Check-In ViewModel

The ViewModel fetches the member's facilities, starts geofence monitoring, and listens for enter/dwell/exit events to update the UI. Visit submission is handled automatically by the SDK.

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.geofencing.model.AFGeofenceEvent
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

data class CheckInState(
val status: CheckInStatus = CheckInStatus.IDLE,
val facilityName: String? = null,
val facilityId: String? = null,
val errorMessage: String? = null
)

enum class CheckInStatus {
IDLE, MONITORING, ARRIVED, CHECKED_IN, ERROR
}

class GymCheckInViewModel : ViewModel() {

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

private var facilityMap = emptyMap<String, String>()

fun setup() {
viewModelScope.launch {
try {
val facilities = AFCore.facilities().get().filterNotNull()
facilityMap = facilities.associate {
it.id.toString() to (it.name ?: "Unknown Gym")
}

AFCore.facilities().startMonitoring()
_state.update { it.copy(status = CheckInStatus.MONITORING) }

subscribeToGeofenceEvents()
} catch (e: Throwable) {
_state.update { it.copy(
status = CheckInStatus.ERROR,
errorMessage = "Setup failed: ${e.message}"
)}
}
}
}

private fun subscribeToGeofenceEvents() {
AFCore.facilities().subscribeToEvents(
onEvent = { event ->
when (event) {
is AFGeofenceEvent.Entered -> handleEnter(event)
is AFGeofenceEvent.Dwell -> handleDwell(event)
is AFGeofenceEvent.Exited -> handleExit(event)
is AFGeofenceEvent.Error -> {
_state.update { it.copy(
status = CheckInStatus.ERROR,
errorMessage = event.throwable.message
)}
}
else -> { /* Registered, Unregistered -- no UI action */ }
}
},
onError = { error ->
_state.update { it.copy(
status = CheckInStatus.ERROR,
errorMessage = error.message
)}
}
)
}

private fun handleEnter(event: AFGeofenceEvent.Entered) {
_state.update { it.copy(
status = CheckInStatus.ARRIVED,
facilityId = event.geofenceId,
facilityName = facilityMap[event.geofenceId] ?: "Unknown Gym"
)}
}

private fun handleDwell(event: AFGeofenceEvent.Dwell) {
// The SDK submits the visit automatically on dwell
_state.update { it.copy(status = CheckInStatus.CHECKED_IN) }
}

private fun handleExit(event: AFGeofenceEvent.Exited) {
// Visit was already submitted by the SDK -- reset UI to monitoring
_state.update { it.copy(
status = CheckInStatus.MONITORING,
facilityName = null,
facilityId = null
)}
}
}
Auto-Restore on App Restart

You do not need to manually re-register geofences after an app restart or device reboot. AFCore persists the geofence list and automatically restores monitoring during AFCore.initialize().


Step 3: Build the UI

import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel

@Composable
fun GymCheckInScreen(viewModel: GymCheckInViewModel = viewModel()) {
val state by viewModel.state.collectAsState()

LaunchedEffect(Unit) { viewModel.setup() }

LocationPermissionGate(onAllGranted = { }) {
Column(
modifier = Modifier.fillMaxSize().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CheckInStatusIndicator(state)
Spacer(modifier = Modifier.height(24.dp))

when (state.status) {
CheckInStatus.IDLE -> {
Text("Initializing...")
CircularProgressIndicator()
}
CheckInStatus.MONITORING -> {
Text("Monitoring your gyms", style = MaterialTheme.typography.titleMedium)
Text("We'll automatically detect when you arrive.",
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
CheckInStatus.ARRIVED -> {
Text("Welcome to ${state.facilityName}!",
style = MaterialTheme.typography.titleMedium)
Text("Stay a few more minutes to confirm your visit.")
}
CheckInStatus.CHECKED_IN -> {
Text("Checked in at ${state.facilityName}",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary)
Text("Enjoy your workout!")
}
CheckInStatus.ERROR -> {
Text("Something went wrong",
color = MaterialTheme.colorScheme.error)
Text(state.errorMessage ?: "Unknown error")
}
}
}
}
}

@Composable
fun CheckInStatusIndicator(state: CheckInState) {
val color by animateColorAsState(
targetValue = when (state.status) {
CheckInStatus.IDLE, CheckInStatus.MONITORING -> Color.Gray
CheckInStatus.ARRIVED -> Color(0xFFFFC107)
CheckInStatus.CHECKED_IN -> Color(0xFF4CAF50)
CheckInStatus.ERROR -> Color(0xFFF44336)
},
label = "statusColor"
)

Surface(
modifier = Modifier.size(120.dp),
shape = MaterialTheme.shapes.extraLarge,
color = color.copy(alpha = 0.15f),
tonalElevation = 2.dp
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = when (state.status) {
CheckInStatus.IDLE -> "..."
CheckInStatus.MONITORING -> "SCAN"
CheckInStatus.ARRIVED -> "HERE"
CheckInStatus.CHECKED_IN -> "IN"
CheckInStatus.ERROR -> "ERR"
},
style = MaterialTheme.typography.headlineMedium,
color = color
)
}
}
}

Edge Cases and Best Practices

Multiple Facilities

A member may have several verified facilities. AFGeofencing monitors all of them simultaneously, so overlapping geofences are handled correctly.

App Termination and Restart

AFCore automatically persists and restores geofences across app restarts and device reboots. You do not need to call facilities().startMonitoring() again after a cold start -- the SDK handles this during AFCore.initialize().

Location Accuracy

Geofences have a minimum effective radius of approximately 100 meters on both platforms. Setting dwellTimeMillis to at least 2--5 minutes filters out drive-by false positives.

Battery Impact

Geofence monitoring is hardware-accelerated on both Android (Google Play Services) and iOS (CLLocationManager) and consumes minimal battery. The OS batches location updates and wakes the app only on actual transitions.


Next Steps