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
AFPermissionsto 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 inAndroidManifest.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'sonCreate().
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.
- Jetpack Compose
- Android XML
- SwiftUI
- UIKit
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")
}
}
}
}
}
Register AFPermissions in your Activity, then request permissions from your Fragment:
// MainActivity.kt
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
AFPermissions.registerLifecycle(this)
setContentView(R.layout.activity_main)
}
}
// GymCheckInFragment.kt
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import com.advantahealth.api.AFPermissions
import com.advantahealth.api.Permission
import com.advantahealth.api.PermissionStatus
import kotlinx.coroutines.launch
class GymCheckInFragment : Fragment(R.layout.fragment_gym_checkin) {
private val viewModel: GymCheckInViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
val fineStatus = AFPermissions.requestPermission(Permission.FINE_LOCATION)
if (fineStatus != PermissionStatus.GRANTED) {
showPermissionDenied("Location access is required for automatic gym check-in.")
return@launch
}
val bgStatus = AFPermissions.requestPermission(Permission.BACKGROUND_LOCATION)
if (bgStatus != PermissionStatus.GRANTED) {
showPermissionDenied("Background location is required. Enable 'Always' in Settings.")
return@launch
}
// Permissions granted -- start the check-in flow
viewModel.setup()
observeState()
}
}
private fun showPermissionDenied(message: String) {
view?.findViewById<TextView>(R.id.permissionMessage)?.text = message
view?.findViewById<View>(R.id.permissionGroup)?.visibility = View.VISIBLE
view?.findViewById<Button>(R.id.openSettingsButton)?.setOnClickListener {
AFPermissions.openAppSettings()
}
}
private fun observeState() {
viewLifecycleOwner.lifecycleScope.launch {
viewModel.state.collect { state ->
// Update UI based on state (see Step 3)
}
}
}
}
import AFCore
import SwiftUI
struct LocationPermissionGate<Content: View>: View {
let onGranted: () -> Void
@ViewBuilder let content: () -> Content
@State private var fineGranted = false
@State private var backgroundGranted = false
@State private var checked = false
var body: some View {
Group {
if !checked {
ProgressView()
} else if fineGranted && backgroundGranted {
content()
} else if fineGranted {
VStack(spacing: 16) {
Text("Background Location Required").font(.headline)
Text("Automatic gym check-in needs 'Always' location access.")
.multilineTextAlignment(.center)
Button("Open Settings") {
AFPermissions.shared.openAppSettings()
}
.buttonStyle(.borderedProminent)
}
.padding()
} else {
VStack(spacing: 16) {
Text("Location Access Required").font(.headline)
Text("We need your location to automatically check you in at the gym.")
.multilineTextAlignment(.center)
Button("Open Settings") {
AFPermissions.shared.openAppSettings()
}
.buttonStyle(.borderedProminent)
}
.padding()
}
}
.task {
do {
let fineStatus = try await AFPermissions.shared
.requestPermission(permission: .fineLocation)
fineGranted = fineStatus == .granted
if fineGranted {
let bgStatus = try await AFPermissions.shared
.requestPermission(permission: .backgroundLocation)
backgroundGranted = bgStatus == .granted
}
} catch { }
checked = true
if fineGranted && backgroundGranted { onGranted() }
}
}
}
import AFCore
import UIKit
class LocationPermissionViewController: UIViewController {
private let onGranted: () -> Void
init(onGranted: @escaping () -> Void) {
self.onGranted = onGranted
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
requestPermissions()
}
private func requestPermissions() {
Task {
do {
let fineStatus = try await AFPermissions.shared
.requestPermission(permission: .fineLocation)
guard fineStatus == .granted else {
showDenied("Location access is required for automatic gym check-in.")
return
}
let bgStatus = try await AFPermissions.shared
.requestPermission(permission: .backgroundLocation)
if bgStatus == .granted {
onGranted()
} else {
showDenied("Background location is required. Enable 'Always' in Settings.")
}
} catch {
showDenied("Permission request failed: \(error.localizedDescription)")
}
}
}
private func showDenied(_ message: String) {
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 16
stack.alignment = .center
stack.translatesAutoresizingMaskIntoConstraints = false
let label = UILabel()
label.text = message
label.textAlignment = .center
label.numberOfLines = 0
stack.addArrangedSubview(label)
let button = UIButton(type: .system)
button.setTitle("Open Settings", for: .normal)
button.addAction(UIAction { _ in
AFPermissions.shared.openAppSettings()
}, for: .touchUpInside)
stack.addArrangedSubview(button)
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)
])
}
}
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.
- 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.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
)}
}
}
Same
GymCheckInViewModelas the Jetpack Compose tab. Observe from a Fragment:
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 -> { }
}
},
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) {
_state.update { it.copy(status = CheckInStatus.CHECKED_IN) }
}
private fun handleExit(event: AFGeofenceEvent.Exited) {
_state.update { it.copy(
status = CheckInStatus.MONITORING,
facilityName = null,
facilityId = null
)}
}
}
This ViewModel is shared by both SwiftUI and UIKit implementations.
import AFCore
import Foundation
enum CheckInStatus {
case idle, monitoring, arrived, checkedIn, error
}
struct CheckInState {
var status: CheckInStatus = .idle
var facilityName: String?
var facilityId: String?
var errorMessage: String?
}
@MainActor
class GymCheckInViewModel: ObservableObject {
@Published var state = CheckInState()
private var facilityMap: [String: String] = [:]
private var eventSubscription: (any Kotlinx_coroutines_coreDisposableHandle)?
func setup() async {
do {
let facilities = try await AFCore.shared.facilities().get().compactMap { $0 }
for facility in facilities {
facilityMap["\(facility.id)"] = facility.name ?? "Unknown Gym"
}
try await AFCore.shared.facilities().startMonitoring()
state.status = .monitoring
subscribeToEvents()
} catch {
state.status = .error
state.errorMessage = "Setup failed: \(error.localizedDescription)"
}
}
private func subscribeToEvents() {
eventSubscription = AFCore.shared.facilities().subscribeToEvents(
onEvent: { [weak self] event in
Task { @MainActor in self?.handleEvent(event) }
},
onError: { [weak self] error in
Task { @MainActor in
self?.state.status = .error
self?.state.errorMessage = error.localizedDescription
}
}
)
}
private func handleEvent(_ event: AFGeofenceEvent) {
if let entered = event as? AFGeofenceEvent.Entered {
state.status = .arrived
state.facilityId = entered.geofenceId
state.facilityName = facilityMap[entered.geofenceId] ?? "Unknown Gym"
} else if event is AFGeofenceEvent.Dwell {
// The SDK submits the visit automatically on dwell
state.status = .checkedIn
} else if event is AFGeofenceEvent.Exited {
// Visit was already submitted by the SDK — reset UI to monitoring
state.status = .monitoring
state.facilityName = nil
state.facilityId = nil
} else if let error = event as? AFGeofenceEvent.Error {
state.status = .error
state.errorMessage = error.throwable.message
}
}
deinit { eventSubscription?.close() }
}
Same
GymCheckInViewModelas the SwiftUI tab. It uses@Publishedproperties that you observe with Combine in UIKit.
import AFCore
import Foundation
enum CheckInStatus {
case idle, monitoring, arrived, checkedIn, error
}
struct CheckInState {
var status: CheckInStatus = .idle
var facilityName: String?
var facilityId: String?
var errorMessage: String?
}
@MainActor
class GymCheckInViewModel: ObservableObject {
@Published var state = CheckInState()
private var facilityMap: [String: String] = [:]
private var eventSubscription: (any Kotlinx_coroutines_coreDisposableHandle)?
func setup() async {
do {
let facilities = try await AFCore.shared.facilities().get().compactMap { $0 }
for facility in facilities {
facilityMap["\(facility.id)"] = facility.name ?? "Unknown Gym"
}
try await AFCore.shared.facilities().startMonitoring()
state.status = .monitoring
subscribeToEvents()
} catch {
state.status = .error
state.errorMessage = "Setup failed: \(error.localizedDescription)"
}
}
private func subscribeToEvents() {
eventSubscription = AFCore.shared.facilities().subscribeToEvents(
onEvent: { [weak self] event in
Task { @MainActor in self?.handleEvent(event) }
},
onError: { [weak self] error in
Task { @MainActor in
self?.state.status = .error
self?.state.errorMessage = error.localizedDescription
}
}
)
}
private func handleEvent(_ event: AFGeofenceEvent) {
if let entered = event as? AFGeofenceEvent.Entered {
state.status = .arrived
state.facilityId = entered.geofenceId
state.facilityName = facilityMap[entered.geofenceId] ?? "Unknown Gym"
} else if event is AFGeofenceEvent.Dwell {
state.status = .checkedIn
} else if event is AFGeofenceEvent.Exited {
state.status = .monitoring
state.facilityName = nil
state.facilityId = nil
} else if let error = event as? AFGeofenceEvent.Error {
state.status = .error
state.errorMessage = error.throwable.message
}
}
deinit { eventSubscription?.close() }
}
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
- Jetpack Compose
- Android XML
- SwiftUI
- UIKit
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
)
}
}
}
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- Permission denied group (hidden by default) -->
<LinearLayout
android:id="@+id/permissionGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:orientation="vertical"
android:padding="32dp"
android:visibility="gone">
<TextView
android:id="@+id/permissionMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAlignment="center" />
<Button
android:id="@+id/openSettingsButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Open Settings" />
</LinearLayout>
<!-- Check-in content -->
<LinearLayout
android:id="@+id/checkInContent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:id="@+id/statusLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="24sp"
android:textStyle="bold" />
<TextView
android:id="@+id/statusDescription"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textAlignment="center" />
</LinearLayout>
</FrameLayout>
// GymCheckInFragment.kt -- UI binding (continued from Step 1)
private fun observeState() {
viewLifecycleOwner.lifecycleScope.launch {
viewModel.state.collect { state ->
val statusLabel = view?.findViewById<TextView>(R.id.statusLabel)
val statusDesc = view?.findViewById<TextView>(R.id.statusDescription)
when (state.status) {
CheckInStatus.IDLE -> {
statusLabel?.text = "Initializing..."
statusDesc?.text = ""
}
CheckInStatus.MONITORING -> {
statusLabel?.text = "Monitoring your gyms"
statusDesc?.text = "We'll automatically detect when you arrive."
}
CheckInStatus.ARRIVED -> {
statusLabel?.text = "Welcome to ${state.facilityName}!"
statusDesc?.text = "Stay a few more minutes to confirm your visit."
}
CheckInStatus.CHECKED_IN -> {
statusLabel?.text = "Checked in at ${state.facilityName}"
statusDesc?.text = "Enjoy your workout!"
}
CheckInStatus.ERROR -> {
statusLabel?.text = "Something went wrong"
statusDesc?.text = state.errorMessage ?: "Unknown error"
}
}
}
}
}
import SwiftUI
import AFCore
struct GymCheckInScreen: View {
@StateObject private var viewModel = GymCheckInViewModel()
var body: some View {
LocationPermissionGate(onGranted: { }) {
VStack(spacing: 24) {
CheckInStatusIndicator(status: viewModel.state.status)
statusContent
}
.padding(24)
.task { await viewModel.setup() }
}
}
@ViewBuilder
private var statusContent: some View {
switch viewModel.state.status {
case .idle:
ProgressView("Initializing...")
case .monitoring:
Text("Monitoring your gyms").font(.title3.bold())
Text("We'll automatically detect when you arrive.")
.foregroundStyle(.secondary)
case .arrived:
Text("Welcome to \(viewModel.state.facilityName ?? "the gym")!")
.font(.title3.bold())
Text("Stay a few more minutes to confirm your visit.")
.foregroundStyle(.secondary)
case .checkedIn:
Text("Checked in at \(viewModel.state.facilityName ?? "the gym")")
.font(.title3.bold()).foregroundStyle(.blue)
Text("Enjoy your workout!")
.foregroundStyle(.secondary)
case .error:
Text("Something went wrong").font(.title3.bold()).foregroundStyle(.red)
Text(viewModel.state.errorMessage ?? "Unknown error")
.foregroundStyle(.secondary)
}
}
}
struct CheckInStatusIndicator: View {
let status: CheckInStatus
private var color: Color {
switch status {
case .idle, .monitoring: return .gray
case .arrived: return .yellow
case .checkedIn: return .blue
case .error: return .red
}
}
private var label: String {
switch status {
case .idle: return "..."
case .monitoring: return "SCAN"
case .arrived: return "HERE"
case .checkedIn: return "IN"
case .error: return "ERR"
}
}
var body: some View {
ZStack {
Circle().fill(color.opacity(0.15)).frame(width: 120, height: 120)
Text(label).font(.title.bold()).foregroundStyle(color)
}
}
}
import AFCore
import Combine
import UIKit
class GymCheckInViewController: UIViewController {
private let viewModel = GymCheckInViewModel()
private var cancellables = Set<AnyCancellable>()
private let statusLabel = UILabel()
private let descriptionLabel = UILabel()
private let statusCircle = UIView()
private let statusCircleLabel = UILabel()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
setupLayout()
bindViewModel()
Task { await viewModel.setup() }
}
private func setupLayout() {
statusCircle.layer.cornerRadius = 60
statusCircle.translatesAutoresizingMaskIntoConstraints = false
statusCircleLabel.font = .systemFont(ofSize: 28, weight: .bold)
statusCircleLabel.textAlignment = .center
statusCircleLabel.translatesAutoresizingMaskIntoConstraints = false
statusLabel.font = .preferredFont(forTextStyle: .title2)
statusLabel.textAlignment = .center
statusLabel.numberOfLines = 0
statusLabel.translatesAutoresizingMaskIntoConstraints = false
descriptionLabel.font = .preferredFont(forTextStyle: .body)
descriptionLabel.textColor = .secondaryLabel
descriptionLabel.textAlignment = .center
descriptionLabel.numberOfLines = 0
descriptionLabel.translatesAutoresizingMaskIntoConstraints = false
let stack = UIStackView(arrangedSubviews: [statusCircle, statusLabel, descriptionLabel])
stack.axis = .vertical
stack.spacing = 24
stack.alignment = .center
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
statusCircle.addSubview(statusCircleLabel)
NSLayoutConstraint.activate([
stack.centerXAnchor.constraint(equalTo: view.centerXAnchor),
stack.centerYAnchor.constraint(equalTo: view.centerYAnchor),
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
statusCircle.widthAnchor.constraint(equalToConstant: 120),
statusCircle.heightAnchor.constraint(equalToConstant: 120),
statusCircleLabel.centerXAnchor.constraint(equalTo: statusCircle.centerXAnchor),
statusCircleLabel.centerYAnchor.constraint(equalTo: statusCircle.centerYAnchor)
])
}
private func bindViewModel() {
viewModel.$state
.receive(on: DispatchQueue.main)
.sink { [weak self] state in self?.updateUI(state) }
.store(in: &cancellables)
}
private func updateUI(_ state: CheckInState) {
let (label, desc, color, badge): (String, String, UIColor, String) = {
switch state.status {
case .idle:
return ("Initializing...", "", .systemGray, "...")
case .monitoring:
return ("Monitoring your gyms",
"We'll automatically detect when you arrive.",
.systemGray, "SCAN")
case .arrived:
return ("Welcome to \(state.facilityName ?? "the gym")!",
"Stay a few more minutes to confirm your visit.",
.systemYellow, "HERE")
case .checkedIn:
return ("Checked in at \(state.facilityName ?? "the gym")",
"Enjoy your workout!",
.systemBlue, "IN")
case .error:
return ("Something went wrong",
state.errorMessage ?? "Unknown error",
.systemRed, "ERR")
}
}()
statusLabel.text = label
descriptionLabel.text = desc
statusCircle.backgroundColor = color.withAlphaComponent(0.15)
statusCircleLabel.text = badge
statusCircleLabel.textColor = 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
- Geofencing Guide -- Deep dive into AFGeofencing configuration and diagnostics.
- Activities Guide -- Activity types, calendar views, and manual self-report.
- Step Tracking Dashboard -- Complement gym visits with step tracking.