Skip to content

Commit 8c37f50

Browse files
committed
Add debug tree map
1 parent f8ff800 commit 8c37f50

File tree

14 files changed

+376
-7
lines changed

14 files changed

+376
-7
lines changed

app/build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,9 @@ dependencies {
208208
implementation libs.bundles.compose
209209
implementation libs.accompanist.permissions
210210

211+
// MapLibre
212+
implementation libs.maplibre.android.sdk
213+
211214
// Kotlin
212215
implementation libs.kotlin.stdlib.jdk7
213216
implementation libs.kotlinx.coroutines.core

app/src/main/java/org/greenstand/android/TreeTracker/database/TreeTrackerDAO.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,9 @@ interface TreeTrackerDAO {
187187
@Query("SELECT * FROM tree WHERE _id IN (:ids)")
188188
suspend fun getTreesByIds(ids: List<Long>): List<TreeEntity>
189189

190+
@Query("SELECT * FROM tree")
191+
suspend fun getAllTrees(): List<TreeEntity>
192+
190193
@Query("UPDATE tree_capture SET bundle_id = :bundleId WHERE _id IN (:ids)")
191194
suspend fun updateTreeCapturesBundleIds(ids: List<Long>, bundleId: String)
192195

app/src/main/java/org/greenstand/android/TreeTracker/di/AppModule.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import org.greenstand.android.TreeTracker.dashboard.TreesToSyncHelper
3636
import org.greenstand.android.TreeTracker.devoptions.Configurator
3737
import org.greenstand.android.TreeTracker.devoptions.DevOptionsViewModel
3838
import org.greenstand.android.TreeTracker.languagepicker.LanguagePickerViewModel
39+
import org.greenstand.android.TreeTracker.map.MapViewModel
3940
import org.greenstand.android.TreeTracker.messages.ChatViewModel
4041
import org.greenstand.android.TreeTracker.messages.announcementmessage.AnnouncementViewModel
4142
import org.greenstand.android.TreeTracker.messages.individualmeassagelist.IndividualMessageListViewModel
@@ -128,6 +129,8 @@ val appModule = module {
128129

129130
viewModel { SettingsViewModel(get()) }
130131

132+
viewModel { MapViewModel(get()) }
133+
131134
single { UserRepo(get(), get(), get(), get(), get(), get()) }
132135

133136
factory<TreeCapturer> { CaptureFlowScopeManager.getData().get() }
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
/*
2+
* Copyright 2023 Treetracker
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.greenstand.android.TreeTracker.map
17+
18+
import androidx.compose.foundation.layout.Box
19+
import androidx.compose.foundation.layout.fillMaxSize
20+
import androidx.compose.foundation.layout.navigationBarsPadding
21+
import androidx.compose.foundation.layout.padding
22+
import androidx.compose.foundation.layout.statusBarsPadding
23+
import androidx.compose.material.Scaffold
24+
import androidx.compose.material.Text
25+
import androidx.compose.runtime.Composable
26+
import androidx.compose.runtime.DisposableEffect
27+
import androidx.compose.runtime.collectAsState
28+
import androidx.compose.runtime.getValue
29+
import androidx.compose.runtime.remember
30+
import androidx.compose.ui.Modifier
31+
import androidx.compose.ui.platform.LocalContext
32+
import androidx.compose.ui.platform.LocalLifecycleOwner
33+
import androidx.compose.ui.res.stringResource
34+
import androidx.compose.ui.text.font.FontWeight
35+
import androidx.compose.ui.text.style.TextAlign
36+
import androidx.compose.ui.viewinterop.AndroidView
37+
import androidx.lifecycle.Lifecycle
38+
import androidx.lifecycle.LifecycleEventObserver
39+
import androidx.lifecycle.viewmodel.compose.viewModel
40+
import org.greenstand.android.TreeTracker.R
41+
import org.greenstand.android.TreeTracker.root.LocalNavHostController
42+
import org.greenstand.android.TreeTracker.root.LocalViewModelFactory
43+
import org.greenstand.android.TreeTracker.theme.CustomTheme
44+
import org.greenstand.android.TreeTracker.view.ActionBar
45+
import org.greenstand.android.TreeTracker.view.AppColors
46+
import org.greenstand.android.TreeTracker.view.ArrowButton
47+
import org.maplibre.android.MapLibre
48+
import org.maplibre.android.annotations.MarkerOptions
49+
import org.maplibre.android.camera.CameraPosition
50+
import org.maplibre.android.camera.CameraUpdateFactory
51+
import org.maplibre.android.geometry.LatLng
52+
import org.maplibre.android.geometry.LatLngBounds
53+
import org.maplibre.android.maps.MapView
54+
55+
@Composable
56+
fun MapScreen(
57+
viewModel: MapViewModel = viewModel(factory = LocalViewModelFactory.current)
58+
) {
59+
val navController = LocalNavHostController.current
60+
val context = LocalContext.current
61+
val state by viewModel.state.collectAsState()
62+
63+
// Initialize MapLibre
64+
DisposableEffect(Unit) {
65+
MapLibre.getInstance(context)
66+
onDispose { }
67+
}
68+
69+
Scaffold(
70+
topBar = {
71+
ActionBar(
72+
modifier = Modifier.statusBarsPadding(),
73+
centerAction = {
74+
Text(
75+
text = stringResource(id = R.string.map_title),
76+
color = AppColors.Green,
77+
fontWeight = FontWeight.Bold,
78+
style = CustomTheme.typography.large,
79+
textAlign = TextAlign.Center,
80+
)
81+
},
82+
)
83+
},
84+
bottomBar = {
85+
ActionBar(
86+
modifier = Modifier.navigationBarsPadding(),
87+
leftAction = {
88+
ArrowButton(isLeft = true) {
89+
navController.popBackStack()
90+
}
91+
},
92+
)
93+
}
94+
) { paddingValues ->
95+
Box(
96+
modifier = Modifier
97+
.fillMaxSize()
98+
.padding(paddingValues)
99+
) {
100+
MapLibreMap(markers = state.markers)
101+
}
102+
}
103+
}
104+
105+
@Composable
106+
fun MapLibreMap(
107+
markers: List<MapMarker>,
108+
modifier: Modifier = Modifier,
109+
styleUrl: String = "https://demotiles.maplibre.org/style.json"
110+
) {
111+
val context = LocalContext.current
112+
val lifecycleOwner = LocalLifecycleOwner.current
113+
114+
val mapView = remember {
115+
MapView(context).apply {
116+
getMapAsync { mapLibreMap ->
117+
mapLibreMap.setStyle(styleUrl) {
118+
// Set initial camera position (centered on equator with moderate zoom)
119+
val initialPosition = CameraPosition.Builder()
120+
.target(LatLng(0.0, 0.0))
121+
.zoom(2.0)
122+
.build()
123+
mapLibreMap.cameraPosition = initialPosition
124+
}
125+
}
126+
}
127+
}
128+
129+
// Handle lifecycle events
130+
DisposableEffect(lifecycleOwner) {
131+
val observer = LifecycleEventObserver { _, event ->
132+
when (event) {
133+
Lifecycle.Event.ON_START -> mapView.onStart()
134+
Lifecycle.Event.ON_RESUME -> mapView.onResume()
135+
Lifecycle.Event.ON_PAUSE -> mapView.onPause()
136+
Lifecycle.Event.ON_STOP -> mapView.onStop()
137+
Lifecycle.Event.ON_DESTROY -> mapView.onDestroy()
138+
else -> {}
139+
}
140+
}
141+
lifecycleOwner.lifecycle.addObserver(observer)
142+
onDispose {
143+
lifecycleOwner.lifecycle.removeObserver(observer)
144+
mapView.onDestroy()
145+
}
146+
}
147+
148+
AndroidView(
149+
modifier = modifier.fillMaxSize(),
150+
factory = { mapView },
151+
update = { view ->
152+
view.getMapAsync { mapLibreMap ->
153+
mapLibreMap.getStyle { style ->
154+
// Clear existing markers
155+
mapLibreMap.clear()
156+
157+
// Add markers in bulk
158+
if (markers.isNotEmpty()) {
159+
val markerOptions = markers.map { marker ->
160+
MarkerOptions()
161+
.position(LatLng(marker.latitude, marker.longitude))
162+
}
163+
164+
// Add all markers at once
165+
mapLibreMap.addMarkers(markerOptions)
166+
167+
// Calculate bounds to fit all markers
168+
val boundsBuilder = LatLngBounds.Builder()
169+
markers.forEach { marker ->
170+
boundsBuilder.include(LatLng(marker.latitude, marker.longitude))
171+
}
172+
173+
val bounds = boundsBuilder.build()
174+
val padding = 100 // padding in pixels
175+
176+
// Animate camera to show all markers
177+
mapLibreMap.animateCamera(
178+
CameraUpdateFactory.newLatLngBounds(bounds, padding)
179+
)
180+
}
181+
}
182+
}
183+
}
184+
)
185+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*
2+
* Copyright 2023 Treetracker
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package org.greenstand.android.TreeTracker.map
17+
18+
import androidx.lifecycle.ViewModel
19+
import androidx.lifecycle.viewModelScope
20+
import kotlinx.coroutines.Dispatchers
21+
import kotlinx.coroutines.flow.MutableStateFlow
22+
import kotlinx.coroutines.flow.StateFlow
23+
import kotlinx.coroutines.flow.asStateFlow
24+
import kotlinx.coroutines.launch
25+
import kotlinx.coroutines.withContext
26+
import org.greenstand.android.TreeTracker.database.TreeTrackerDAO
27+
28+
data class MapMarker(
29+
val latitude: Double,
30+
val longitude: Double,
31+
val id: String,
32+
val isUploaded: Boolean,
33+
val note: String,
34+
)
35+
36+
data class MapState(
37+
val markers: List<MapMarker> = emptyList(),
38+
val isLoading: Boolean = true
39+
)
40+
41+
class MapViewModel(
42+
private val dao: TreeTrackerDAO
43+
) : ViewModel() {
44+
45+
private val _state = MutableStateFlow(MapState())
46+
val state: StateFlow<MapState> = _state.asStateFlow()
47+
48+
init {
49+
loadTrees()
50+
}
51+
52+
private fun loadTrees() {
53+
viewModelScope.launch {
54+
_state.value = _state.value.copy(isLoading = true)
55+
56+
val trees = withContext(Dispatchers.IO) {
57+
// Load both legacy tree captures and new trees
58+
val allTreeEntities = dao.getAllTrees()
59+
buildList {
60+
addAll(
61+
allTreeEntities.map { tree ->
62+
MapMarker(
63+
latitude = tree.latitude,
64+
longitude = tree.longitude,
65+
id = "tree_${tree.id}",
66+
isUploaded = tree.uploaded,
67+
note = tree.note,
68+
)
69+
}
70+
)
71+
}
72+
}
73+
74+
_state.value = _state.value.copy(
75+
markers = trees,
76+
isLoading = false
77+
)
78+
}
79+
}
80+
}

app/src/main/java/org/greenstand/android/TreeTracker/models/NavRoute.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import org.greenstand.android.TreeTracker.capture.TreeImageReviewScreen
3232
import org.greenstand.android.TreeTracker.dashboard.DashboardScreen
3333
import org.greenstand.android.TreeTracker.devoptions.DevOptionsRoot
3434
import org.greenstand.android.TreeTracker.languagepicker.LanguageSelectScreen
35+
import org.greenstand.android.TreeTracker.map.MapScreen
3536
import org.greenstand.android.TreeTracker.messages.ChatScreen
3637
import org.greenstand.android.TreeTracker.messages.MessagesUserSelectScreen
3738
import org.greenstand.android.TreeTracker.messages.announcementmessage.AnnouncementScreen
@@ -335,6 +336,15 @@ sealed class NavRoute : KoinComponent {
335336
}
336337
override val route: String = "dev"
337338
}
339+
340+
object Map : NavRoute() {
341+
override val content: @Composable (NavBackStackEntry) -> Unit = {
342+
MapScreen()
343+
}
344+
override val route: String = "map"
345+
346+
fun create() = route
347+
}
338348
}
339349

340350
// Navigation uses '/' to denote params or nesting. Having a param that contains '/'

app/src/main/java/org/greenstand/android/TreeTracker/models/TreeTrackerViewModelFactory.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import org.greenstand.android.TreeTracker.capture.TreeImageReviewViewModel
2121
import org.greenstand.android.TreeTracker.dashboard.DashboardViewModel
2222
import org.greenstand.android.TreeTracker.devoptions.DevOptionsViewModel
2323
import org.greenstand.android.TreeTracker.languagepicker.LanguagePickerViewModel
24+
import org.greenstand.android.TreeTracker.map.MapViewModel
2425
import org.greenstand.android.TreeTracker.messages.individualmeassagelist.IndividualMessageListViewModel
2526
import org.greenstand.android.TreeTracker.orgpicker.AddOrgViewModel
2627
import org.greenstand.android.TreeTracker.orgpicker.OrgPickerViewModel
@@ -57,6 +58,7 @@ class TreeTrackerViewModelFactory : ViewModelProvider.NewInstanceFactory(), Koin
5758
modelClass.isAssignableFrom(SessionNoteViewModel::class.java) -> get<SessionNoteViewModel>() as T
5859
modelClass.isAssignableFrom(DevOptionsViewModel::class.java) -> get<DevOptionsViewModel>() as T
5960
modelClass.isAssignableFrom(SettingsViewModel::class.java) -> get<SettingsViewModel>() as T
61+
modelClass.isAssignableFrom(MapViewModel::class.java) -> get<MapViewModel>() as T
6062
else -> throw RuntimeException("Unable to create instance of ${modelClass.simpleName}. Did you forget to update the TreeTrackerViewModelFactory?")
6163
}
6264
}

app/src/main/java/org/greenstand/android/TreeTracker/root/Host.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ fun Host() {
5757
NavRoute.ProfileSelect,
5858
NavRoute.DeleteProfile,
5959
NavRoute.DevOptions,
60+
NavRoute.Map,
6061
).forEach { addNavRoute(it) }
6162
}
6263
}

app/src/main/java/org/greenstand/android/TreeTracker/settings/SettingsScreen.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,16 @@ fun SettingsScreen() {
9898
)
9999
Divider(color = Color.White)
100100

101+
SettingsItem(
102+
iconResId = R.drawable.map_icon,
103+
titleResId = R.string.map_title,
104+
descriptionResId = R.string.map_description,
105+
onClick = {
106+
navController.navigate(NavRoute.Map.route)
107+
}
108+
)
109+
Divider(color = Color.White)
110+
101111
SettingsItem(
102112
iconResId = R.drawable.privacy_policy, // Replace with your privacy icon
103113
titleResId = R.string.privacy_title,

0 commit comments

Comments
 (0)