Skip to main content

Maps & Dual Provider System

This document covers the Visla GPS Android app's map architecture, including the dual Google Maps + Mapbox provider system, marker rendering, the MapBottomSheet with AnchoredDraggable, position history playback, and camera interaction patterns.

Sub-pages:

  • Google Maps Implementation β€” Google Maps SDK integration, composables, camera state, fit-all algorithm
  • Mapbox Implementation β€” Mapbox SDK integration, MarkerBitmapFactory, polylines, position history playback
  • MapBottomSheet β€” AnchoredDraggable state machine, anchor points, nested scroll, content modes

Architecture Overview​

The map system supports two map providers β€” Google Maps and Mapbox β€” behind a unified composable API. The screen layer (MapScreen) is provider-agnostic; it delegates to MapContent, which switches between GoogleMapContent and MapboxMapContent based on the user's preference.

Key Source Files​

FilePurpose
ui/maps/MapContent.ktUnified composable β€” delegates to active provider
ui/maps/GoogleMapContent.ktGoogle Maps implementation
ui/maps/MapboxMapContent.ktMapbox bridge composable
ui/maps/MapboxMapView.ktMapbox Maps SDK composable
ui/maps/MapAction.ktOne-shot map commands (sealed class)
ui/maps/MapCameraState.ktProvider-agnostic camera state
ui/maps/MarkerBitmapFactory.ktBitmap marker rendering for Mapbox
ui/viewmodels/MapViewModel.ktMVI ViewModel for map state + actions
ui/screens/MapScreen.ktTop-level screen composable
ui/components/map/MapBottomSheet.ktAnchoredDraggable bottom sheet
ui/components/map/SheetAnchor.ktSheet anchor enum
ui/components/map/EdgeClampedMarkers.ktOff-screen marker clamping
ui/components/map/MapControls.ktMap control buttons
ui/components/map/MapSheetContent.ktDevice info & notification content
ui/components/common/DeviceMarker.ktUnified marker composable
data/MapProviderManager.ktProvider preference persistence
ui/theme/MapConstants.ktShared map constants

Map Provider Abstraction​

MapProvider Enum & Manager​

The MapProvider enum and MapProviderManager manage provider selection, persisted in SharedPreferences:

// data/MapProviderManager.kt
enum class MapProvider(val displayName: String, val id: String) {
GOOGLE("Google Maps", "google"),
MAPBOX("Mapbox", "mapbox");

companion object {
fun fromId(id: String): MapProvider = entries.find { it.id == id } ?: GOOGLE
}
}

@Singleton
class MapProviderManager @Inject constructor(@ApplicationContext context: Context) {
private val prefs = context.getSharedPreferences("map_settings", Context.MODE_PRIVATE)
private val _currentProvider = MutableStateFlow(loadProvider())
val currentProvider: StateFlow<MapProvider> = _currentProvider.asStateFlow()

fun setProvider(provider: MapProvider) {
prefs.edit { putString("map_provider", provider.id) }
_currentProvider.value = provider
}
}

The ViewModel exposes the provider as a StateFlow and handles switching via MapIntent.SetMapProvider:

// ui/viewmodels/MapViewModel.kt
val currentProvider: StateFlow<MapProvider> = mapProviderManager.currentProvider

Unified MapContent Composable​

MapContent is the single entry point consumed by MapScreen. It accepts a provider-agnostic parameter set and uses a when expression to delegate:

// ui/maps/MapContent.kt
@Composable
fun MapContent(
provider: MapProvider,
devices: List<DeviceWithPosition>,
selectedDeviceId: Int?,
cameraState: MapCameraState,
mapType: MapType,
locationEnabled: Boolean,
mapActions: Flow<MapAction>,
pendingInitialFit: Boolean,
onCameraStateChanged: (MapCameraState) -> Unit,
onDeviceClick: (DeviceWithPosition) -> Unit,
onMapClick: () -> Unit,
onProjectionAvailable: (((Double, Double) -> Pair<Float, Float>?)?) -> Unit,
// ...
) {
when (provider) {
MapProvider.GOOGLE -> GoogleMapContent(/* ... */)
MapProvider.MAPBOX -> MapboxMapContent(/* ... */)
}
}

