Facility Finder Map
This recipe builds a facility finder map that shows nearby gyms and fitness facilities on an interactive map. Users can browse facilities, view details, get directions, and start geofence monitoring for automatic check-in.
AFCore 1.7.1
What You Will Build
- A map view displaying nearby facilities as markers.
- Location permission handling via
AFPermissions. - Tap-to-view facility detail sheet with name, address, distance, and verified badge.
- "Get Directions" action to open the native maps app.
- "Start Monitoring" toggle to enable geofence-based automatic check-in.
- Viewport-based loading as the user pans the map.
Prerequisites
- AFCore is installed and initialized.
- The member is authenticated.
- Android: Google Maps SDK added to your project, API key configured in
AndroidManifest.xml. CallAFPermissions.registerLifecycle(activity)in your foreground Activity'sonCreate(). - iOS: No additional dependencies needed -- MapKit is built into iOS.
Step 1: Request Location Permission
The facility finder needs the user's current location to center the map and load nearby facilities. Use AFPermissions for a consistent cross-platform permission flow.
- Jetpack Compose
- Android XML
- SwiftUI
- UIKit
Register AFPermissions in your Activity, then request in Compose:
// MainActivity.kt
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
AFPermissions.registerLifecycle(this)
setContent { FacilityFinderScreen() }
}
}
import androidx.compose.runtime.*
import com.advantahealth.api.AFPermissions
import com.advantahealth.api.Permission
import com.advantahealth.api.PermissionStatus
@Composable
fun rememberLocationPermissionState(): State<Boolean> {
val isGranted = remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
val status = AFPermissions.getPermissionStatus(Permission.FINE_LOCATION)
if (status == PermissionStatus.GRANTED) {
isGranted.value = true
} else {
val result = AFPermissions.requestPermission(Permission.FINE_LOCATION)
isGranted.value = result == PermissionStatus.GRANTED
}
}
return isGranted
}
Register AFPermissions in your Activity, then request from your Fragment:
// MainActivity.kt
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
AFPermissions.registerLifecycle(this)
setContentView(R.layout.activity_main)
}
}
// FacilityFinderFragment.kt
import com.advantahealth.api.AFPermissions
import com.advantahealth.api.Permission
import com.advantahealth.api.PermissionStatus
class FacilityFinderFragment : Fragment(R.layout.fragment_facility_finder) {
private val viewModel: FacilityFinderViewModel by viewModels()
private var locationGranted = false
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
val status = AFPermissions.requestPermission(Permission.FINE_LOCATION)
locationGranted = status == PermissionStatus.GRANTED
if (locationGranted) {
setupMap()
} else {
showPermissionDenied()
}
}
}
private fun showPermissionDenied() {
view?.findViewById<View>(R.id.permissionGroup)?.visibility = View.VISIBLE
view?.findViewById<Button>(R.id.openSettingsButton)?.setOnClickListener {
AFPermissions.openAppSettings()
}
}
private fun setupMap() {
// Initialize map and load facilities (see Steps 2-3)
}
}
import AFCore
import SwiftUI
import CoreLocation
@MainActor
class LocationManager: ObservableObject {
@Published var userLocation: CLLocationCoordinate2D?
@Published var permissionGranted = false
private let clManager = CLLocationManager()
private var delegate: LocationDelegate?
func requestPermission() async {
do {
let status = try await AFPermissions.shared
.requestPermission(permission: .fineLocation)
permissionGranted = status == .granted
if permissionGranted {
delegate = LocationDelegate { [weak self] location in
self?.userLocation = location
}
clManager.delegate = delegate
clManager.desiredAccuracy = kCLLocationAccuracyBest
clManager.startUpdatingLocation()
}
} catch { }
}
}
// CLLocationManagerDelegate for location updates
private class LocationDelegate: NSObject, CLLocationManagerDelegate {
let onUpdate: (CLLocationCoordinate2D) -> Void
init(onUpdate: @escaping (CLLocationCoordinate2D) -> Void) { self.onUpdate = onUpdate }
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
if let coord = locations.last?.coordinate {
onUpdate(coord)
manager.stopUpdatingLocation()
}
}
}
import AFCore
import CoreLocation
import UIKit
class FacilityFinderLocationController: NSObject, CLLocationManagerDelegate {
var userLocation: CLLocationCoordinate2D?
var onPermissionResult: ((Bool) -> Void)?
var onLocationUpdate: ((CLLocationCoordinate2D) -> Void)?
private let clManager = CLLocationManager()
override init() {
super.init()
clManager.delegate = self
clManager.desiredAccuracy = kCLLocationAccuracyBest
}
func requestPermission() {
Task {
do {
let status = try await AFPermissions.shared
.requestPermission(permission: .fineLocation)
let granted = status == .granted
await MainActor.run { onPermissionResult?(granted) }
if granted {
clManager.startUpdatingLocation()
}
} catch {
await MainActor.run { onPermissionResult?(false) }
}
}
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let coord = locations.last?.coordinate else { return }
userLocation = coord
onLocationUpdate?(coord)
manager.stopUpdatingLocation()
}
}
Step 2: Facility Finder ViewModel
The ViewModel fetches nearby facilities, manages the selected facility state, and toggles geofence monitoring.
- 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.map.model.MapMarker
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class FacilityFinderState(
val markers: List<MapMarker> = emptyList(),
val selectedMarker: MapMarker? = null,
val isLoading: Boolean = false,
val isMonitoring: Boolean = false,
val errorMessage: String? = null
)
class FacilityFinderViewModel : ViewModel() {
private val _state = MutableStateFlow(FacilityFinderState())
val state = _state.asStateFlow()
private var viewportJob: Job? = null
fun loadNearbyFacilities(latitude: Double, longitude: Double) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
try {
val markers = AFCore.map()
.getNearbyFacilities(latitude, longitude)
.filterNotNull()
_state.update { it.copy(markers = markers, isLoading = false) }
} catch (e: Throwable) {
_state.update { it.copy(
isLoading = false,
errorMessage = "Failed to load facilities: ${e.message}"
)}
}
}
}
fun loadFacilitiesInViewport(
upperLeftLat: Double, upperLeftLng: Double,
lowerRightLat: Double, lowerRightLng: Double
) {
viewportJob?.cancel()
viewportJob = viewModelScope.launch {
delay(500) // Debounce
try {
val markers = AFCore.map()
.getFacilitiesByViewPort(
upperLeftLat = upperLeftLat,
upperLeftLng = upperLeftLng,
lowerRightLat = lowerRightLat,
lowerRightLng = lowerRightLng
)
.filterNotNull()
_state.update { it.copy(markers = markers) }
} catch (e: Throwable) {
_state.update { it.copy(
errorMessage = "Failed to load facilities: ${e.message}"
)}
}
}
}
fun selectMarker(marker: MapMarker) {
_state.update { it.copy(selectedMarker = marker) }
}
fun dismissSelection() {
_state.update { it.copy(selectedMarker = null) }
}
fun toggleMonitoring() {
viewModelScope.launch {
try {
if (_state.value.isMonitoring) {
AFCore.facilities().stopMonitoring()
_state.update { it.copy(isMonitoring = false) }
} else {
val success = AFCore.facilities().startMonitoring()
_state.update { it.copy(isMonitoring = success) }
}
} catch (e: Throwable) {
_state.update { it.copy(
errorMessage = "Monitoring toggle failed: ${e.message}"
)}
}
}
}
fun checkMonitoringStatus() {
viewModelScope.launch {
try {
val active = AFCore.facilities().isMonitoringActive()
_state.update { it.copy(isMonitoring = active) }
} catch (_: Throwable) { }
}
}
}
Same
FacilityFinderViewModelas the Jetpack Compose tab.
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.advantahealth.api.AFCore
import com.advantahealth.api.map.model.MapMarker
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class FacilityFinderState(
val markers: List<MapMarker> = emptyList(),
val selectedMarker: MapMarker? = null,
val isLoading: Boolean = false,
val isMonitoring: Boolean = false,
val errorMessage: String? = null
)
class FacilityFinderViewModel : ViewModel() {
private val _state = MutableStateFlow(FacilityFinderState())
val state = _state.asStateFlow()
private var viewportJob: Job? = null
fun loadNearbyFacilities(latitude: Double, longitude: Double) {
viewModelScope.launch {
_state.update { it.copy(isLoading = true) }
try {
val markers = AFCore.map()
.getNearbyFacilities(latitude, longitude)
.filterNotNull()
_state.update { it.copy(markers = markers, isLoading = false) }
} catch (e: Throwable) {
_state.update { it.copy(
isLoading = false,
errorMessage = "Failed to load facilities: ${e.message}"
)}
}
}
}
fun loadFacilitiesInViewport(
upperLeftLat: Double, upperLeftLng: Double,
lowerRightLat: Double, lowerRightLng: Double
) {
viewportJob?.cancel()
viewportJob = viewModelScope.launch {
delay(500)
try {
val markers = AFCore.map()
.getFacilitiesByViewPort(
upperLeftLat = upperLeftLat,
upperLeftLng = upperLeftLng,
lowerRightLat = lowerRightLat,
lowerRightLng = lowerRightLng
)
.filterNotNull()
_state.update { it.copy(markers = markers) }
} catch (e: Throwable) {
_state.update { it.copy(
errorMessage = "Failed to load facilities: ${e.message}"
)}
}
}
}
fun selectMarker(marker: MapMarker) {
_state.update { it.copy(selectedMarker = marker) }
}
fun dismissSelection() {
_state.update { it.copy(selectedMarker = null) }
}
fun toggleMonitoring() {
viewModelScope.launch {
try {
if (_state.value.isMonitoring) {
AFCore.facilities().stopMonitoring()
_state.update { it.copy(isMonitoring = false) }
} else {
val success = AFCore.facilities().startMonitoring()
_state.update { it.copy(isMonitoring = success) }
}
} catch (e: Throwable) {
_state.update { it.copy(
errorMessage = "Monitoring toggle failed: ${e.message}"
)}
}
}
}
fun checkMonitoringStatus() {
viewModelScope.launch {
try {
val active = AFCore.facilities().isMonitoringActive()
_state.update { it.copy(isMonitoring = active) }
} catch (_: Throwable) { }
}
}
}
This ViewModel is shared by both SwiftUI and UIKit implementations.
import AFCore
import Foundation
import MapKit
struct FacilityFinderState {
var markers: [MapMarker] = []
var selectedMarker: MapMarker?
var isLoading: Bool = false
var isMonitoring: Bool = false
var errorMessage: String?
}
@MainActor
class FacilityFinderViewModel: ObservableObject {
@Published var state = FacilityFinderState()
private var viewportTask: Task<Void, Never>?
func loadNearbyFacilities(latitude: Double, longitude: Double) async {
state.isLoading = true
do {
let markers = try await AFCore.shared.map()
.getNearbyFacilities(latitude: latitude, longitude: longitude)
.compactMap { $0 }
state.markers = markers
state.isLoading = false
} catch {
state.isLoading = false
state.errorMessage = "Failed to load facilities: \(error.localizedDescription)"
}
}
func loadFacilitiesInViewport(region: MKCoordinateRegion) {
viewportTask?.cancel()
viewportTask = Task {
try? await Task.sleep(nanoseconds: 500_000_000) // 500ms debounce
guard !Task.isCancelled else { return }
let upperLeftLat = region.center.latitude + region.span.latitudeDelta / 2
let upperLeftLng = region.center.longitude - region.span.longitudeDelta / 2
let lowerRightLat = region.center.latitude - region.span.latitudeDelta / 2
let lowerRightLng = region.center.longitude + region.span.longitudeDelta / 2
do {
let markers = try await AFCore.shared.map()
.getFacilitiesByViewPort(
upperLeftLat: upperLeftLat, upperLeftLng: upperLeftLng,
lowerRightLat: lowerRightLat, lowerRightLng: lowerRightLng
)
.compactMap { $0 }
state.markers = markers
} catch {
state.errorMessage = "Failed to load facilities: \(error.localizedDescription)"
}
}
}
func selectMarker(_ marker: MapMarker) { state.selectedMarker = marker }
func dismissSelection() { state.selectedMarker = nil }
func toggleMonitoring() async {
do {
if state.isMonitoring {
let _ = try await AFCore.shared.facilities().stopMonitoring()
state.isMonitoring = false
} else {
let success = try await AFCore.shared.facilities().startMonitoring()
state.isMonitoring = success.boolValue
}
} catch {
state.errorMessage = "Monitoring toggle failed: \(error.localizedDescription)"
}
}
func checkMonitoringStatus() async {
do {
let active = try await AFCore.shared.facilities().isMonitoringActive()
state.isMonitoring = active
} catch { }
}
}
Same
FacilityFinderViewModelas the SwiftUI tab.
import AFCore
import Foundation
import MapKit
struct FacilityFinderState {
var markers: [MapMarker] = []
var selectedMarker: MapMarker?
var isLoading: Bool = false
var isMonitoring: Bool = false
var errorMessage: String?
}
@MainActor
class FacilityFinderViewModel: ObservableObject {
@Published var state = FacilityFinderState()
private var viewportTask: Task<Void, Never>?
func loadNearbyFacilities(latitude: Double, longitude: Double) async {
state.isLoading = true
do {
let markers = try await AFCore.shared.map()
.getNearbyFacilities(latitude: latitude, longitude: longitude)
.compactMap { $0 }
state.markers = markers
state.isLoading = false
} catch {
state.isLoading = false
state.errorMessage = "Failed to load facilities: \(error.localizedDescription)"
}
}
func loadFacilitiesInViewport(region: MKCoordinateRegion) {
viewportTask?.cancel()
viewportTask = Task {
try? await Task.sleep(nanoseconds: 500_000_000)
guard !Task.isCancelled else { return }
let upperLeftLat = region.center.latitude + region.span.latitudeDelta / 2
let upperLeftLng = region.center.longitude - region.span.longitudeDelta / 2
let lowerRightLat = region.center.latitude - region.span.latitudeDelta / 2
let lowerRightLng = region.center.longitude + region.span.longitudeDelta / 2
do {
let markers = try await AFCore.shared.map()
.getFacilitiesByViewPort(
upperLeftLat: upperLeftLat, upperLeftLng: upperLeftLng,
lowerRightLat: lowerRightLat, lowerRightLng: lowerRightLng
)
.compactMap { $0 }
state.markers = markers
} catch {
state.errorMessage = "Failed to load facilities: \(error.localizedDescription)"
}
}
}
func selectMarker(_ marker: MapMarker) { state.selectedMarker = marker }
func dismissSelection() { state.selectedMarker = nil }
func toggleMonitoring() async {
do {
if state.isMonitoring {
let _ = try await AFCore.shared.facilities().stopMonitoring()
state.isMonitoring = false
} else {
let success = try await AFCore.shared.facilities().startMonitoring()
state.isMonitoring = success.boolValue
}
} catch {
state.errorMessage = "Monitoring toggle failed: \(error.localizedDescription)"
}
}
func checkMonitoringStatus() async {
do {
let active = try await AFCore.shared.facilities().isMonitoringActive()
state.isMonitoring = active
} catch { }
}
}
Step 3: Build the Map UI
- Jetpack Compose
- Android XML
- SwiftUI
- UIKit
This example uses Google Maps Compose. Add the dependency:
dependencies {
implementation("com.google.maps.android:maps-compose:4.3.0")
implementation("com.google.android.gms:play-services-maps:19.0.0")
implementation("com.google.android.gms:play-services-location:21.3.0")
}
import android.content.Context
import android.content.Intent
import android.net.Uri
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.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.google.android.gms.location.LocationServices
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.*
import kotlinx.coroutines.tasks.await
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FacilityFinderScreen(viewModel: FacilityFinderViewModel = viewModel()) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
val locationGranted by rememberLocationPermissionState()
var userLatLng by remember { mutableStateOf(LatLng(37.7749, -122.4194)) }
val cameraPositionState = rememberCameraPositionState {
position = CameraPosition.fromLatLngZoom(userLatLng, 13f)
}
LaunchedEffect(locationGranted) {
if (locationGranted) {
try {
val fusedClient = LocationServices.getFusedLocationProviderClient(context)
val location = fusedClient.lastLocation.await()
if (location != null) {
userLatLng = LatLng(location.latitude, location.longitude)
cameraPositionState.position =
CameraPosition.fromLatLngZoom(userLatLng, 13f)
viewModel.loadNearbyFacilities(location.latitude, location.longitude)
}
} catch (_: SecurityException) { }
}
}
LaunchedEffect(Unit) { viewModel.checkMonitoringStatus() }
LaunchedEffect(cameraPositionState.isMoving) {
if (!cameraPositionState.isMoving) {
val bounds = cameraPositionState.projection?.visibleRegion?.latLngBounds
if (bounds != null) {
viewModel.loadFacilitiesInViewport(
upperLeftLat = bounds.northeast.latitude,
upperLeftLng = bounds.southwest.longitude,
lowerRightLat = bounds.southwest.latitude,
lowerRightLng = bounds.northeast.longitude
)
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Find a Gym") },
actions = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
if (state.isMonitoring) "Monitoring On" else "Monitoring Off",
style = MaterialTheme.typography.labelSmall
)
Switch(
checked = state.isMonitoring,
onCheckedChange = { viewModel.toggleMonitoring() }
)
}
}
)
}
) { padding ->
Box(modifier = Modifier.padding(padding)) {
GoogleMap(
modifier = Modifier.fillMaxSize(),
cameraPositionState = cameraPositionState,
properties = MapProperties(isMyLocationEnabled = locationGranted)
) {
state.markers.forEach { marker ->
val position = LatLng(
marker.latitude ?: 0.0,
marker.longitude ?: 0.0
)
Marker(
state = MarkerState(position = position),
title = marker.name ?: "Facility",
snippet = marker.address,
onClick = {
viewModel.selectMarker(marker)
true
}
)
}
}
if (state.isLoading) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.TopCenter).padding(16.dp)
)
}
}
state.selectedMarker?.let { marker ->
FacilityDetailSheet(
marker = marker,
userLat = userLatLng.latitude,
userLng = userLatLng.longitude,
onDismiss = { viewModel.dismissSelection() },
onDirections = {
openDirections(context, marker.latitude ?: 0.0, marker.longitude ?: 0.0)
}
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FacilityDetailSheet(
marker: MapMarker, userLat: Double, userLng: Double,
onDismiss: () -> Unit, onDirections: () -> Unit
) {
val sheetState = rememberModalBottomSheetState()
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(modifier = Modifier.fillMaxWidth().padding(24.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(marker.name ?: "Unknown Facility",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.weight(1f))
if (marker.verified) {
Surface(shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.primaryContainer) {
Text("Verified",
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
marker.address?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }
val cityState = listOfNotNull(marker.city, marker.state).joinToString(", ")
if (cityState.isNotBlank()) {
Text(cityState, style = MaterialTheme.typography.bodyMedium)
}
marker.phone?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(it, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary)
}
Spacer(modifier = Modifier.height(8.dp))
val distance = calculateDistance(
userLat, userLng, marker.latitude ?: 0.0, marker.longitude ?: 0.0)
Text("%.1f miles away".format(distance),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(modifier = Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onDirections, modifier = Modifier.weight(1f)) {
Text("Get Directions")
}
OutlinedButton(onClick = onDismiss, modifier = Modifier.weight(1f)) {
Text("Close")
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
fun openDirections(context: Context, lat: Double, lng: Double) {
val uri = Uri.parse("google.navigation:q=$lat,$lng&mode=d")
val intent = Intent(Intent.ACTION_VIEW, uri).apply {
setPackage("com.google.android.apps.maps")
}
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
} else {
val webUri = Uri.parse("https://www.google.com/maps/dir/?api=1&destination=$lat,$lng")
context.startActivity(Intent(Intent.ACTION_VIEW, webUri))
}
}
fun calculateDistance(lat1: Double, lng1: Double, lat2: Double, lng2: Double): Double {
val r = 3958.8
val dLat = Math.toRadians(lat2 - lat1)
val dLng = Math.toRadians(lng2 - lng1)
val a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) *
Math.sin(dLng / 2) * Math.sin(dLng / 2)
val c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return r * c
}
Add the Google Maps dependency and use a SupportMapFragment:
dependencies {
implementation("com.google.android.gms:play-services-maps:19.0.0")
implementation("com.google.android.gms:play-services-location:21.3.0")
}
<?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">
<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:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Location access is required to find nearby facilities." />
<Button
android:id="@+id/openSettingsButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="Open Settings" />
</LinearLayout>
<fragment
android:id="@+id/mapFragment"
android:name="com.google.android.gms.maps.SupportMapFragment"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<ProgressBar
android:id="@+id/loadingIndicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|center_horizontal"
android:layout_marginTop="16dp"
android:visibility="gone" />
</FrameLayout>
// FacilityFinderFragment.kt -- Map setup (continued from Step 1)
private fun setupMap() {
val mapFragment = childFragmentManager.findFragmentById(R.id.mapFragment)
as? SupportMapFragment ?: return
mapFragment.getMapAsync { googleMap ->
val fusedClient = LocationServices.getFusedLocationProviderClient(requireContext())
viewLifecycleOwner.lifecycleScope.launch {
try {
val location = fusedClient.lastLocation.await()
if (location != null) {
val latLng = LatLng(location.latitude, location.longitude)
googleMap.moveCamera(CameraUpdateFactory.newLatLngZoom(latLng, 13f))
googleMap.isMyLocationEnabled = true
viewModel.loadNearbyFacilities(location.latitude, location.longitude)
}
} catch (_: SecurityException) { }
}
// Camera idle listener for viewport loading
googleMap.setOnCameraIdleListener {
val bounds = googleMap.projection.visibleRegion.latLngBounds
viewModel.loadFacilitiesInViewport(
upperLeftLat = bounds.northeast.latitude,
upperLeftLng = bounds.southwest.longitude,
lowerRightLat = bounds.southwest.latitude,
lowerRightLng = bounds.northeast.longitude
)
}
// Marker click listener
googleMap.setOnMarkerClickListener { gMarker ->
val marker = viewModel.state.value.markers.find { it.name == gMarker.title }
if (marker != null) viewModel.selectMarker(marker)
true
}
// Observe state to update markers
viewLifecycleOwner.lifecycleScope.launch {
viewModel.state.collect { state ->
googleMap.clear()
for (marker in state.markers) {
googleMap.addMarker(
MarkerOptions()
.position(LatLng(marker.latitude ?: 0.0, marker.longitude ?: 0.0))
.title(marker.name ?: "Facility")
.snippet(marker.address)
)
}
view?.findViewById<ProgressBar>(R.id.loadingIndicator)?.visibility =
if (state.isLoading) View.VISIBLE else View.GONE
}
}
viewModel.checkMonitoringStatus()
}
}
import SwiftUI
import MapKit
import AFCore
struct FacilityAnnotation: Identifiable {
let id: Int32
let marker: MapMarker
var coordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(
latitude: marker.latitude?.doubleValue ?? 0,
longitude: marker.longitude?.doubleValue ?? 0
)
}
}
struct FacilityFinderScreen: View {
@StateObject private var viewModel = FacilityFinderViewModel()
@StateObject private var locationManager = LocationManager()
@State private var region = MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194),
span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
)
@State private var showDetail = false
private var annotations: [FacilityAnnotation] {
viewModel.state.markers.map {
FacilityAnnotation(id: $0.facilityId, marker: $0)
}
}
var body: some View {
NavigationStack {
ZStack {
Map(
coordinateRegion: $region,
showsUserLocation: true,
annotationItems: annotations
) { annotation in
MapAnnotation(coordinate: annotation.coordinate) {
FacilityMapPin(
isVerified: annotation.marker.verified,
onTap: {
viewModel.selectMarker(annotation.marker)
showDetail = true
}
)
}
}
.ignoresSafeArea(edges: .bottom)
.onChange(of: region.center.latitude) { _ in
viewModel.loadFacilitiesInViewport(region: region)
}
if viewModel.state.isLoading {
VStack {
ProgressView()
.padding()
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 8))
Spacer()
}
.padding(.top)
}
}
.navigationTitle("Find a Gym")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Toggle(isOn: Binding(
get: { viewModel.state.isMonitoring },
set: { _ in Task { await viewModel.toggleMonitoring() } }
)) {
Text(viewModel.state.isMonitoring ? "Monitoring" : "Monitor")
.font(.caption)
}
.toggleStyle(.switch)
}
}
.sheet(isPresented: $showDetail) {
if let marker = viewModel.state.selectedMarker {
FacilityDetailSheet(
marker: marker,
userLocation: locationManager.userLocation,
onDismiss: {
showDetail = false
viewModel.dismissSelection()
}
)
.presentationDetents([.medium])
}
}
.task { await locationManager.requestPermission() }
.task(id: locationManager.userLocation?.latitude) {
if let loc = locationManager.userLocation {
region = MKCoordinateRegion(
center: loc,
span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
)
await viewModel.loadNearbyFacilities(
latitude: loc.latitude, longitude: loc.longitude)
}
}
.task { await viewModel.checkMonitoringStatus() }
}
}
}
struct FacilityMapPin: View {
let isVerified: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
VStack(spacing: 0) {
Image(systemName: isVerified ? "checkmark.circle.fill" : "mappin.circle.fill")
.font(.title)
.foregroundStyle(isVerified ? .green : .blue)
Image(systemName: "arrowtriangle.down.fill")
.font(.caption2)
.foregroundStyle(isVerified ? .green : .blue)
.offset(y: -3)
}
}
}
}
struct FacilityDetailSheet: View {
let marker: MapMarker
let userLocation: CLLocationCoordinate2D?
let onDismiss: () -> Void
private var distanceMiles: Double? {
guard let userLoc = userLocation,
let lat = marker.latitude?.doubleValue,
let lng = marker.longitude?.doubleValue else { return nil }
let facilityLocation = CLLocation(latitude: lat, longitude: lng)
let userCLLocation = CLLocation(latitude: userLoc.latitude, longitude: userLoc.longitude)
return userCLLocation.distance(from: facilityLocation) / 1609.344
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text(marker.name ?? "Unknown Facility").font(.title2.bold())
Spacer()
if marker.verified {
Text("Verified").font(.caption.bold())
.padding(.horizontal, 8).padding(.vertical, 4)
.background(Color.green.opacity(0.15))
.foregroundStyle(.green).clipShape(Capsule())
}
}
if let address = marker.address { Text(address).foregroundStyle(.secondary) }
let cityState = [marker.city, marker.state].compactMap { $0 }.joined(separator: ", ")
if !cityState.isEmpty { Text(cityState).foregroundStyle(.secondary) }
if let phone = marker.phone { Text(phone).foregroundStyle(.blue) }
if let distance = distanceMiles {
Text(String(format: "%.1f miles away", distance))
.font(.subheadline).foregroundStyle(.secondary)
}
Divider()
HStack(spacing: 12) {
Button { openDirections() } label: {
Label("Get Directions", systemImage: "arrow.triangle.turn.up.right.circle")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
Button(action: onDismiss) {
Text("Close").frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
}
}
.padding(24)
}
private func openDirections() {
guard let lat = marker.latitude?.doubleValue,
let lng = marker.longitude?.doubleValue else { return }
let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lng)
let placemark = MKPlacemark(coordinate: coordinate)
let mapItem = MKMapItem(placemark: placemark)
mapItem.name = marker.name
mapItem.openInMaps(launchOptions: [
MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDriving
])
}
}
import AFCore
import Combine
import MapKit
import UIKit
class FacilityFinderViewController: UIViewController, MKMapViewDelegate {
private let viewModel = FacilityFinderViewModel()
private let locationController = FacilityFinderLocationController()
private var cancellables = Set<AnyCancellable>()
private let mapView = MKMapView()
private let loadingIndicator = UIActivityIndicatorView(style: .medium)
private let monitoringSwitch = UISwitch()
override func viewDidLoad() {
super.viewDidLoad()
title = "Find a Gym"
setupLayout()
bindViewModel()
locationController.onPermissionResult = { [weak self] granted in
if granted {
self?.mapView.showsUserLocation = true
}
}
locationController.onLocationUpdate = { [weak self] coord in
let region = MKCoordinateRegion(
center: coord,
span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05)
)
self?.mapView.setRegion(region, animated: true)
Task { await self?.viewModel.loadNearbyFacilities(
latitude: coord.latitude, longitude: coord.longitude) }
}
locationController.requestPermission()
Task { await viewModel.checkMonitoringStatus() }
}
private func setupLayout() {
mapView.delegate = self
mapView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(mapView)
loadingIndicator.hidesWhenStopped = true
loadingIndicator.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(loadingIndicator)
let monitorLabel = UILabel()
monitorLabel.text = "Monitor"
monitorLabel.font = .preferredFont(forTextStyle: .caption1)
monitoringSwitch.addAction(UIAction { [weak self] _ in
Task { await self?.viewModel.toggleMonitoring() }
}, for: .valueChanged)
navigationItem.rightBarButtonItem = UIBarButtonItem(
customView: {
let stack = UIStackView(arrangedSubviews: [monitorLabel, monitoringSwitch])
stack.spacing = 8
stack.alignment = .center
return stack
}()
)
NSLayoutConstraint.activate([
mapView.topAnchor.constraint(equalTo: view.topAnchor),
mapView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
mapView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
mapView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
loadingIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
loadingIndicator.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor,
constant: 16)
])
}
private func bindViewModel() {
viewModel.$state
.receive(on: DispatchQueue.main)
.sink { [weak self] state in self?.updateUI(state) }
.store(in: &cancellables)
}
private func updateUI(_ state: FacilityFinderState) {
if state.isLoading { loadingIndicator.startAnimating() }
else { loadingIndicator.stopAnimating() }
monitoringSwitch.isOn = state.isMonitoring
// Update annotations
mapView.removeAnnotations(mapView.annotations)
for marker in state.markers {
let annotation = MKPointAnnotation()
annotation.coordinate = CLLocationCoordinate2D(
latitude: marker.latitude?.doubleValue ?? 0,
longitude: marker.longitude?.doubleValue ?? 0
)
annotation.title = marker.name ?? "Facility"
annotation.subtitle = marker.address
mapView.addAnnotation(annotation)
}
}
// MARK: - MKMapViewDelegate
func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) {
let region = mapView.region
viewModel.loadFacilitiesInViewport(region: region)
}
func mapView(_ mapView: MKMapView, didSelect annotation: MKAnnotationView) {
guard let title = annotation.annotation?.title,
let marker = viewModel.state.markers.first(where: { $0.name == title }) else { return }
let detailVC = FacilityDetailViewController(
marker: marker,
userLocation: locationController.userLocation
)
if let sheet = detailVC.sheetPresentationController {
sheet.detents = [.medium()]
}
present(detailVC, animated: true)
}
}
class FacilityDetailViewController: UIViewController {
private let marker: MapMarker
private let userLocation: CLLocationCoordinate2D?
init(marker: MapMarker, userLocation: CLLocationCoordinate2D?) {
self.marker = marker
self.userLocation = userLocation
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) not implemented") }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
let nameLabel = UILabel()
nameLabel.text = marker.name ?? "Unknown Facility"
nameLabel.font = .systemFont(ofSize: 22, weight: .bold)
let addressLabel = UILabel()
addressLabel.text = [marker.address, [marker.city, marker.state]
.compactMap { $0 }.joined(separator: ", ")]
.compactMap { $0 }.joined(separator: "\n")
addressLabel.numberOfLines = 0
addressLabel.textColor = .secondaryLabel
let directionsButton = UIButton(type: .system)
directionsButton.setTitle("Get Directions", for: .normal)
directionsButton.addAction(UIAction { [weak self] _ in
self?.openDirections()
}, for: .touchUpInside)
let closeButton = UIButton(type: .system)
closeButton.setTitle("Close", for: .normal)
closeButton.addAction(UIAction { [weak self] _ in
self?.dismiss(animated: true)
}, for: .touchUpInside)
let buttonStack = UIStackView(arrangedSubviews: [directionsButton, closeButton])
buttonStack.spacing = 12
buttonStack.distribution = .fillEqually
let stack = UIStackView(arrangedSubviews: [nameLabel, addressLabel, buttonStack])
stack.axis = .vertical
stack.spacing = 16
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 24),
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24)
])
}
private func openDirections() {
guard let lat = marker.latitude?.doubleValue,
let lng = marker.longitude?.doubleValue else { return }
let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lng)
let placemark = MKPlacemark(coordinate: coordinate)
let mapItem = MKMapItem(placemark: placemark)
mapItem.name = marker.name
mapItem.openInMaps(launchOptions: [
MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDriving
])
}
}
Viewport-Based Loading
As the user pans and zooms the map, the ViewModel calls getFacilitiesByViewPort() to load facilities within the visible area. To avoid excessive API calls during rapid camera movements, the calls are debounced with a 500ms delay. The viewport search supplements the initial nearby search, allowing users to discover facilities in other areas.
Monitoring Toggle
The "Start Monitoring" toggle calls AFCore.facilities().startMonitoring(), which fetches the member's facilities, converts them to geofences, and begins monitoring with AFGeofencing.start(). Once monitoring is active, the member gets automatic check-ins as described in the Automatic Gym Check-In recipe.
To stop monitoring, the toggle calls AFCore.facilities().stopMonitoring(), which removes all active geofences.
startMonitoring() requires background location permission for the geofences to trigger while the app is backgrounded. If you only have "When In Use" permission, geofences will only trigger while the app is in the foreground. See the Automatic Gym Check-In recipe for the full permission flow.
Best Practices
Marker Clustering
When the API returns many facilities, consider using marker clustering:
- Android: Use
com.google.maps.android:android-maps-utilswithClusterManager. - iOS: Use
MKMapViewwithMKClusterAnnotationsupport.
Caching
AFCore.map().getNearbyFacilities() uses the SDK's standard 15-minute HTTP cache. If your app needs faster repeated lookups, maintain a local cache in the ViewModel and only re-fetch when the user's location changes significantly (e.g., > 500 meters).
Offline State
If the device is offline, the API calls will throw. Consider caching the last-known facility list in local storage so the map can display previously loaded facilities while offline.
Next Steps
- Automatic Gym Check-In -- Use geofencing to automatically check in when the member visits a facility.
- Facilities Guide -- Deep dive into facility management, verification, and nomination.
- Geofencing Guide -- Understand how geofence monitoring works under the hood.