Skip to content

Commit 4b00f6d

Browse files
committed
Add debug tree map
1 parent b7f7eff commit 4b00f6d

File tree

6 files changed

+184
-93
lines changed

6 files changed

+184
-93
lines changed

app/build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,9 @@ dependencies {
211211
// MapLibre
212212
implementation libs.maplibre.android.sdk
213213

214+
// Image Loading
215+
implementation libs.coil.compose
216+
214217
// Kotlin
215218
implementation libs.kotlin.stdlib.jdk7
216219
implementation libs.kotlinx.coroutines.core

app/src/main/java/org/greenstand/android/TreeTracker/map/LibreMap.kt

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,20 @@ import androidx.compose.ui.platform.LocalLifecycleOwner
1010
import androidx.compose.ui.viewinterop.AndroidView
1111
import androidx.lifecycle.Lifecycle
1212
import androidx.lifecycle.LifecycleEventObserver
13-
import org.maplibre.android.annotations.MarkerOptions
13+
import com.google.gson.JsonArray
14+
import com.google.gson.JsonObject
1415
import org.maplibre.android.camera.CameraPosition
1516
import org.maplibre.android.camera.CameraUpdateFactory
1617
import org.maplibre.android.geometry.LatLng
1718
import org.maplibre.android.geometry.LatLngBounds
19+
import org.maplibre.android.location.LocationComponentActivationOptions
20+
import org.maplibre.android.location.LocationComponentOptions
1821
import org.maplibre.android.maps.MapView
22+
import org.maplibre.android.style.layers.CircleLayer
23+
import org.maplibre.android.style.layers.PropertyFactory
24+
import org.maplibre.android.style.sources.GeoJsonSource
1925

26+
@androidx.annotation.RequiresPermission(allOf = [android.Manifest.permission.ACCESS_FINE_LOCATION, android.Manifest.permission.ACCESS_COARSE_LOCATION])
2027
@Composable
2128
fun LibreMap(
2229
markers: List<MapMarker>,
@@ -30,13 +37,25 @@ fun LibreMap(
3037
val mapView = remember {
3138
MapView(context).apply {
3239
getMapAsync { mapLibreMap ->
33-
mapLibreMap.setStyle(styleUrl) {
40+
mapLibreMap.setStyle(styleUrl) { style ->
3441
// Set initial camera position (centered on equator with moderate zoom)
3542
val initialPosition = CameraPosition.Builder()
3643
.target(LatLng(0.0, 0.0))
3744
.zoom(2.0)
3845
.build()
3946
mapLibreMap.cameraPosition = initialPosition
47+
val locationComponent = mapLibreMap.locationComponent
48+
locationComponent.activateLocationComponent(
49+
LocationComponentActivationOptions
50+
.builder(context, style)
51+
.locationComponentOptions(
52+
LocationComponentOptions.builder(context)
53+
.pulseEnabled(true)
54+
.build()
55+
)
56+
.build()
57+
)
58+
locationComponent.isLocationComponentEnabled = true
4059
}
4160
}
4261
}
@@ -67,18 +86,50 @@ fun LibreMap(
6786
update = { view ->
6887
view.getMapAsync { mapLibreMap ->
6988
mapLibreMap.getStyle { style ->
70-
// Clear existing markers
71-
mapLibreMap.clear()
89+
// Remove existing source and layer if they exist
90+
style.getLayer("tree-markers-layer")?.let { style.removeLayer(it) }
91+
style.getSource("tree-markers-source")?.let { style.removeSource(it) }
7292

73-
// Add markers in bulk
93+
// Add markers in bulk as GeoJSON
7494
if (markers.isNotEmpty()) {
75-
val markerOptions = markers.map { marker ->
76-
MarkerOptions()
77-
.position(LatLng(marker.latitude, marker.longitude))
95+
// Create GeoJSON FeatureCollection
96+
val features = JsonArray()
97+
markers.forEach { marker ->
98+
val feature = JsonObject().apply {
99+
addProperty("type", "Feature")
100+
add("geometry", JsonObject().apply {
101+
addProperty("type", "Point")
102+
add("coordinates", JsonArray().apply {
103+
add(marker.longitude)
104+
add(marker.latitude)
105+
})
106+
})
107+
add("properties", JsonObject().apply {
108+
addProperty("id", marker.id)
109+
})
110+
}
111+
features.add(feature)
112+
}
113+
114+
val featureCollection = JsonObject().apply {
115+
addProperty("type", "FeatureCollection")
116+
add("features", features)
78117
}
79118

80-
// Add all markers at once
81-
mapLibreMap.addMarkers(markerOptions)
119+
// Add GeoJSON source
120+
val source = GeoJsonSource("tree-markers-source", featureCollection.toString())
121+
style.addSource(source)
122+
123+
// Add circle layer with green dots
124+
val circleLayer = CircleLayer("tree-markers-layer", "tree-markers-source").apply {
125+
setProperties(
126+
PropertyFactory.circleRadius(8f),
127+
PropertyFactory.circleColor("#4CAF50"),
128+
PropertyFactory.circleStrokeWidth(2f),
129+
PropertyFactory.circleStrokeColor("#FFFFFF")
130+
)
131+
}
132+
style.addLayer(circleLayer)
82133

83134
// Handle selected marker zoom
84135
if (selectedMarkerId != null) {

app/src/main/java/org/greenstand/android/TreeTracker/map/MapScreen.kt

Lines changed: 93 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,15 @@
1515
*/
1616
package org.greenstand.android.TreeTracker.map
1717

18-
import androidx.compose.foundation.Image
18+
import androidx.compose.animation.core.animateDpAsState
19+
import androidx.compose.animation.core.tween
1920
import androidx.compose.foundation.clickable
2021
import androidx.compose.foundation.layout.Arrangement
2122
import androidx.compose.foundation.layout.Box
2223
import androidx.compose.foundation.layout.Column
2324
import androidx.compose.foundation.layout.PaddingValues
2425
import androidx.compose.foundation.layout.Spacer
26+
import androidx.compose.foundation.layout.fillMaxHeight
2527
import androidx.compose.foundation.layout.fillMaxSize
2628
import androidx.compose.foundation.layout.fillMaxWidth
2729
import androidx.compose.foundation.layout.height
@@ -34,27 +36,30 @@ import androidx.compose.foundation.lazy.items
3436
import androidx.compose.foundation.lazy.rememberLazyListState
3537
import androidx.compose.foundation.shape.RoundedCornerShape
3638
import androidx.compose.material.Card
37-
import androidx.compose.material.Scaffold
39+
import androidx.compose.material.Icon
40+
import androidx.compose.material.IconButton
41+
import androidx.compose.material.Surface
3842
import androidx.compose.material.Text
43+
import androidx.compose.material.icons.Icons
44+
import androidx.compose.material.icons.automirrored.filled.ArrowBack
3945
import androidx.compose.runtime.Composable
40-
import androidx.compose.runtime.DisposableEffect
4146
import androidx.compose.runtime.LaunchedEffect
4247
import androidx.compose.runtime.collectAsState
4348
import androidx.compose.runtime.getValue
4449
import androidx.compose.runtime.rememberCoroutineScope
4550
import androidx.compose.ui.Alignment
4651
import androidx.compose.ui.Modifier
52+
import androidx.compose.ui.graphics.Color
4753
import androidx.compose.ui.layout.ContentScale
4854
import androidx.compose.ui.platform.LocalConfiguration
4955
import androidx.compose.ui.platform.LocalContext
5056
import androidx.compose.ui.platform.LocalDensity
5157
import androidx.compose.ui.res.painterResource
52-
import androidx.compose.ui.res.stringResource
5358
import androidx.compose.ui.text.font.FontWeight
54-
import androidx.compose.ui.text.style.TextAlign
5559
import androidx.compose.ui.text.style.TextOverflow
5660
import androidx.compose.ui.unit.dp
5761
import androidx.lifecycle.viewmodel.compose.viewModel
62+
import coil.compose.AsyncImage
5863
import kotlinx.coroutines.launch
5964
import kotlinx.datetime.Instant
6065
import kotlinx.datetime.TimeZone
@@ -64,10 +69,9 @@ import org.greenstand.android.TreeTracker.R
6469
import org.greenstand.android.TreeTracker.root.LocalNavHostController
6570
import org.greenstand.android.TreeTracker.root.LocalViewModelFactory
6671
import org.greenstand.android.TreeTracker.theme.CustomTheme
67-
import org.greenstand.android.TreeTracker.view.ActionBar
6872
import org.greenstand.android.TreeTracker.view.AppColors
69-
import org.greenstand.android.TreeTracker.view.ArrowButton
7073
import org.maplibre.android.MapLibre
74+
import java.io.File
7175
import java.time.format.DateTimeFormatter
7276

7377
@Composable
@@ -78,62 +82,53 @@ fun MapScreen(
7882
val context = LocalContext.current
7983
val state by viewModel.state.collectAsState()
8084

81-
DisposableEffect(Unit) {
85+
LaunchedEffect(Unit) {
8286
MapLibre.getInstance(context)
83-
onDispose { }
8487
}
8588

86-
Scaffold(
87-
topBar = {
88-
ActionBar(
89-
modifier = Modifier.statusBarsPadding(),
90-
centerAction = {
91-
Text(
92-
modifier = Modifier.fillMaxWidth(),
93-
text = stringResource(id = R.string.map_title),
94-
color = AppColors.Green,
95-
fontWeight = FontWeight.Bold,
96-
style = CustomTheme.typography.large,
97-
textAlign = TextAlign.Center,
98-
)
99-
},
100-
)
101-
},
102-
bottomBar = {
103-
ActionBar(
104-
modifier = Modifier.navigationBarsPadding(),
105-
leftAction = {
106-
ArrowButton(isLeft = true) {
107-
navController.popBackStack()
108-
}
109-
},
110-
)
111-
}
112-
) { paddingValues ->
113-
Box(
89+
Box(
90+
modifier = Modifier.fillMaxSize()
91+
) {
92+
LibreMap(
93+
markers = state.markers,
94+
selectedMarkerId = state.selectedMarkerId,
95+
styleUrl = "https://tiles.openfreemap.org/styles/liberty"
96+
)
97+
98+
// Back button in top left corner
99+
Surface(
114100
modifier = Modifier
115-
.fillMaxSize()
116-
.padding(paddingValues)
101+
.align(Alignment.TopStart)
102+
.padding(start = 16.dp, top = 4.dp)
103+
.statusBarsPadding(),
104+
elevation = 4.dp,
105+
shape = RoundedCornerShape(24.dp),
106+
color = Color.White
117107
) {
118-
LibreMap(
119-
markers = state.markers,
120-
selectedMarkerId = state.selectedMarkerId
121-
)
122-
123-
// Carousel at the bottom
124-
if (state.markers.isNotEmpty()) {
125-
TreeMarkerCarousel(
126-
modifier = Modifier
127-
.align(Alignment.BottomCenter)
128-
.fillMaxWidth(),
129-
markers = state.markers,
130-
selectedMarkerId = state.selectedMarkerId,
131-
onMarkerClick = { marker ->
132-
viewModel.selectMarker(marker.id)
133-
}
108+
IconButton(
109+
onClick = { navController.popBackStack() }
110+
) {
111+
Icon(
112+
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
113+
contentDescription = "Back",
134114
)
135115
}
136116
}
117+
118+
// Carousel overlay at bottom
119+
if (state.markers.isNotEmpty()) {
120+
TreeMarkerCarousel(
121+
modifier = Modifier
122+
.align(Alignment.BottomCenter)
123+
.fillMaxWidth()
124+
.navigationBarsPadding(),
125+
markers = state.markers,
126+
selectedMarkerId = state.selectedMarkerId,
127+
onMarkerClick = { marker ->
128+
viewModel.selectMarker(marker.id)
129+
}
130+
)
131+
}
137132
}
138133
}
139134

@@ -149,11 +144,10 @@ fun TreeMarkerCarousel(
149144
val configuration = LocalConfiguration.current
150145
val density = LocalDensity.current
151146

152-
// Calculate centering offset
153-
val cardWidth = 200.dp
147+
// Calculate centering offset based on selected card size
154148
val screenWidth = configuration.screenWidthDp.dp
155149
val centerOffset = with(density) {
156-
((screenWidth - cardWidth) / 2).toPx().toInt()
150+
((screenWidth - CARD_WIDTH_SELECTED) / 2).toPx().toInt()
157151
}
158152

159153
// Scroll to selected marker when it changes
@@ -173,10 +167,11 @@ fun TreeMarkerCarousel(
173167
}
174168

175169
LazyRow(
170+
modifier = modifier.height(CARD_HEIGHT_SELECTED),
176171
state = listState,
177-
modifier = modifier,
178172
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
179-
horizontalArrangement = Arrangement.spacedBy(12.dp)
173+
horizontalArrangement = Arrangement.spacedBy(12.dp),
174+
verticalAlignment = Alignment.CenterVertically,
180175
) {
181176
items(markers, key = { it.id }) { marker ->
182177
TreeMarkerCard(
@@ -195,31 +190,50 @@ fun TreeMarkerCard(
195190
onClick: () -> Unit,
196191
modifier: Modifier = Modifier
197192
) {
193+
// Animate width and height by 10% when selected
194+
val cardWidth by animateDpAsState(
195+
targetValue = if (isSelected) CARD_WIDTH_SELECTED else CARD_WIDTH_NORMAL,
196+
animationSpec = tween(durationMillis = 300),
197+
label = "card_width"
198+
)
199+
200+
val cardHeight by animateDpAsState(
201+
targetValue = if (isSelected) CARD_HEIGHT_SELECTED else CARD_HEIGHT_NORMAL,
202+
animationSpec = tween(durationMillis = 300),
203+
label = "card_height"
204+
)
205+
198206
Card(
199207
modifier = modifier
200-
.width(200.dp)
208+
.width(cardWidth)
209+
.height(cardHeight)
201210
.clickable { onClick() },
202211
elevation = if (isSelected) 8.dp else 4.dp,
203-
backgroundColor = AppColors.LightGray,
212+
backgroundColor = Color.White,
204213
shape = RoundedCornerShape(8.dp)
205214
) {
206-
Column {
207-
// Placeholder image
208-
Image(
209-
painter = painterResource(id = R.drawable.yellow_leafs_placeholder),
210-
contentDescription = "Tree placeholder",
215+
Column(
216+
modifier = Modifier.fillMaxHeight()
217+
) {
218+
// Tree image loaded from file path - takes 2/3 of card space
219+
AsyncImage(
220+
model = marker.imagePath?.let { File(it) },
221+
contentDescription = "Tree image",
211222
modifier = Modifier
212223
.fillMaxWidth()
213-
.height(120.dp),
214-
contentScale = ContentScale.Crop
224+
.weight(2f),
225+
contentScale = ContentScale.Crop,
226+
placeholder = painterResource(id = R.drawable.yellow_leafs_placeholder),
227+
error = painterResource(id = R.drawable.yellow_leafs_placeholder),
228+
fallback = painterResource(id = R.drawable.yellow_leafs_placeholder)
215229
)
216230

231+
// Content section - takes 1/3 of card space
217232
Column(
218-
modifier = Modifier.padding(12.dp)
233+
modifier = Modifier
234+
.weight(1f)
235+
.padding(12.dp)
219236
) {
220-
Spacer(modifier = Modifier.height(8.dp))
221-
222-
// Latitude/Longitude
223237
Text(
224238
text = "Lat: ${String.format("%.6f", marker.latitude)}",
225239
style = CustomTheme.typography.small,
@@ -267,3 +281,9 @@ private fun formatPlantDate(instant: Instant): String {
267281
val formatter = DateTimeFormatter.ofPattern("MMM dd, yyyy")
268282
return javaDateTime.format(formatter)
269283
}
284+
285+
// Card size constants
286+
private val CARD_WIDTH_NORMAL = 200.dp
287+
private val CARD_WIDTH_SELECTED = 220.dp
288+
private val CARD_HEIGHT_NORMAL = 280.dp
289+
private val CARD_HEIGHT_SELECTED = 308.dp

0 commit comments

Comments
 (0)