17
17
18
18
package com.geeksville.mesh.ui.map
19
19
20
+ import android.content.Intent
20
21
import android.graphics.Canvas
21
22
import android.graphics.Paint
23
+ import android.net.Uri
22
24
import android.util.Log
25
+ import androidx.activity.compose.rememberLauncherForActivityResult
26
+ import androidx.activity.result.contract.ActivityResultContracts
23
27
import androidx.appcompat.app.AppCompatDelegate
28
+ import androidx.compose.foundation.clickable
24
29
import androidx.compose.foundation.isSystemInDarkTheme
25
30
import androidx.compose.foundation.layout.Arrangement
26
31
import androidx.compose.foundation.layout.Box
27
32
import androidx.compose.foundation.layout.Column
28
33
import androidx.compose.foundation.layout.fillMaxSize
29
34
import androidx.compose.foundation.layout.padding
35
+ import androidx.compose.foundation.lazy.LazyColumn
36
+ import androidx.compose.foundation.lazy.items
30
37
import androidx.compose.material.icons.Icons
38
+ import androidx.compose.material.icons.filled.Delete
31
39
import androidx.compose.material.icons.filled.Favorite
32
40
import androidx.compose.material.icons.filled.Place
41
+ import androidx.compose.material.icons.filled.Visibility
42
+ import androidx.compose.material.icons.filled.VisibilityOff
33
43
import androidx.compose.material.icons.outlined.Layers
44
+ import androidx.compose.material.icons.outlined.Map
34
45
import androidx.compose.material.icons.outlined.RadioButtonUnchecked
35
46
import androidx.compose.material.icons.outlined.Tune
47
+ import androidx.compose.material3.AlertDialog
36
48
import androidx.compose.material3.Checkbox
37
49
import androidx.compose.material3.DropdownMenu
38
50
import androidx.compose.material3.DropdownMenuItem
51
+ import androidx.compose.material3.HorizontalDivider
39
52
import androidx.compose.material3.Icon
53
+ import androidx.compose.material3.IconButton
54
+ import androidx.compose.material3.ListItem
55
+ import androidx.compose.material3.ListItemDefaults
40
56
import androidx.compose.material3.Text
57
+ import androidx.compose.material3.TextButton
41
58
import androidx.compose.runtime.Composable
42
59
import androidx.compose.runtime.LaunchedEffect
43
60
import androidx.compose.runtime.getValue
@@ -47,6 +64,7 @@ import androidx.compose.runtime.setValue
47
64
import androidx.compose.ui.Alignment
48
65
import androidx.compose.ui.Modifier
49
66
import androidx.compose.ui.graphics.Color
67
+ import androidx.compose.ui.platform.LocalContext
50
68
import androidx.compose.ui.res.stringResource
51
69
import androidx.compose.ui.unit.dp
52
70
import androidx.core.graphics.createBitmap
@@ -72,6 +90,7 @@ import com.google.maps.android.clustering.view.DefaultClusterRenderer
72
90
import com.google.maps.android.compose.Circle
73
91
import com.google.maps.android.compose.ComposeMapColorScheme
74
92
import com.google.maps.android.compose.GoogleMap
93
+ import com.google.maps.android.compose.MapEffect
75
94
import com.google.maps.android.compose.MapProperties
76
95
import com.google.maps.android.compose.MapType
77
96
import com.google.maps.android.compose.MapUiSettings
@@ -87,18 +106,34 @@ import com.google.maps.android.compose.widgets.DisappearingScaleBar
87
106
@Composable
88
107
fun MapView (
89
108
uiViewModel : UIViewModel ,
109
+ mapViewModel : MapViewModel ,
90
110
navigateToNodeDetails : (Int ) -> Unit ,
91
111
) {
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
+
92
127
var mapFilterMenuExpanded by remember { mutableStateOf(false ) }
93
128
val mapFilterState by uiViewModel.mapFilterStateFlow.collectAsStateWithLifecycle()
94
129
val ourNodeInfo by uiViewModel.ourNodeInfo.collectAsStateWithLifecycle()
95
130
var editingWaypoint by remember { mutableStateOf<MeshProtos .Waypoint ?>(null ) }
96
131
97
132
var selectedMapType by remember { mutableStateOf(MapType .NORMAL ) }
98
133
var mapTypeMenuExpanded by remember { mutableStateOf(false ) }
99
- val defaultLatLng = LatLng (39.8283 , - 98.5795 )
134
+ val defaultLatLng = LatLng (40.7871508066057 , - 119.2041344866371 )
100
135
val cameraPositionState = rememberCameraPositionState {
101
- position = CameraPosition .fromLatLngZoom(defaultLatLng, 3f )
136
+ position = CameraPosition .fromLatLngZoom(defaultLatLng, 7f )
102
137
}
103
138
104
139
val allNodes by uiViewModel.filteredNodeList.collectAsStateWithLifecycle()
@@ -238,11 +273,23 @@ fun MapView(
238
273
} else {
239
274
// Optionally show a toast that it's locked by someone else
240
275
}
241
-
242
276
}
243
277
)
244
278
}
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
+ }
245
291
}
292
+
246
293
DisappearingScaleBar (
247
294
cameraPositionState = cameraPositionState
248
295
)
@@ -278,7 +325,7 @@ fun MapView(
278
325
279
326
Column (
280
327
modifier = Modifier
281
- .align(Alignment .CenterEnd )
328
+ .align(Alignment .TopEnd )
282
329
.padding(16 .dp),
283
330
horizontalAlignment = Alignment .End ,
284
331
verticalArrangement = Arrangement .spacedBy(8 .dp)
@@ -299,7 +346,7 @@ fun MapView(
299
346
300
347
Box {
301
348
MapButton (
302
- icon = Icons .Outlined .Layers ,
349
+ icon = Icons .Outlined .Map ,
303
350
contentDescription = stringResource(id = R .string.map_tile_source),
304
351
onClick = { mapTypeMenuExpanded = true }
305
352
)
@@ -312,6 +359,38 @@ fun MapView(
312
359
}
313
360
)
314
361
}
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
+ )
315
394
}
316
395
}
317
396
}
@@ -452,3 +531,104 @@ private fun unicodeEmojiToBitmap(icon: Int): BitmapDescriptor {
452
531
)
453
532
return BitmapDescriptorFactory .fromBitmap(bitmap)
454
533
}
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
+ }
0 commit comments