diff --git a/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt b/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt index 4dcefc5..b054c17 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt @@ -2,6 +2,7 @@ package org.db3.airmq.features.map import android.graphics.Bitmap import android.graphics.Canvas +import android.graphics.Point import android.graphics.drawable.BitmapDrawable import android.os.Handler import android.os.Looper @@ -22,8 +23,14 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -63,6 +70,8 @@ import org.osmdroid.views.overlay.Marker import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue import androidx.compose.material3.rememberModalBottomSheetState import androidx.core.graphics.createBitmap import androidx.core.graphics.drawable.toDrawable @@ -98,6 +107,29 @@ fun MapScreen( ) } +/** + * Bottom-sheet height fraction for map camera padding (0 = none, 0.5 = half screen). + * Uses [SheetState] so the map reacts as soon as open/close *animations* start, not only when + * values settle: closing uses target Hidden while still expanded; opening must not keep 0 inset + * while both values are still Hidden (that delayed the camera until the sheet finished opening). + */ +@OptIn(ExperimentalMaterial3Api::class) +private fun mapSheetFractionForDevicePanel( + devicePanelPresent: Boolean, + sheetState: SheetState +): Float { + if (!devicePanelPresent) return 0f + return when { + sheetState.currentValue == SheetValue.Expanded && + sheetState.targetValue == SheetValue.Expanded -> 0.5f + sheetState.targetValue == SheetValue.Hidden && + sheetState.currentValue == SheetValue.Expanded -> 0f + sheetState.targetValue == SheetValue.Expanded && + sheetState.currentValue == SheetValue.Hidden -> 0.5f + else -> 0.5f + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun MapScreenContent( @@ -106,10 +138,22 @@ private fun MapScreenContent( showMap: Boolean ) { val context = LocalContext.current + val modalBottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val centerOnMarker = uiState.selectedMarkerId?.let { id -> uiState.items.find { it.id == id } } - val sheetHeightFraction = if (uiState.devicePanelState != null) 0.5f else 0f + // Map padding tracks sheet motion: interpolate with SheetState.progress during open/close so the + // camera offset stays in sync with the panel animation (not only after VM/sheet settle). + val mapSheetHeightFraction = mapSheetFractionForDevicePanel( + devicePanelPresent = uiState.devicePanelState != null, + sheetState = modalBottomSheetState + ) + + LaunchedEffect(uiState.devicePanelState?.id) { + if (uiState.devicePanelState != null) { + modalBottomSheetState.show() + } + } Box(modifier = Modifier.fillMaxSize()) { if (showMap) { @@ -117,7 +161,7 @@ private fun MapScreenContent( items = uiState.items, onMarkerClick = { onEvent(Event.MarkerClicked(it)) }, centerOnMarker = centerOnMarker, - sheetHeightFraction = sheetHeightFraction, + sheetHeightFraction = mapSheetHeightFraction, clusterEnabled = true, modifier = Modifier.fillMaxSize() ) @@ -165,10 +209,9 @@ private fun MapScreenContent( } uiState.devicePanelState?.let { panelData -> - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ModalBottomSheet( onDismissRequest = { onEvent(Event.DevicePanelClosed) }, - sheetState = sheetState + sheetState = modalBottomSheetState ) { MapDevicePanelContent( data = panelData, @@ -200,6 +243,22 @@ private const val MapClusterDebounceMs = 150L private const val MapClusterDistanceDp = 48f private const val MapClusterZoomPaddingPx = 64 +/** Extra time after [mapAnimationSpeedMs] so osmdroid ValueAnimator + layout can finish before we read Projection. */ +private const val MapCameraSettleExtraMs = 48L + +/** + * Waits until [MapView.isAnimating] is false (zoom/pan animation) or [maxWaitMs], then one more frame. + */ +private suspend fun MapView.awaitMapAnimationSettled(maxWaitMs: Long = 600L) { + val step = 16L + var waited = 0L + while (isAnimating() && waited < maxWaitMs) { + delay(step) + waited += step + } + delay(MapCameraSettleExtraMs) +} + @Composable private fun AirMQMap( items: List, @@ -216,6 +275,9 @@ private fun AirMQMap( val latestOnMarkerClick = rememberUpdatedState(onMarkerClick) val latestClusterEnabled = rememberUpdatedState(clusterEnabled) val latestCenterOnMarker = rememberUpdatedState(centerOnMarker) + val latestSheetHeightFraction = rememberUpdatedState(sheetHeightFraction) + /** Tracks sheet inset so we can run a one-shot recenter when the panel fully dismisses (fraction 0). */ + var previousSheetFraction by remember { mutableFloatStateOf(0f) } val initialCameraDone = remember { AtomicBoolean(false) } @@ -279,28 +341,57 @@ private fun AirMQMap( } val mapAnimationSpeedMs = 200L - LaunchedEffect(centerOnMarker, sheetHeightFraction) { - centerOnMarker?.let { marker -> - val markerGeo = GeoPoint(marker.latitude, marker.longitude) - val zoomLevel = 15.5 - if (sheetHeightFraction > 0f) { + val markerZoomLevel = 15.5 + + // Zoom/pan starts as soon as the marker is selected (same time as the bottom sheet), not after it opens. + LaunchedEffect(centerOnMarker?.id) { + val marker = centerOnMarker ?: return@LaunchedEffect + val markerGeo = GeoPoint(marker.latitude, marker.longitude) + mapView.controller.animateTo(markerGeo, markerZoomLevel, mapAnimationSpeedMs) + coroutineScope { + launch { + var waited = 0 + while (latestSheetHeightFraction.value <= 0f && waited < 500) { + delay(16) + waited += 16 + } + val frac = latestSheetHeightFraction.value + if (frac <= 0f) return@launch + delay(mapAnimationSpeedMs) + mapView.awaitMapAnimationSettled() mapView.post { val height = mapView.height - val width = mapView.width - if (height > 0 && width > 0) { - val sheetHeightPx = (height * sheetHeightFraction).toInt() - val offsetCenterY = height / 2 + sheetHeightPx / 2 - val projection = mapView.projection - val offsetGeo = projection.fromPixels(width / 2, offsetCenterY) - mapView.controller.animateTo(offsetGeo, zoomLevel, mapAnimationSpeedMs) - } + if (height <= 0) return@post + val sheetHeightPx = (height * frac).toInt() + val targetY = (height - sheetHeightPx) / 2 + val projection = mapView.projection + val p = Point() + projection.toPixels(markerGeo, p) + mapView.scrollBy(0, p.y - targetY) } - } else { - mapView.controller.animateTo(markerGeo, zoomLevel, mapAnimationSpeedMs) } } } + // When the device panel dismisses, recenter on the marker for the full map (no sheet inset). + LaunchedEffect(sheetHeightFraction, centerOnMarker?.id) { + val marker = centerOnMarker + if (marker == null) { + previousSheetFraction = sheetHeightFraction + return@LaunchedEffect + } + val wasSheet = previousSheetFraction > 0f + val isSheet = sheetHeightFraction > 0f + if (wasSheet && !isSheet) { + mapView.controller.animateTo( + GeoPoint(marker.latitude, marker.longitude), + markerZoomLevel, + mapAnimationSpeedMs + ) + } + previousSheetFraction = sheetHeightFraction + } + // Overlay rebuild must be keyed off composable inputs: AndroidView's `update` runs without // recording snapshot reads when values are only read inside View.post {}, so map items can // load without a rebuild until something else (e.g. pan → debounced rebuild) retriggers it.