Both providers:

  • Consume the same Flow<MapAction> for one-shot commands
  • Report camera changes via onCameraStateChanged(MapCameraState)
  • Provide a projection function via onProjectionAvailable for edge-clamped markers
  • Handle pendingInitialFit to auto-zoom to all devices on first load

MapCameraState​

A provider-agnostic data class that captures camera position:

// ui/maps/MapCameraState.kt
data class MapCameraState(
val centerLat: Double = MapConstants.DEFAULT_LAT, // 42.5 (Italy)
val centerLng: Double = MapConstants.DEFAULT_LNG, // 12.5
val zoom: Double = MapConstants.DEFAULT_OVERVIEW_ZOOM, // 5.5
val bearing: Double = 0.0,
val pitch: Double = 0.0,
)

MapAction β€” One-Shot Commands​

The ViewModel emits commands through a Channel<MapAction> consumed as a Flow:

// ui/maps/MapAction.kt
sealed class MapAction {
data class FocusOnPoint(
val latitude: Double,
val longitude: Double,
val zoom: Double? = null,
) : MapAction()

data object FitDevices : MapAction()
}
// ui/viewmodels/MapViewModel.kt
private val _mapActions = Channel<MapAction>(Channel.BUFFERED)
val mapActions: Flow<MapAction> = _mapActions.receiveAsFlow()

Marker System​

DeviceMarker Composable​

The DeviceMarker composable (in ui/components/common/DeviceMarker.kt) is a unified marker used across all map contexts. It always uses DarkColorScheme for consistent white accents regardless of the app theme.

enum class MarkerVariant {
DEVICE, // Standard device marker (online/offline, moving/stationary)
GEOFENCE_CENTER, // Circle geofence center
GEOFENCE_POINT, // Polygon vertex with index
DEVICE_POSITION, // Static position (history, geofence editor)
HISTORY_CURRENT, // History playback current position
ROUTE_ENDPOINT, // Route start (green) / end (red)
}

Marker sizing (from Dimensions.Marker)​

TokenValueUsage
size32.dpMain marker circle diameter
glowSize44.dpGlow effect diameter for online devices
iconSize16.dpDefault icon size inside marker
navigationIconSize18.dpNavigation arrow icon when moving

See UI Components for the full Dimensions reference.

Status-based rendering​

StateBackground colorIconRotation
Online + stationarycolors.success (green)Device category iconNone
Online + movingcolors.success (green)Icons.Default.Navigationposition.course degrees
Offlinecolors.error (red)Device category iconNone

Online devices show a glow ring at 0.3 alpha around the marker circle.

EdgeClampedMarkers​

For devices outside the visible map viewport, EdgeClampedMarkers renders small markers clamped to the screen edges pointing in the direction of the off-screen device:

@Composable
fun EdgeClampedMarkers(
devices: List<DeviceWithPosition>,
toScreenPosition: ((Double, Double) -> Pair<Float, Float>?)?,
onDeviceClick: (DeviceWithPosition) -> Unit,
)

The clamping algorithm:

  1. Projects each device's lat/lng to screen coordinates via the provider's projection function
  2. Skips devices that are on-screen (sx in 0..width && sy in 0..height)
  3. Computes direction vector from screen center to projected position
  4. Scales the vector to intersect the screen edge, offset by half the marker size
  5. Places a clickable DeviceMarker at the clamped position

Map Interaction Patterns​

Camera Movement​

Camera updates flow through the MVI cycle:

Programmatic camera movements go through MapAction:

Initial Fit​

On first load, the app auto-fits to show all devices:

  1. MapScreen detects first non-empty device list β†’ sends MapIntent.RequestInitialFit
  2. ViewModel sets pendingInitialFit = true (only once per session via hasPerformedInitialFit guard)
  3. Both providers check pendingInitialFit && devices.isNotEmpty() β†’ run fit-all β†’ call onInitialFitComplete()
  4. ViewModel clears the flag via MapIntent.ClearInitialFit

