Skip to main content

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. Call AFPermissions.registerLifecycle(activity) in your foreground Activity's onCreate().
  • 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.

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
}

Step 2: Facility Finder ViewModel

The ViewModel fetches nearby facilities, manages the selected facility state, and toggles geofence monitoring.

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) { }
}
}
}

Step 3: Build the Map UI

This example uses Google Maps Compose. Add the dependency:

app/build.gradle.kts
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
}

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.

info

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-utils with ClusterManager.
  • iOS: Use MKMapView with MKClusterAnnotation support.

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