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β
| File | Purpose |
|---|---|
ui/maps/MapContent.kt | Unified composable β delegates to active provider |
ui/maps/GoogleMapContent.kt | Google Maps implementation |
ui/maps/MapboxMapContent.kt | Mapbox bridge composable |
ui/maps/MapboxMapView.kt | Mapbox Maps SDK composable |
ui/maps/MapAction.kt | One-shot map commands (sealed class) |
ui/maps/MapCameraState.kt | Provider-agnostic camera state |
ui/maps/MarkerBitmapFactory.kt | Bitmap marker rendering for Mapbox |
ui/viewmodels/MapViewModel.kt | MVI ViewModel for map state + actions |
ui/screens/MapScreen.kt | Top-level screen composable |
ui/components/map/MapBottomSheet.kt | AnchoredDraggable bottom sheet |
ui/components/map/SheetAnchor.kt | Sheet anchor enum |
ui/components/map/EdgeClampedMarkers.kt | Off-screen marker clamping |
ui/components/map/MapControls.kt | Map control buttons |
ui/components/map/MapSheetContent.kt | Device info & notification content |
ui/components/common/DeviceMarker.kt | Unified marker composable |
data/MapProviderManager.kt | Provider preference persistence |
ui/theme/MapConstants.kt | Shared 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
onProjectionAvailablefor edge-clamped markers - Handle
pendingInitialFitto 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)β
| Token | Value | Usage |
|---|---|---|
size | 32.dp | Main marker circle diameter |
glowSize | 44.dp | Glow effect diameter for online devices |
iconSize | 16.dp | Default icon size inside marker |
navigationIconSize | 18.dp | Navigation arrow icon when moving |
See UI Components for the full Dimensions reference.
Status-based renderingβ
| State | Background color | Icon | Rotation |
|---|---|---|---|
| Online + stationary | colors.success (green) | Device category icon | None |
| Online + moving | colors.success (green) | Icons.Default.Navigation | position.course degrees |
| Offline | colors.error (red) | Device category icon | None |
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:
- Projects each device's lat/lng to screen coordinates via the provider's projection function
- Skips devices that are on-screen (
sx in 0..width && sy in 0..height) - Computes direction vector from screen center to projected position
- Scales the vector to intersect the screen edge, offset by half the marker size
- Places a clickable
DeviceMarkerat 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:
MapScreendetects first non-empty device list β sendsMapIntent.RequestInitialFit- ViewModel sets
pendingInitialFit = true(only once per session viahasPerformedInitialFitguard) - Both providers check
pendingInitialFit && devices.isNotEmpty()β run fit-all β callonInitialFitComplete() - 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.