diff --git a/README.md b/README.md index 0d292ec..5b2535c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Add to your `build.gradle.kts`: ```kotlin dependencies { - implementation("de.afarber:openmapview:0.11.0") + implementation("de.afarber:openmapview:0.12.0") } ``` diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md index 1a682bf..9d0fb7b 100644 --- a/docs/PERFORMANCE.md +++ b/docs/PERFORMANCE.md @@ -269,7 +269,7 @@ HttpClient(Android) { All requests include a user-agent header as required by OSM tile usage policy: ```kotlin -header("User-Agent", "OpenMapView/0.11.0 (https://github.com/afarber/OpenMapView)") +header("User-Agent", "OpenMapView/0.12.0 (https://github.com/afarber/OpenMapView)") ``` ### Coroutine-Based Downloads diff --git a/docs/PUBLIC_API.md b/docs/PUBLIC_API.md index 069d98d..38887cd 100644 --- a/docs/PUBLIC_API.md +++ b/docs/PUBLIC_API.md @@ -43,6 +43,18 @@ This document lists all public non-deprecated methods from Google's MapView and | -------------------------- | ----------- | ----- | | `addMarker(MarkerOptions)` | `Marker` | | | `clear()` | `void` | | +| `showInfoWindow(Marker)` | `void` | Shows marker's info window with auto-dismiss support | +| `hideInfoWindow(Marker)` | `void` | Hides marker's info window and cancels auto-dismiss | + +--- + +## Marker Class + +| Method | Return Type | Notes | +| ------------------ | ----------- | -------------------------------------------------------------------- | +| `showInfoWindow()` | `void` | Shows this marker's info window (auto-dismiss if configured via UiSettings) | +| `hideInfoWindow()` | `void` | Hides this marker's info window | +| `isInfoWindowShown`| `Boolean` | Returns whether this marker's info window is currently shown | --- @@ -222,6 +234,7 @@ Methods available on the UiSettings object returned by `getUiSettings()`: | `setMapToolbarEnabled(boolean)` | `void` | Not implemented - use openInExternalApp() instead (see External Map Integration section) | | `isMapToolbarEnabled()` | `boolean` | Always returns false | | `setAllGesturesEnabled(boolean)` | `void` | | +| `infoWindowAutoDismiss` | `Duration` | OpenMapView-specific: auto-dismiss info windows after duration (ZERO = disabled) | --- @@ -287,7 +300,7 @@ OpenMapView provides comprehensive event listener support using Kotlin `fun inte | `setOnMapLoadedCallback(OnMapLoadedCallback)` | `void` | Not implemented - tiles load asynchronously, callback could be added | | `setInfoWindowAdapter(InfoWindowAdapter)` | `void` | Not implemented - custom adapters not yet implemented | | `setOnInfoWindowClickListener(OnInfoWindowClickListener)` | `void` | | -| `setOnInfoWindowCloseListener(OnInfoWindowCloseListener)` | `void` | Not implemented | +| `setOnInfoWindowCloseListener(OnInfoWindowCloseListener)` | `void` | Called when info window is closed (manual or auto-dismiss) | | `setOnInfoWindowLongClickListener(OnInfoWindowLongClickListener)` | `void` | Not implemented | | `setOnMyLocationButtonClickListener(OnMyLocationButtonClickListener)` | `void` | Not implemented | | `setOnMyLocationClickListener(OnMyLocationClickListener)` | `void` | Not implemented | diff --git a/docs/REPLACING_GOOGLE_MAPS.md b/docs/REPLACING_GOOGLE_MAPS.md index ff70263..46c1a6d 100644 --- a/docs/REPLACING_GOOGLE_MAPS.md +++ b/docs/REPLACING_GOOGLE_MAPS.md @@ -107,7 +107,7 @@ dependencies { ```kotlin // Add to build.gradle.kts dependencies { - implementation("de.afarber:openmapview:0.11.0") + implementation("de.afarber:openmapview:0.12.0") } ``` diff --git a/examples/Example01Pan/src/main/kotlin/de/afarber/openmapview/example01pan/ArrowToolbar.kt b/examples/Example01Pan/src/main/kotlin/de/afarber/openmapview/example01pan/ArrowToolbar.kt index 3eb7f85..27a1186 100644 --- a/examples/Example01Pan/src/main/kotlin/de/afarber/openmapview/example01pan/ArrowToolbar.kt +++ b/examples/Example01Pan/src/main/kotlin/de/afarber/openmapview/example01pan/ArrowToolbar.kt @@ -90,7 +90,7 @@ fun ArrowToolbar( modifier = Modifier.size(56.dp), shape = RoundedCornerShape(topEnd = ToolbarCornerRadius, bottomEnd = ToolbarCornerRadius), colors = IconButtonDefaults.filledIconButtonColors( - containerColor = OsmParkGreen, + containerColor = OsmHighwayPink, contentColor = Color.Black, ), ) { diff --git a/examples/Example02Zoom/src/main/kotlin/de/afarber/openmapview/example02zoom/ZoomToolbar.kt b/examples/Example02Zoom/src/main/kotlin/de/afarber/openmapview/example02zoom/ZoomToolbar.kt index 5c110db..8efc458 100644 --- a/examples/Example02Zoom/src/main/kotlin/de/afarber/openmapview/example02zoom/ZoomToolbar.kt +++ b/examples/Example02Zoom/src/main/kotlin/de/afarber/openmapview/example02zoom/ZoomToolbar.kt @@ -11,8 +11,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Remove +import androidx.compose.material.icons.filled.ZoomIn +import androidx.compose.material.icons.filled.ZoomOut import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButtonDefaults @@ -54,7 +54,7 @@ fun ZoomToolbar( contentColor = Color.Black, ), ) { - Icon(Icons.Default.Add, contentDescription = "Zoom In") + Icon(Icons.Default.ZoomIn, contentDescription = "Zoom In") } FilledIconButton( onClick = onZoomOutClick, @@ -65,7 +65,7 @@ fun ZoomToolbar( contentColor = Color.Black, ), ) { - Icon(Icons.Default.Remove, contentDescription = "Zoom Out") + Icon(Icons.Default.ZoomOut, contentDescription = "Zoom Out") } } } diff --git a/examples/Example03Markers/README.md b/examples/Example03Markers/README.md index b068ab3..015c53a 100644 --- a/examples/Example03Markers/README.md +++ b/examples/Example03Markers/README.md @@ -1,20 +1,19 @@ -# Example03Markers - Marker Overlays and Click Handling +# Example03Markers - Marker Navigation and Info Windows [Back to README](../../README.md) -This example demonstrates the marker system in OpenMapView, including marker rendering, touch detection, and click event handling. +This example demonstrates the marker system in OpenMapView, including marker rendering, touch detection, info windows, and marker navigation. ## Features Demonstrated -- Multiple markers at different geographic locations -- Default red teardrop marker icons with color variations -- Both API styles: Kotlin direct instantiation and Google Maps builder pattern -- Marker click detection and callbacks -- Toast notifications on marker click -- Markers with title and snippet metadata -- Marker positioning with proper anchor points -- Markers that stay fixed during pan and zoom -- Info windows showing marker titles and snippets +- Multiple markers at real Bochum landmark locations +- Default teardrop marker icons with color variations +- Marker click detection and selection tracking +- Info windows showing marker titles and snippets with auto-dismiss +- Navigation between markers with prev/next buttons +- Info window toggle via FAB or marker tap +- Real-time status display (selection index, camera state) +- Visual feedback when info window is shown (red text) ## Screenshot @@ -39,30 +38,75 @@ This example demonstrates the marker system in OpenMapView, including marker ren adb shell am start -n de.afarber.openmapview.example03markers/.MainActivity ``` +## Project Structure + +``` +example03markers/ +├── MainActivity.kt # Main activity and MapViewScreen composable +├── MarkerToolbar.kt # Horizontal toolbar with prev/next navigation buttons +├── StatusToolbar.kt # Status overlay showing selection index and camera state +├── MarkerData.kt # Marker data class and Bochum POI locations +└── Constants.kt # Colors, dimensions, and durations +``` + ## Code Highlights -### Adding Markers - Kotlin Style +### MainActivity.kt ```kotlin -OpenMapView(context).apply { - setCenter(LatLng(51.4661, 7.2491)) // Bochum, Germany - setZoom(14.0) - - // Kotlin-style direct instantiation - addMarker( - Marker( - position = LatLng(51.4661, 7.2491), - title = "Bochum City Center", - snippet = "Welcome to Bochum!", +@Composable +fun MapViewScreen() { + val lifecycleOwner = LocalLifecycleOwner.current + var mapView: OpenMapView? by remember { mutableStateOf(null) } + var selectedIndex by remember { mutableIntStateOf(0) } + var selectedMarker: Marker? by remember { mutableStateOf(null) } + var isInfoWindowShown by remember { mutableStateOf(false) } + + Box(modifier = Modifier.fillMaxSize()) { + AndroidView( + factory = { ctx -> + OpenMapView(ctx).apply { + lifecycleOwner.lifecycle.addObserver(this) + setCenter(initialLocation) + setZoom(13.0f) + getUiSettings().infoWindowAutoDismiss = 10.seconds + + setOnMarkerClickListener { marker -> + selectedMarker = marker + isInfoWindowShown = marker.isInfoWindowShown + true + } + setOnInfoWindowCloseListener { + isInfoWindowShown = false + } + mapView = this + } + }, + modifier = Modifier.fillMaxSize(), ) - ) + + StatusToolbar(selectedIndex, selectedMarker?.title, cameraState, isInfoWindowShown, ...) + MarkerToolbar(onPrevClick = { ... }, onNextClick = { ... }) + } } ``` +### Adding Markers - Kotlin Style + +```kotlin +addMarker( + Marker( + position = LatLng(51.4783, 7.2231), + title = "Bochum Hauptbahnhof", + snippet = "Main railway station", + icon = BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_RED), + ) +) +``` + ### Adding Markers - Google Maps Style ```kotlin -// Google Maps API builder pattern addMarker( MarkerOptions() .position(LatLng(51.4650, 7.2500)) @@ -72,57 +116,51 @@ addMarker( ) ``` -### Click Listener +### OSM-Inspired Colors (Constants.kt) ```kotlin -setOnMarkerClickListener { marker -> - val message = buildString { - append(marker.title ?: "Marker") - if (marker.snippet != null) { - append("\n") - append(marker.snippet) - } - } - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() - true // Consume the click event -} +val OsmParkGreen = Color(0xFFAAD3A2) // Navigation buttons (prev/next) +val OsmHighwayPink = Color(0xFFE892A2) // Info window toggle FAB +val OsmWaterBlue = Color(0xFFAAD3DF) // Reserved for future use ``` ### Key Concepts - **Marker**: Data class with position, title, snippet, icon, anchor, and tag - **addMarker()**: Add a marker to the map -- **removeMarker()**: Remove a specific marker -- **clearMarkers()**: Remove all markers +- **getMarkers()**: Get list of all markers - **setOnMarkerClickListener()**: Handle marker click events -- **Default icon**: Red teardrop shape generated via MarkerIconFactory -- **Custom icons**: Provide your own Bitmap via the `icon` parameter +- **setOnInfoWindowClickListener()**: Handle info window click events +- **setOnInfoWindowCloseListener()**: Handle info window close events (manual or auto-dismiss) +- **infoWindowAutoDismiss**: Auto-dismiss info windows after a duration ## What to Test -1. **Launch the app** - you should see 5 red markers around Bochum -2. **Click a marker** - Toast message shows title and snippet -3. **Pan the map** - markers stay at correct geographic positions -4. **Zoom in/out** - markers remain properly positioned -5. **Click different markers** - each shows its own title/snippet +1. **Launch the app** - you should see 6 colored markers at Bochum landmarks +2. **Tap a marker** - info window shows title and snippet, status text turns red +3. **Tap the same marker again** - info window closes, status text turns black +4. **Tap info window** - toast message confirms the click +5. **Tap prev/next buttons** - navigate between markers with camera animation +6. **Tap the FAB** - toggles info window on selected marker +7. **Wait 10 seconds** - info window auto-dismisses, status text turns black +8. **Pan/zoom the map** - markers stay at correct geographic positions ## Marker Locations -This example displays 5 markers: - -| Location | Coordinates | Description | -| -------- | ------------------- | ------------------ | -| Center | 51.4661°N, 7.2491°E | Bochum City Center | -| North | 51.4700°N, 7.2550°E | North Location | -| South | 51.4620°N, 7.2430°E | South Location | -| West | 51.4680°N, 7.2380°E | West Location | -| East | 51.4640°N, 7.2600°E | East Location | +This example displays 6 markers at notable Bochum landmarks: -**Note on marker positioning:** While the 4 outer markers are placed on N, S, W, E sides of the central marker (by adjusting latitude/longitude), they do not appear strictly above, below, left, right on the screen. This is due to the Web Mercator projection used by OpenStreetMap, which distorts distances and angles, especially at higher latitudes. The further from the equator, the more pronounced this distortion becomes. +| Location | Coordinates | Description | +| ----------------- | ------------------- | -------------------- | +| Hauptbahnhof | 51.4783°N, 7.2231°E | Main railway station | +| Ruhr University | 51.4452°N, 7.2622°E | Ruhr-Universitat | +| Rathaus | 51.4816°N, 7.2166°E | City Hall | +| Bermuda3eck | 51.4807°N, 7.2222°E | Entertainment dist. | +| Bergbau-Museum | 51.4892°N, 7.2174°E | Mining Museum | +| Starlight Express | 51.4649°N, 7.2043°E | Musical theater | ## Custom Marker Icons -To use custom marker icons instead of the default red teardrop: +To use custom marker icons instead of the default teardrop: ```kotlin // Create custom bitmap (e.g., from resources) @@ -171,6 +209,6 @@ Click detection uses: ## Map Location -**Default Center:** Bochum, Germany (51.4661°N, 7.2491°E) at zoom 14.0 +**Default Center:** Calculated from marker positions (~51.47°N, 7.22°E) at zoom 13.0 -All 5 markers are positioned around Bochum within ~1km radius. +All 6 markers are positioned around Bochum at real landmark locations. diff --git a/examples/Example03Markers/screenshot.gif b/examples/Example03Markers/screenshot.gif index 889b6e3..2471919 100644 Binary files a/examples/Example03Markers/screenshot.gif and b/examples/Example03Markers/screenshot.gif differ diff --git a/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/Constants.kt b/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/Constants.kt new file mode 100644 index 0000000..f396b5c --- /dev/null +++ b/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/Constants.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Alexander Farber + * SPDX-License-Identifier: MIT + * + * This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView) + */ + +package de.afarber.openmapview.example03markers + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Constants for the Example03Markers app: colors, dimensions, and durations. + */ + +/** Green color used by OpenStreetMap for parks and forests. */ +val OsmParkGreen = Color(0xFFAAD3A2) + +/** Pink color used by OpenStreetMap for highways and major roads. */ +val OsmHighwayPink = Color(0xFFE892A2) + +/** Blue color used by OpenStreetMap for water areas (lakes, rivers). */ +val OsmWaterBlue = Color(0xFFAAD3DF) + +/** Shared corner radius for all toolbar components (matches Material3 FAB). */ +val ToolbarCornerRadius = 16.dp + +/** Duration after which info windows are automatically dismissed. */ +val InfoWindowAutoDismissDuration: Duration = 5.seconds diff --git a/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/MainActivity.kt b/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/MainActivity.kt index ce085d6..757a953 100644 --- a/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/MainActivity.kt +++ b/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/MainActivity.kt @@ -11,19 +11,46 @@ import android.os.Bundle import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.LocationOn +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.compose.LocalLifecycleOwner import de.afarber.openmapview.BitmapDescriptorFactory +import de.afarber.openmapview.CameraUpdateFactory import de.afarber.openmapview.LatLng import de.afarber.openmapview.Marker -import de.afarber.openmapview.MarkerOptions +import de.afarber.openmapview.OnCameraMoveStartedListener import de.afarber.openmapview.OpenMapView +/** + * Main activity demonstrating OpenMapView marker navigation. + * + * This example showcases: + * - Displaying markers with different colors at real Bochum locations + * - Navigating between markers with prev/next buttons + * - Toggling info windows via FAB or marker tap + * - Camera animation when centering on markers + * - Real-time selection index and info window state tracking + * - Camera state monitoring + */ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -40,90 +67,154 @@ class MainActivity : ComponentActivity() { } } +/** + * Main composable screen containing the map and marker navigation controls. + * + * Displays an OpenMapView with markers at notable Bochum locations, + * a status toolbar showing selection state, and a marker toolbar for navigation. + */ @Composable fun MapViewScreen() { val context = LocalContext.current - val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current - - AndroidView( - factory = { ctx -> - OpenMapView(ctx).apply { - // Register lifecycle observer for proper cleanup - lifecycleOwner.lifecycle.addObserver(this) - - // Center on Bochum, Germany - setCenter(LatLng(51.4661, 7.2491)) - setZoom(14.0f) - - // Add several markers around Bochum with different colors - addMarker( - Marker( - position = LatLng(51.4661, 7.2491), - title = "Bochum City Center", - snippet = "Welcome to Bochum!", - icon = BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_RED), - ), - ) - - addMarker( - Marker( - position = LatLng(51.4700, 7.2550), - title = "North Location", - snippet = "A place north of center", - icon = BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_BLUE), - ), - ) - - addMarker( - Marker( - position = LatLng(51.4620, 7.2430), - title = "South Location", - snippet = "A place south of center", - icon = BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_GREEN), - ), - ) - - addMarker( - Marker( - position = LatLng(51.4680, 7.2380), - title = "West Location", - snippet = "A place west of center", - icon = BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_ORANGE), - ), - ) - - addMarker( - Marker( - position = LatLng(51.4640, 7.2600), - title = "East Location", - snippet = "A place east of center", - icon = BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_MAGENTA), - ), - ) - - // Alternative: Google Maps API style using MarkerOptions builder - addMarker( - MarkerOptions() - .position(LatLng(51.4650, 7.2500)) - .title("Builder Pattern") - .snippet("Created with MarkerOptions") - .icon(BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_CYAN)) - .alpha(0.8f), - ) - - // Set marker click listener - // Info window is shown automatically if marker has title or snippet - setOnMarkerClickListener { marker -> - // Can still add custom logic here - true // Consume the click event + val lifecycleOwner = LocalLifecycleOwner.current + + // Calculate initial location from POI marker positions + val initialLocation = LatLng( + poiMarkers.map { it.position.latitude }.average(), + poiMarkers.map { it.position.longitude }.average(), + ) + val initialZoom = 14.0f + + // State variables - mapView is nullable because AndroidView.factory runs after first composition + var mapView: OpenMapView? by remember { mutableStateOf(null) } + var selectedIndex by remember { mutableIntStateOf(0) } + var cameraState by remember { mutableStateOf("Idle") } + var isInfoWindowShown by remember { mutableStateOf(false) } + + // Derived state - selectedMarker is computed from mapView and selectedIndex + val selectedMarker: Marker? = mapView?.getMarkers()?.getOrNull(selectedIndex) + + /** + * Creates POI markers on the map. + */ + fun createMarkers(map: OpenMapView) { + poiMarkers.forEach { data -> + map.addMarker( + Marker( + position = data.position, + title = data.title, + snippet = data.snippet, + icon = BitmapDescriptorFactory.defaultMarker(data.hue), + ), + ) + } + } + + Box(modifier = Modifier.fillMaxSize()) { + // Map view + AndroidView( + factory = { ctx -> + OpenMapView(ctx).apply { + lifecycleOwner.lifecycle.addObserver(this) + + setCenter(initialLocation) + setZoom(initialZoom) + getUiSettings().infoWindowAutoDismiss = InfoWindowAutoDismissDuration + + createMarkers(this) + + setOnCameraMoveStartedListener { reason -> + cameraState = when (reason) { + OnCameraMoveStartedListener.REASON_GESTURE -> "Moving (gesture)" + OnCameraMoveStartedListener.REASON_API_ANIMATION -> "Moving (animation)" + OnCameraMoveStartedListener.REASON_DEVELOPER_ANIMATION -> "Moving (programmatic)" + else -> "Moving" + } + } + + setOnCameraIdleListener { + cameraState = "Idle" + } + + setOnMarkerClickListener { marker -> + isInfoWindowShown = marker.isInfoWindowShown + selectedIndex = getMarkers().indexOf(marker).coerceAtLeast(0) + animateCamera(CameraUpdateFactory.newLatLng(marker.position), 500) + true + } + + setOnInfoWindowClickListener { marker -> + Toast.makeText(context, "Clicked: ${marker.title}", Toast.LENGTH_SHORT).show() + } + + setOnInfoWindowCloseListener { + isInfoWindowShown = false + } + + mapView = this + } + }, + modifier = Modifier.fillMaxSize(), + ) + + // Status overlay at top + StatusToolbar( + selectedIndex = selectedIndex, + selectedMarkerTitle = selectedMarker?.title, + cameraState = cameraState, + isInfoWindowShown = isInfoWindowShown, + modifier = Modifier + .align(Alignment.CenterStart) + .padding(16.dp), + ) + + // Marker toolbar at bottom + MarkerToolbar( + onPrevClick = { + mapView?.apply { + val markers = getMarkers() + if (markers.isNotEmpty()) { + selectedIndex = (selectedIndex - 1 + markers.size) % markers.size + animateCamera(CameraUpdateFactory.newLatLng(markers[selectedIndex].position), 500) + } } + }, + onNextClick = { + mapView?.apply { + val markers = getMarkers() + if (markers.isNotEmpty()) { + selectedIndex = (selectedIndex + 1) % markers.size + animateCamera(CameraUpdateFactory.newLatLng(markers[selectedIndex].position), 500) + } + } + }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp), + ) - // Set info window click listener - setOnInfoWindowClickListener { marker -> - Toast.makeText(context, "Clicked: ${marker.title}", Toast.LENGTH_SHORT).show() + FloatingActionButton( + onClick = { + selectedMarker?.let { marker -> + if (marker.isInfoWindowShown) { + marker.hideInfoWindow() + } else { + marker.showInfoWindow() + isInfoWindowShown = true + mapView?.animateCamera(CameraUpdateFactory.newLatLng(marker.position), 500) + } } - } - }, - modifier = Modifier.fillMaxSize(), - ) + }, + containerColor = OsmHighwayPink, + contentColor = Color.Black, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + ) { + Icon( + imageVector = Icons.Default.LocationOn, + contentDescription = "Toggle Info Window", + ) + } + } } diff --git a/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/MarkerData.kt b/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/MarkerData.kt new file mode 100644 index 0000000..6d137e1 --- /dev/null +++ b/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/MarkerData.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Alexander Farber + * SPDX-License-Identifier: MIT + * + * This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView) + */ + +package de.afarber.openmapview.example03markers + +import de.afarber.openmapview.BitmapDescriptorFactory +import de.afarber.openmapview.LatLng + +/** + * Data class representing a marker location with its display properties. + * + * @param position Geographic coordinates of the marker. + * @param title Display title shown in the info window. + * @param snippet Additional text shown in the info window. + * @param hue Color hue for the marker icon. + */ +data class MarkerData( + val position: LatLng, + val title: String, + val snippet: String, + val hue: Float, +) + +/** Points of interest at notable Bochum locations. */ +val poiMarkers = listOf( + MarkerData( + position = LatLng(51.4783, 7.2231), + title = "Bochum Hauptbahnhof", + snippet = "Main railway station", + hue = BitmapDescriptorFactory.HUE_RED, + ), + MarkerData( + position = LatLng(51.4452, 7.2622), + title = "Ruhr University", + snippet = "Ruhr-Universität Bochum", + hue = BitmapDescriptorFactory.HUE_BLUE, + ), + MarkerData( + position = LatLng(51.4816, 7.2166), + title = "Bochum Rathaus", + snippet = "City Hall", + hue = BitmapDescriptorFactory.HUE_GREEN, + ), + MarkerData( + position = LatLng(51.4807, 7.2222), + title = "Bermuda3eck", + snippet = "Entertainment district", + hue = BitmapDescriptorFactory.HUE_ORANGE, + ), + MarkerData( + position = LatLng(51.4892, 7.2174), + title = "Bergbau-Museum", + snippet = "German Mining Museum", + hue = BitmapDescriptorFactory.HUE_MAGENTA, + ), + MarkerData( + position = LatLng(51.4649, 7.2043), + title = "Starlight Express", + snippet = "Musical theater", + hue = BitmapDescriptorFactory.HUE_CYAN, + ), +) diff --git a/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/MarkerToolbar.kt b/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/MarkerToolbar.kt new file mode 100644 index 0000000..113026c --- /dev/null +++ b/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/MarkerToolbar.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2025 Alexander Farber + * SPDX-License-Identifier: MIT + * + * This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView) + */ + +package de.afarber.openmapview.example03markers + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardDoubleArrowLeft +import androidx.compose.material.icons.filled.KeyboardDoubleArrowRight +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * A horizontal toolbar with buttons to navigate between markers. + * + * Both buttons use OsmParkGreen for visual consistency with OSM styling. + * + * @param onPrevClick Callback invoked when the previous marker button is clicked. + * @param onNextClick Callback invoked when the next marker button is clicked. + * @param modifier Modifier to be applied to the toolbar. + */ +@Composable +fun MarkerToolbar( + onPrevClick: () -> Unit, + onNextClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(ToolbarCornerRadius), + shadowElevation = 6.dp, + color = MaterialTheme.colorScheme.surface, + ) { + Row { + FilledIconButton( + onClick = onPrevClick, + modifier = Modifier.size(56.dp), + shape = RoundedCornerShape(topStart = ToolbarCornerRadius, bottomStart = ToolbarCornerRadius), + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = OsmParkGreen, + contentColor = Color.Black, + ), + ) { + Icon(Icons.Default.KeyboardDoubleArrowLeft, contentDescription = "Previous Marker") + } + FilledIconButton( + onClick = onNextClick, + modifier = Modifier.size(56.dp), + shape = RoundedCornerShape(topEnd = ToolbarCornerRadius, bottomEnd = ToolbarCornerRadius), + colors = IconButtonDefaults.filledIconButtonColors( + containerColor = OsmParkGreen, + contentColor = Color.Black, + ), + ) { + Icon(Icons.Default.KeyboardDoubleArrowRight, contentDescription = "Next Marker") + } + } + } +} diff --git a/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/StatusToolbar.kt b/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/StatusToolbar.kt new file mode 100644 index 0000000..9babc18 --- /dev/null +++ b/examples/Example03Markers/src/main/kotlin/de/afarber/openmapview/example03markers/StatusToolbar.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 Alexander Farber + * SPDX-License-Identifier: MIT + * + * This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView) + */ + +package de.afarber.openmapview.example03markers + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * A status overlay displaying current marker selection and camera state. + * + * Shows the currently selected marker index, name, and camera state. + * The marker index text turns red when an info window is shown. + * + * @param selectedIndex The index of the currently selected marker. + * @param selectedMarkerTitle Title of the currently selected marker, or null if none selected. + * @param cameraState Current camera state description (e.g., "Idle", "Moving (gesture)"). + * @param isInfoWindowShown Whether an info window is currently shown. + * @param modifier Modifier to be applied to the status overlay. + */ +@Composable +fun StatusToolbar( + selectedIndex: Int, + selectedMarkerTitle: String?, + cameraState: String, + isInfoWindowShown: Boolean, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .clip(RoundedCornerShape(ToolbarCornerRadius)) + .background(Color.White) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = "Marker #${selectedIndex + 1}", + color = if (isInfoWindowShown) Color.Red else Color.Black, + ) + Text( + text = selectedMarkerTitle ?: "None", + color = Color.Black, + ) + Text( + text = "Camera: $cameraState", + color = Color.Black, + ) + } +} diff --git a/openmapview/src/main/kotlin/de/afarber/openmapview/MapController.kt b/openmapview/src/main/kotlin/de/afarber/openmapview/MapController.kt index f4da41f..34aceb9 100644 --- a/openmapview/src/main/kotlin/de/afarber/openmapview/MapController.kt +++ b/openmapview/src/main/kotlin/de/afarber/openmapview/MapController.kt @@ -173,6 +173,7 @@ class MapController( private val defaultMarkerIcon by lazy { MarkerIconFactory.getDefaultIcon() } var onMarkerClickListener: OnMarkerClickListener? = null var onInfoWindowClickListener: OnInfoWindowClickListener? = null + var onInfoWindowCloseListener: OnInfoWindowCloseListener? = null private val polylines = mutableListOf() private val polygons = mutableListOf() diff --git a/openmapview/src/main/kotlin/de/afarber/openmapview/Marker.kt b/openmapview/src/main/kotlin/de/afarber/openmapview/Marker.kt index e54705e..d55b443 100644 --- a/openmapview/src/main/kotlin/de/afarber/openmapview/Marker.kt +++ b/openmapview/src/main/kotlin/de/afarber/openmapview/Marker.kt @@ -44,24 +44,54 @@ data class Marker( */ internal val id: String = "marker_${System.nanoTime()}_${System.identityHashCode(this)}" + /** + * Reference to the parent map view. + * Set when the marker is added to a map, cleared when removed. + */ + internal var mapView: OpenMapView? = null + /** * Whether the info window is currently shown for this marker. + * + * Use [showInfoWindow] and [hideInfoWindow] to change this state. */ - internal var isInfoWindowShown: Boolean = false + var isInfoWindowShown: Boolean + get() = _isInfoWindowShown + private set(value) { + _isInfoWindowShown = value + } + + private var _isInfoWindowShown: Boolean = false /** * Shows the info window for this marker. + * * The info window displays the marker's title and snippet text. + * If [UiSettings.infoWindowAutoDismiss] is set to a positive duration on the map, + * the info window will be automatically hidden after that duration. + * + * Only one info window can be shown at a time - showing this marker's info window + * will hide any other currently shown info window. */ fun showInfoWindow() { - isInfoWindowShown = true + mapView?.showInfoWindow(this) ?: run { _isInfoWindowShown = true } } /** * Hides the info window for this marker. + * + * Also cancels any pending auto-dismiss timer. */ fun hideInfoWindow() { - isInfoWindowShown = false + mapView?.hideInfoWindow(this) ?: run { _isInfoWindowShown = false } + } + + /** + * Internal method to set the info window shown state without triggering map updates. + * Used by OpenMapView to avoid infinite recursion. + */ + internal fun setInfoWindowShownInternal(shown: Boolean) { + _isInfoWindowShown = shown } override fun equals(other: Any?): Boolean { diff --git a/openmapview/src/main/kotlin/de/afarber/openmapview/OnInfoWindowCloseListener.kt b/openmapview/src/main/kotlin/de/afarber/openmapview/OnInfoWindowCloseListener.kt new file mode 100644 index 0000000..ccf8406 --- /dev/null +++ b/openmapview/src/main/kotlin/de/afarber/openmapview/OnInfoWindowCloseListener.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Alexander Farber + * SPDX-License-Identifier: MIT + * + * This file is part of the OpenMapView project (https://github.com/afarber/OpenMapView) + */ + +package de.afarber.openmapview + +/** + * Interface for receiving info window close events. + * Called when the info window of a marker is closed, either manually or via auto-dismiss. + */ +fun interface OnInfoWindowCloseListener { + /** + * Called when an info window is closed. + * + * @param marker The marker whose info window was closed + */ + fun onInfoWindowClose(marker: Marker) +} diff --git a/openmapview/src/main/kotlin/de/afarber/openmapview/OpenMapView.kt b/openmapview/src/main/kotlin/de/afarber/openmapview/OpenMapView.kt index cbde3c5..7fd59c5 100644 --- a/openmapview/src/main/kotlin/de/afarber/openmapview/OpenMapView.kt +++ b/openmapview/src/main/kotlin/de/afarber/openmapview/OpenMapView.kt @@ -10,6 +10,8 @@ package de.afarber.openmapview import android.content.Context import android.graphics.Bitmap import android.graphics.Canvas +import android.os.Handler +import android.os.Looper import android.util.AttributeSet import android.view.GestureDetector import android.view.MotionEvent @@ -17,6 +19,7 @@ import android.view.ScaleGestureDetector import android.widget.FrameLayout import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner +import kotlin.time.Duration /** * A MapView powered by OpenStreetMap tiles. @@ -75,6 +78,8 @@ class OpenMapView private var onMarkerDragListener: OnMarkerDragListener? = null private var draggedMarker: Marker? = null private var isDragging = false + private val infoWindowHandler = Handler(Looper.getMainLooper()) + private var infoWindowDismissRunnable: Runnable? = null private val gestureDetector = GestureDetector( @@ -280,11 +285,24 @@ class OpenMapView // Check for marker touch val touchedMarker = controller.handleMarkerTouch(event.x, event.y) if (touchedMarker != null) { - // Hide all other info windows (only one can be shown at a time) - controller.getMarkers().forEach { it.hideInfoWindow() } - // Show info window for clicked marker if it has title or snippet - if (touchedMarker.title != null || touchedMarker.snippet != null) { - touchedMarker.showInfoWindow() + // Toggle info window if clicking same marker, otherwise show new one + if (touchedMarker.isInfoWindowShown) { + touchedMarker.setInfoWindowShownInternal(false) + cancelInfoWindowAutoDismiss() + controller.onInfoWindowCloseListener?.onInfoWindowClose(touchedMarker) + } else { + // Hide all other info windows (only one can be shown at a time) + controller.getMarkers().forEach { marker -> + if (marker.isInfoWindowShown) { + marker.setInfoWindowShownInternal(false) + controller.onInfoWindowCloseListener?.onInfoWindowClose(marker) + } + } + // Show info window for clicked marker if it has title or snippet + if (touchedMarker.title != null || touchedMarker.snippet != null) { + touchedMarker.setInfoWindowShownInternal(true) + scheduleInfoWindowAutoDismiss() + } } val consumed = controller.onMarkerClickListener?.onMarkerClick(touchedMarker) ?: false @@ -904,6 +922,7 @@ class OpenMapView * @return The added marker instance */ fun addMarker(marker: Marker): Marker { + marker.mapView = this val result = controller.addMarker(marker) invalidate() return result @@ -938,7 +957,10 @@ class OpenMapView */ fun removeMarker(marker: Marker): Boolean { val result = controller.removeMarker(marker) - if (result) invalidate() + if (result) { + marker.mapView = null + invalidate() + } return result } @@ -946,6 +968,7 @@ class OpenMapView * Removes all markers from the map. */ fun clearMarkers() { + controller.getMarkers().forEach { it.mapView = null } controller.clearMarkers() invalidate() } @@ -957,6 +980,56 @@ class OpenMapView */ fun getMarkers(): List = controller.getMarkers() + /** + * Shows the info window for a marker and schedules auto-dismiss if configured. + * + * This method hides any currently shown info windows, shows the info window + * for the specified marker, and triggers a redraw. If [UiSettings.infoWindowAutoDismiss] + * is set to a positive duration, the info window will be automatically hidden + * after that duration. + * + * Example: + * ```kotlin + * val marker = mapView.addMarker(Marker(position = LatLng(51.5, -0.1), title = "London")) + * mapView.showInfoWindow(marker) // Shows info window with auto-dismiss + * ``` + * + * @param marker The marker whose info window should be shown + * @see hideInfoWindow + * @see UiSettings.infoWindowAutoDismiss + */ + fun showInfoWindow(marker: Marker) { + // Hide all other info windows (only one can be shown at a time) + controller.getMarkers().forEach { it.setInfoWindowShownInternal(false) } + // Show info window for the marker if it has title or snippet + if (marker.title != null || marker.snippet != null) { + marker.setInfoWindowShownInternal(true) + scheduleInfoWindowAutoDismiss() + } + invalidate() + } + + /** + * Hides the info window for a marker and cancels any pending auto-dismiss. + * + * Example: + * ```kotlin + * mapView.hideInfoWindow(marker) + * ``` + * + * @param marker The marker whose info window should be hidden + * @see showInfoWindow + */ + fun hideInfoWindow(marker: Marker) { + val wasShown = marker.isInfoWindowShown + marker.setInfoWindowShownInternal(false) + cancelInfoWindowAutoDismiss() + if (wasShown) { + controller.onInfoWindowCloseListener?.onInfoWindowClose(marker) + } + invalidate() + } + /** * Sets a listener to handle marker click events. * @@ -993,6 +1066,27 @@ class OpenMapView controller.onInfoWindowClickListener = listener } + /** + * Sets a listener to handle info window close events. + * + * Called when an info window is closed, either manually via hideInfoWindow() + * or automatically via the auto-dismiss timer. + * + * Example: + * ```kotlin + * mapView.setOnInfoWindowCloseListener { marker -> + * Log.d("Map", "Info window closed: ${marker.title}") + * } + * ``` + * + * @param listener The listener to receive info window close events, or null to clear the listener + * @see hideInfoWindow + * @see UiSettings.infoWindowAutoDismiss + */ + fun setOnInfoWindowCloseListener(listener: OnInfoWindowCloseListener?) { + controller.onInfoWindowCloseListener = listener + } + /** * Sets a listener to handle marker drag events. * @@ -1512,6 +1606,42 @@ class OpenMapView invalidate() } + /** + * Schedules auto-dismiss of info windows if enabled in UiSettings. + * + * Called when an info window is shown. Only starts the timer if + * infoWindowAutoDismiss is positive; Duration.ZERO means disabled. + */ + private fun scheduleInfoWindowAutoDismiss() { + infoWindowDismissRunnable?.let { infoWindowHandler.removeCallbacks(it) } + val duration = uiSettings.infoWindowAutoDismiss + if (duration == Duration.ZERO) return + val runnable = + Runnable { + controller.getMarkers().forEach { marker -> + if (marker.isInfoWindowShown) { + marker.setInfoWindowShownInternal(false) + controller.onInfoWindowCloseListener?.onInfoWindowClose(marker) + } + } + // OpenMapView is a traditional Android View (not Compose), so we must + // call invalidate() to trigger a redraw after hiding info windows + invalidate() + } + infoWindowDismissRunnable = runnable + infoWindowHandler.postDelayed(runnable, duration.inWholeMilliseconds) + } + + /** + * Cancels any pending auto-dismiss of info windows. + * + * Called when an info window is manually hidden by the user. + */ + private fun cancelInfoWindowAutoDismiss() { + infoWindowDismissRunnable?.let { infoWindowHandler.removeCallbacks(it) } + infoWindowDismissRunnable = null + } + override fun onResume(owner: LifecycleOwner) { controller.onResume() } @@ -1521,6 +1651,7 @@ class OpenMapView } override fun onDestroy(owner: LifecycleOwner) { + cancelInfoWindowAutoDismiss() controller.onDestroy() } } diff --git a/openmapview/src/main/kotlin/de/afarber/openmapview/TileDownloader.kt b/openmapview/src/main/kotlin/de/afarber/openmapview/TileDownloader.kt index 952887e..71a59c8 100644 --- a/openmapview/src/main/kotlin/de/afarber/openmapview/TileDownloader.kt +++ b/openmapview/src/main/kotlin/de/afarber/openmapview/TileDownloader.kt @@ -33,7 +33,7 @@ class TileDownloader { try { val response = client.get(url) { - header("User-Agent", "OpenMapView/0.11.0 (https://github.com/afarber/OpenMapView)") + header("User-Agent", "OpenMapView/0.12.0 (https://github.com/afarber/OpenMapView)") } val bytes = response.readRawBytes() // Decode with RGB_565 to reduce memory usage (2 bytes per pixel vs 4 bytes for ARGB_8888) diff --git a/openmapview/src/main/kotlin/de/afarber/openmapview/UiSettings.kt b/openmapview/src/main/kotlin/de/afarber/openmapview/UiSettings.kt index 93198ef..cdbc7d5 100644 --- a/openmapview/src/main/kotlin/de/afarber/openmapview/UiSettings.kt +++ b/openmapview/src/main/kotlin/de/afarber/openmapview/UiSettings.kt @@ -7,6 +7,8 @@ package de.afarber.openmapview +import kotlin.time.Duration + /** * Settings for the map user interface controls and gestures. * @@ -88,6 +90,23 @@ class UiSettings { */ val isMapToolbarEnabled: Boolean = false + /** + * Duration after which info windows automatically dismiss. + * Set to Duration.ZERO to disable auto-dismiss (default). + * Non-positive values are normalized to Duration.ZERO (disabled). + * + * Example: + * ```kotlin + * import kotlin.time.Duration.Companion.seconds + * + * mapView.uiSettings.infoWindowAutoDismiss = 3.seconds + * ``` + */ + var infoWindowAutoDismiss: Duration = Duration.ZERO + set(value) { + field = if (value.isPositive()) value else Duration.ZERO + } + /** * Enables or disables all gestures. * diff --git a/openmapview/src/main/kotlin/de/afarber/openmapview/UrlTileProvider.kt b/openmapview/src/main/kotlin/de/afarber/openmapview/UrlTileProvider.kt index ba46416..fc5714a 100644 --- a/openmapview/src/main/kotlin/de/afarber/openmapview/UrlTileProvider.kt +++ b/openmapview/src/main/kotlin/de/afarber/openmapview/UrlTileProvider.kt @@ -62,7 +62,7 @@ abstract class UrlTileProvider( * * @return User-Agent string */ - protected open fun getUserAgent(): String = "OpenMapView/0.11.0 (https://github.com/afarber/OpenMapView)" + protected open fun getUserAgent(): String = "OpenMapView/0.12.0 (https://github.com/afarber/OpenMapView)" /** * Builds the URL for the specified tile coordinates by replacing placeholders diff --git a/openmapview/src/test/kotlin/de/afarber/openmapview/UiSettingsTest.kt b/openmapview/src/test/kotlin/de/afarber/openmapview/UiSettingsTest.kt index f1c6ccd..1bc3313 100644 --- a/openmapview/src/test/kotlin/de/afarber/openmapview/UiSettingsTest.kt +++ b/openmapview/src/test/kotlin/de/afarber/openmapview/UiSettingsTest.kt @@ -7,9 +7,13 @@ package de.afarber.openmapview +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds class UiSettingsTest { @Test @@ -169,4 +173,48 @@ class UiSettingsTest { val uiSettings = UiSettings() assertFalse(uiSettings.isMapToolbarEnabled) } + + @Test + fun testInfoWindowAutoDismiss_DefaultZero() { + val uiSettings = UiSettings() + assertEquals(Duration.ZERO, uiSettings.infoWindowAutoDismiss) + } + + @Test + fun testInfoWindowAutoDismiss_SetSeconds() { + val uiSettings = UiSettings() + uiSettings.infoWindowAutoDismiss = 3.seconds + assertEquals(3000L, uiSettings.infoWindowAutoDismiss.inWholeMilliseconds) + } + + @Test + fun testInfoWindowAutoDismiss_SetMilliseconds() { + val uiSettings = UiSettings() + uiSettings.infoWindowAutoDismiss = 500.milliseconds + assertEquals(500L, uiSettings.infoWindowAutoDismiss.inWholeMilliseconds) + } + + @Test + fun testInfoWindowAutoDismiss_SetToZero() { + val uiSettings = UiSettings() + uiSettings.infoWindowAutoDismiss = 3.seconds + assertEquals(3000L, uiSettings.infoWindowAutoDismiss.inWholeMilliseconds) + + uiSettings.infoWindowAutoDismiss = Duration.ZERO + assertEquals(Duration.ZERO, uiSettings.infoWindowAutoDismiss) + } + + @Test + fun testInfoWindowAutoDismiss_NegativeNormalizedToZero() { + val uiSettings = UiSettings() + uiSettings.infoWindowAutoDismiss = (-3).seconds + assertEquals(Duration.ZERO, uiSettings.infoWindowAutoDismiss) + } + + @Test + fun testInfoWindowAutoDismiss_ZeroStaysZero() { + val uiSettings = UiSettings() + uiSettings.infoWindowAutoDismiss = 0.seconds + assertEquals(Duration.ZERO, uiSettings.infoWindowAutoDismiss) + } }