Skip to content

Commit 54f6d2e

Browse files
committed
feat: Implement KML/KMZ layer support on map view
This commit introduces the ability to add and manage KML/KMZ layers on the map. Key changes: - Added UI elements for managing map layers: - "Manage Map Layers" button in the map view. - A dialog to display loaded layers, toggle their visibility, and remove them. - An "Add Layer" button within the dialog to open a file picker for KML/KMZ files. - Implemented logic in `MapViewModel` to: - Add new map layers from URIs. - Toggle the visibility of layers on the map. - Remove layers. - Load KML data from URIs and display it on the Google Map using `KmlLayer`. - Updated `MapView` to integrate with the new `MapViewModel` functionalities. - Added new string resources for layer management UI. - Included utility function to get file names from URIs. - Set default map view to a location in Nevada. Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
1 parent 52e4f53 commit 54f6d2e

File tree

3 files changed

+192
-5
lines changed

3 files changed

+192
-5
lines changed

app/src/google/java/com/geeksville/mesh/ui/map/MapView.kt

Lines changed: 185 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,27 +17,44 @@
1717

1818
package com.geeksville.mesh.ui.map
1919

20+
import android.content.Intent
2021
import android.graphics.Canvas
2122
import android.graphics.Paint
23+
import android.net.Uri
2224
import android.util.Log
25+
import androidx.activity.compose.rememberLauncherForActivityResult
26+
import androidx.activity.result.contract.ActivityResultContracts
2327
import androidx.appcompat.app.AppCompatDelegate
28+
import androidx.compose.foundation.clickable
2429
import androidx.compose.foundation.isSystemInDarkTheme
2530
import androidx.compose.foundation.layout.Arrangement
2631
import androidx.compose.foundation.layout.Box
2732
import androidx.compose.foundation.layout.Column
2833
import androidx.compose.foundation.layout.fillMaxSize
2934
import androidx.compose.foundation.layout.padding
35+
import androidx.compose.foundation.lazy.LazyColumn
36+
import androidx.compose.foundation.lazy.items
3037
import androidx.compose.material.icons.Icons
38+
import androidx.compose.material.icons.filled.Delete
3139
import androidx.compose.material.icons.filled.Favorite
3240
import androidx.compose.material.icons.filled.Place
41+
import androidx.compose.material.icons.filled.Visibility
42+
import androidx.compose.material.icons.filled.VisibilityOff
3343
import androidx.compose.material.icons.outlined.Layers
44+
import androidx.compose.material.icons.outlined.Map
3445
import androidx.compose.material.icons.outlined.RadioButtonUnchecked
3546
import androidx.compose.material.icons.outlined.Tune
47+
import androidx.compose.material3.AlertDialog
3648
import androidx.compose.material3.Checkbox
3749
import androidx.compose.material3.DropdownMenu
3850
import androidx.compose.material3.DropdownMenuItem
51+
import androidx.compose.material3.HorizontalDivider
3952
import androidx.compose.material3.Icon
53+
import androidx.compose.material3.IconButton
54+
import androidx.compose.material3.ListItem
55+
import androidx.compose.material3.ListItemDefaults
4056
import androidx.compose.material3.Text
57+
import androidx.compose.material3.TextButton
4158
import androidx.compose.runtime.Composable
4259
import androidx.compose.runtime.LaunchedEffect
4360
import androidx.compose.runtime.getValue
@@ -47,6 +64,7 @@ import androidx.compose.runtime.setValue
4764
import androidx.compose.ui.Alignment
4865
import androidx.compose.ui.Modifier
4966
import androidx.compose.ui.graphics.Color
67+
import androidx.compose.ui.platform.LocalContext
5068
import androidx.compose.ui.res.stringResource
5169
import androidx.compose.ui.unit.dp
5270
import androidx.core.graphics.createBitmap
@@ -72,6 +90,7 @@ import com.google.maps.android.clustering.view.DefaultClusterRenderer
7290
import com.google.maps.android.compose.Circle
7391
import com.google.maps.android.compose.ComposeMapColorScheme
7492
import com.google.maps.android.compose.GoogleMap
93+
import com.google.maps.android.compose.MapEffect
7594
import com.google.maps.android.compose.MapProperties
7695
import com.google.maps.android.compose.MapType
7796
import com.google.maps.android.compose.MapUiSettings
@@ -87,18 +106,34 @@ import com.google.maps.android.compose.widgets.DisappearingScaleBar
87106
@Composable
88107
fun MapView(
89108
uiViewModel: UIViewModel,
109+
mapViewModel: MapViewModel,
90110
navigateToNodeDetails: (Int) -> Unit,
91111
) {
112+
val context = LocalContext.current
113+
val mapLayers by mapViewModel.mapLayers.collectAsStateWithLifecycle()
114+
var showLayerManagementDialog by remember { mutableStateOf(false) }
115+
116+
val kmlFilePickerLauncher = rememberLauncherForActivityResult(
117+
contract = ActivityResultContracts.StartActivityForResult()
118+
) { result ->
119+
if (result.resultCode == android.app.Activity.RESULT_OK) {
120+
result.data?.data?.let { uri ->
121+
val fileName = uri.getFileName(context)
122+
mapViewModel.addMapLayer(uri, fileName)
123+
}
124+
}
125+
}
126+
92127
var mapFilterMenuExpanded by remember { mutableStateOf(false) }
93128
val mapFilterState by uiViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
94129
val ourNodeInfo by uiViewModel.ourNodeInfo.collectAsStateWithLifecycle()
95130
var editingWaypoint by remember { mutableStateOf<MeshProtos.Waypoint?>(null) }
96131

97132
var selectedMapType by remember { mutableStateOf(MapType.NORMAL) }
98133
var mapTypeMenuExpanded by remember { mutableStateOf(false) }
99-
val defaultLatLng = LatLng(39.8283, -98.5795)
134+
val defaultLatLng = LatLng(40.7871508066057, -119.2041344866371)
100135
val cameraPositionState = rememberCameraPositionState {
101-
position = CameraPosition.fromLatLngZoom(defaultLatLng, 3f)
136+
position = CameraPosition.fromLatLngZoom(defaultLatLng, 7f)
102137
}
103138

104139
val allNodes by uiViewModel.filteredNodeList.collectAsStateWithLifecycle()
@@ -238,11 +273,23 @@ fun MapView(
238273
} else {
239274
// Optionally show a toast that it's locked by someone else
240275
}
241-
242276
}
243277
)
244278
}
279+
MapEffect(mapLayers) { map ->
280+
mapLayers.forEach { layerItem ->
281+
mapViewModel.loadKmlLayerIfNeeded(map, layerItem)
282+
?.let { kmlLayer -> // Combine let with ?.
283+
if (layerItem.isVisible && !kmlLayer.isLayerOnMap) {
284+
kmlLayer.addLayerToMap()
285+
} else if (!layerItem.isVisible && kmlLayer.isLayerOnMap) {
286+
kmlLayer.removeLayerFromMap()
287+
}
288+
}
289+
}
290+
}
245291
}
292+
246293
DisappearingScaleBar(
247294
cameraPositionState = cameraPositionState
248295
)
@@ -278,7 +325,7 @@ fun MapView(
278325

279326
Column(
280327
modifier = Modifier
281-
.align(Alignment.CenterEnd)
328+
.align(Alignment.TopEnd)
282329
.padding(16.dp),
283330
horizontalAlignment = Alignment.End,
284331
verticalArrangement = Arrangement.spacedBy(8.dp)
@@ -299,7 +346,7 @@ fun MapView(
299346

300347
Box {
301348
MapButton(
302-
icon = Icons.Outlined.Layers,
349+
icon = Icons.Outlined.Map,
303350
contentDescription = stringResource(id = R.string.map_tile_source),
304351
onClick = { mapTypeMenuExpanded = true }
305352
)
@@ -312,6 +359,38 @@ fun MapView(
312359
}
313360
)
314361
}
362+
363+
MapButton( // Add KML Layer Button
364+
icon = Icons.Outlined.Layers,
365+
contentDescription = stringResource(id = R.string.manage_map_layers),
366+
onClick = { showLayerManagementDialog = true }
367+
)
368+
}
369+
if (showLayerManagementDialog) {
370+
LayerManagementDialog(
371+
mapLayers = mapLayers,
372+
onDismissRequest = { showLayerManagementDialog = false },
373+
onAddLayerClicked = {
374+
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
375+
addCategory(Intent.CATEGORY_OPENABLE)
376+
type = "*/*" // Allow all file types initially
377+
// More specific MIME types for KML/KMZ
378+
val mimeTypes = arrayOf(
379+
"application/vnd.google-earth.kml+xml",
380+
"application/vnd.google-earth.kmz"
381+
)
382+
putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
383+
}
384+
kmlFilePickerLauncher.launch(intent)
385+
// showLayerManagementDialog = false // Optionally dismiss after clicking add
386+
},
387+
onToggleVisibility = { layerId ->
388+
mapViewModel.toggleLayerVisibility(
389+
layerId
390+
)
391+
},
392+
onRemoveLayer = { layerId -> mapViewModel.removeMapLayer(layerId) }
393+
)
315394
}
316395
}
317396
}
@@ -452,3 +531,104 @@ private fun unicodeEmojiToBitmap(icon: Int): BitmapDescriptor {
452531
)
453532
return BitmapDescriptorFactory.fromBitmap(bitmap)
454533
}
534+
535+
@Composable
536+
fun LayerManagementDialog(
537+
mapLayers: List<MapLayerItem>,
538+
onDismissRequest: () -> Unit,
539+
onAddLayerClicked: () -> Unit,
540+
onToggleVisibility: (String) -> Unit,
541+
onRemoveLayer: (String) -> Unit
542+
) {
543+
AlertDialog(
544+
onDismissRequest = onDismissRequest,
545+
title = { Text(stringResource(R.string.map_layers_title)) },
546+
text = {
547+
if (mapLayers.isEmpty()) {
548+
Text(stringResource(R.string.no_map_layers_loaded))
549+
} else {
550+
LazyColumn {
551+
items(mapLayers, key = { it.id }) { layer ->
552+
ListItem(
553+
headlineContent = {
554+
Text(
555+
layer.name
556+
)
557+
},
558+
supportingContent = {
559+
Text(
560+
layer.uri.lastPathSegment ?: "Unknown source", maxLines = 1
561+
)
562+
},
563+
leadingContent = {
564+
Icon(
565+
imageVector = if (layer.isVisible) {
566+
Icons.Filled.Visibility
567+
} else {
568+
Icons.Filled.VisibilityOff
569+
},
570+
contentDescription = if (layer.isVisible) "Visible" else "Hidden",
571+
modifier = Modifier.clickable { onToggleVisibility(layer.id) }
572+
)
573+
},
574+
trailingContent = {
575+
IconButton(onClick = { onRemoveLayer(layer.id) }) {
576+
Icon(
577+
Icons.Filled.Delete,
578+
contentDescription = "Remove Layer"
579+
)
580+
}
581+
},
582+
colors = ListItemDefaults.colors(
583+
containerColor = if (layer.isVisible) {
584+
Color.Transparent
585+
} else {
586+
Color.Gray.copy(
587+
alpha = 0.2f
588+
)
589+
}
590+
)
591+
)
592+
HorizontalDivider()
593+
}
594+
}
595+
}
596+
},
597+
confirmButton = {
598+
TextButton(onClick = onAddLayerClicked) {
599+
Text(stringResource(R.string.add_layer_button))
600+
}
601+
},
602+
dismissButton = {
603+
TextButton(onClick = onDismissRequest) {
604+
Text(stringResource(R.string.close))
605+
}
606+
}
607+
)
608+
}
609+
610+
fun Uri.getFileName(context: android.content.Context): String? {
611+
var result: String? = null
612+
if (scheme == "content") {
613+
val cursor = context.contentResolver.query(this, null, null, null, null)
614+
try {
615+
if (cursor != null && cursor.moveToFirst()) {
616+
val displayNameIndex =
617+
cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
618+
if (displayNameIndex != -1) {
619+
result = cursor.getString(displayNameIndex)
620+
}
621+
}
622+
} finally {
623+
cursor?.close()
624+
}
625+
}
626+
if (result == null) {
627+
result = path
628+
val cut = result?.lastIndexOf('/')
629+
if (cut != -1 && cut != null) {
630+
result = result.substring(cut + 1)
631+
}
632+
}
633+
return result
634+
}