Provider Toggle​

Users can switch providers at runtime via the map control button. The MapScreen toggles between GOOGLE and MAPBOX:

onProviderToggle = {
val newProvider = when (currentProvider) {
MapProvider.GOOGLE -> MapProvider.MAPBOX
MapProvider.MAPBOX -> MapProvider.GOOGLE
}
mapViewModel.handle(MapIntent.SetMapProvider(newProvider))
}

The current MapCameraState is preserved across switches since it's stored in the ViewModel, not in provider-specific state.

Map Type Cycling​

Map types cycle through three options: NORMAL β†’ SATELLITE β†’ HYBRID β†’ NORMAL. The Mapbox provider translates these to its own style URIs via mapTypeToMapboxStyle().


MapConstants​

Shared constants used by both providers (defined in ui/theme/MapConstants.kt):

object MapConstants {
const val FOCUS_ZOOM = 16.0 // Device/location focus zoom
const val FOCUS_ZOOM_FLOAT = 16f // Float variant for Google Maps
const val DEFAULT_ZOOM = 14.0 // Default zoom level
const val FIT_ALL_PADDING = 100.0 // Bounds padding (Mapbox EdgeInsets)
const val FIT_ALL_PADDING_INT = 100 // Bounds padding (Google Maps int)
const val PUCK_CLICK_THRESHOLD = 60.0 // Puck tap radius in px (Mapbox)
const val NOTIFICATION_ZOOM = 16f // Deep link zoom level
const val DEFAULT_LAT = 42.5 // Default center latitude (Italy)
const val DEFAULT_LNG = 12.5 // Default center longitude
const val DEFAULT_OVERVIEW_ZOOM = 5.5 // Initial overview zoom
}

Design Decisions​

Why dual map providers?​

Google Maps is the industry standard with broad device support, but Mapbox offers superior 3D rendering (Style.STANDARD with day/night presets), better offline capabilities, and more flexible styling. Supporting both lets users choose their preference and provides a fallback if one provider has issues in a particular region.

Why custom AnchoredDraggable instead of Material BottomSheet?​

Material's BottomSheetScaffold has limited anchor customization and doesn't support content-fitted half-expansion. The custom implementation allows:

  • Dynamic anchor recalculation when content mode changes (device list vs. fixed-height detail)
  • Fine-grained nested scroll coordination with the device list LazyColumn
  • Step-by-step fling collapsing (Expanded β†’ HalfExpanded β†’ Collapsed)

See MapBottomSheet for the full implementation details.

Why Compose markers for Google but bitmaps for Mapbox?​

Google's MarkerComposable renders Compose trees directly as markers with automatic recomposition. Mapbox's PointAnnotation requires bitmap images. The MarkerBitmapFactory bridges this gap by rendering the same visual design (colors, glow, rotation) to an ImageBitmap using CanvasDrawScope, quantizing course to 5Β° increments to minimize bitmap regeneration.

See Mapbox Implementation for details on the bitmap rendering.

Why great circle bearing for off-screen projection?​

Mapbox returns (-1, -1) for off-screen coordinates, losing directional information. A simple linear extrapolation would be incorrect at high zoom levels or for distant devices. The great circle bearing calculation ensures edge-clamped markers always point in the geographically correct direction, even accounting for map bearing rotation.

See Mapbox Implementation for the implementation.

Why does history use Mapbox only?​

The history screen was built after the main map and doesn't need provider switching since it's a dedicated view for route playback. Mapbox's PolylineAnnotation and ViewAnnotation provide better composability for the route + marker overlay pattern compared to Google's approach.

See Mapbox Implementation β€” Position History for details.

Camera state preservation across provider switches​

MapCameraState lives in the MapViewModel, not in provider-specific state objects. When the user toggles providers, the new provider initializes with the same camera position, creating a seamless transition.