Build a real-time location sharing screen: track your own location, share it over WebSocket, and display another user's position on a map that updates live.
Goal
- Track current user's location (foreground service)
- Emit location updates over WebSocket
- Receive partner's location updates and move their map marker smoothly
- Show ETA from current user to partner
Step 1: Location Tracking
class LiveLocationRepository(
private val context: Context,
private val socket: LocationWebSocket
) {
private val fusedLocationClient = LocationServices.getFusedLocationProviderClient(context)
fun startSharing(): Flow<Location> = callbackFlow {
val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 3_000L)
.setMinUpdateDistanceMeters(5f)
.build()
val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
result.lastLocation?.let { location ->
trySend(location)
// Also emit over WebSocket
socket.sendLocation(
latitude = location.latitude,
longitude = location.longitude,
accuracy = location.accuracy,
speed = location.speed
)
}
}
}
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION)
== PackageManager.PERMISSION_GRANTED
) {
fusedLocationClient.requestLocationUpdates(request, callback, Looper.getMainLooper())
}
awaitClose { fusedLocationClient.removeLocationUpdates(callback) }
}
fun observePartnerLocation(): Flow<PartnerLocation> = socket.events
.filterIsInstance<SocketEvent.PartnerLocationUpdate>()
.map { PartnerLocation(lat = it.latitude, lng = it.longitude, updatedAt = it.timestamp) }
}
Step 2: ViewModel
@HiltViewModel
class LiveLocationViewModel @Inject constructor(
private val repository: LiveLocationRepository,
private val routingRepository: RoutingRepository
) : ViewModel() {
private val _state = MutableStateFlow(LiveLocationState())
val state: StateFlow<LiveLocationState> = _state.asStateFlow()
init {
observeMyLocation()
observePartnerLocation()
}
private fun observeMyLocation() = viewModelScope.launch {
repository.startSharing().collect { location ->
_state.update { it.copy(myLocation = LatLng(location.latitude, location.longitude)) }
// Recalculate ETA when my position changes significantly
recomputeEta()
}
}
private fun observePartnerLocation() = viewModelScope.launch {
repository.observePartnerLocation().collect { partner ->
_state.update { it.copy(
partnerLocation = LatLng(partner.lat, partner.lng),
partnerLastSeen = partner.updatedAt
) }
recomputeEta()
}
}
private var etaJob: Job? = null
private fun recomputeEta() {
val my = _state.value.myLocation ?: return
val partner = _state.value.partnerLocation ?: return
// Debounce ETA recalculation
etaJob?.cancel()
etaJob = viewModelScope.launch {
delay(5_000) // wait 5s before recalculating
val result = runCatching { routingRepository.getRoute(my, partner) }
result.onSuccess { route ->
_state.update { it.copy(
etaSeconds = route.durationSeconds,
distanceMeters = route.distanceMeters
) }
}
}
}
}
data class LiveLocationState(
val myLocation: LatLng? = null,
val partnerLocation: LatLng? = null,
val partnerLastSeen: Long? = null,
val etaSeconds: Long? = null,
val distanceMeters: Int? = null
)
Step 3: Smooth Marker Animation
class MarkerAnimator(private val map: GoogleMap) {
private var partnerMarker: Marker? = null
fun animatePartnerTo(newPosition: LatLng) {
val marker = partnerMarker
if (marker == null) {
// First update — place immediately
partnerMarker = map.addMarker(
MarkerOptions()
.position(newPosition)
.icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_AZURE))
.title("Partner")
)
return
}
// Animate from current to new position
val startPosition = marker.position
ValueAnimator.ofFloat(0f, 1f).apply {
duration = 1000 // 1s animation
interpolator = LinearInterpolator()
addUpdateListener { animator ->
val fraction = animator.animatedFraction
val interpolated = LatLng(
startPosition.latitude + (newPosition.latitude - startPosition.latitude) * fraction,
startPosition.longitude + (newPosition.longitude - startPosition.longitude) * fraction
)
marker.position = interpolated
}
start()
}
}
}
Step 4: Compose UI
@Composable
fun LiveLocationScreen(viewModel: LiveLocationViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
Column(Modifier.fillMaxSize()) {
// Status bar
state.etaSeconds?.let { eta ->
Row(
Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.primaryContainer).padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("ETA: ${formatEta(eta)}")
Text("${state.distanceMeters?.let { "${it / 1000.0} km" } ?: ""}")
}
}
// Partner staleness indicator
state.partnerLastSeen?.let { lastSeen ->
val ageSeconds = (System.currentTimeMillis() - lastSeen) / 1000
if (ageSeconds > 30) {
Text(
"Partner location ${ageSeconds}s old",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(8.dp)
)
}
}
// Map
AndroidView(
factory = { context ->
MapView(context).apply {
onCreate(null)
onResume()
}
},
modifier = Modifier.fillMaxSize()
)
}
}
private fun formatEta(seconds: Long): String {
return when {
seconds < 60 -> "< 1 min"
seconds < 3600 -> "${seconds / 60} min"
else -> "${seconds / 3600}h ${(seconds % 3600) / 60}m"
}
}
Verification Checklist
[ ] My location marker moves as I walk
[ ] Partner location marker animates smoothly (no jump) between updates
[ ] ETA updates within 10 seconds of significant position change
[ ] "Location Xsec old" appears when partner goes offline > 30s
[ ] App in background: location still emits (if foreground service running)
[ ] WebSocket reconnects after network interruption