app/src/main/java/com/geeksville/mesh/navigation/MapRoutes.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
package com.geeksville.mesh.navigation
1919

20+
import androidx.hilt.navigation.compose.hiltViewModel
2021
import androidx.navigation.NavGraphBuilder
2122
import androidx.navigation.NavHostController
2223
import androidx.navigation.compose.composable
@@ -36,6 +37,7 @@ fun NavGraphBuilder.mapGraph(
3637
composable<MapRoutes.Map> {
3738
MapView(
3839
uiViewModel = uiViewModel,
40+
mapViewModel = hiltViewModel(),
3941
navigateToNodeDetails = {
4042
navController.navigate(NodesRoutes.NodeDetail(it))
4143
},

app/src/main/res/values/strings.xml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -711,4 +711,9 @@
711711
<string name="map_type_satellite">Satellite</string>
712712
<string name="map_type_terrain">Terrain</string>
713713
<string name="map_type_hybrid">Hybrid</string>
714+
<string name="manage_map_layers">Manage Map Layers</string>
715+
<string name="map_layers_title">Map Layers</string>
716+
<string name="no_map_layers_loaded">No custom layers loaded.</string>
717+
<string name="add_layer_button">Add Layer</string>
718+
<string name="kml_feature_info">KML Feature Information</string>
714719
</resources>

0 commit comments

Comments
 (0)