Add SDK map module and migrate map fetching to Apollo with Hilt wiring.
This includes the new sdk module, MapService + mapper implementation, Apollo provider module, Hilt app integration, and the project .gitignore update. Made-with: Cursor
This commit is contained in:
@@ -1,24 +0,0 @@
|
||||
package org.db3.airmq
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ExampleInstrumentedTest {
|
||||
@Test
|
||||
fun useAppContext() {
|
||||
// Context of the app under test.
|
||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
assertEquals("org.db3.airmq", appContext.packageName)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:name=".AirMQApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
query MapLocations {
|
||||
locations {
|
||||
_id
|
||||
name
|
||||
city
|
||||
latitude
|
||||
longitude
|
||||
isOnline
|
||||
}
|
||||
}
|
||||
7
app/src/main/kotlin/org/db3/airmq/AirMQApplication.kt
Normal file
7
app/src/main/kotlin/org/db3/airmq/AirMQApplication.kt
Normal file
@@ -0,0 +1,7 @@
|
||||
package org.db3.airmq
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class AirMQApplication : Application()
|
||||
@@ -6,16 +6,18 @@ import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.ui.Modifier
|
||||
import org.db3.airmq.features.navigation.AirMqNavGraph
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import org.db3.airmq.features.navigation.AirMQNavGraph
|
||||
import org.db3.airmq.ui.theme.AirMQTheme
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
AirMQTheme {
|
||||
AirMqNavGraph(modifier = Modifier.fillMaxSize())
|
||||
AirMQNavGraph(modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
package org.db3.airmq.features.map
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.BufferedReader
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
class AirMqMapApi {
|
||||
fun fetchMapItems(): List<MapItem> {
|
||||
val payload = JSONObject()
|
||||
.put("query", LOCATIONS_QUERY)
|
||||
.put("variables", JSONObject())
|
||||
|
||||
val connection = (URL(API_URL).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "POST"
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 15_000
|
||||
setRequestProperty("Content-Type", "application/json")
|
||||
doInput = true
|
||||
doOutput = true
|
||||
}
|
||||
|
||||
return try {
|
||||
OutputStreamWriter(connection.outputStream).use { writer ->
|
||||
writer.write(payload.toString())
|
||||
writer.flush()
|
||||
}
|
||||
|
||||
val responseCode = connection.responseCode
|
||||
val body = readResponseBody(connection, responseCode)
|
||||
if (responseCode !in 200..299) {
|
||||
throw IllegalStateException("Map API request failed: HTTP $responseCode")
|
||||
}
|
||||
|
||||
parseMapItems(body)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseMapItems(rawJson: String): List<MapItem> {
|
||||
val root = JSONObject(rawJson)
|
||||
val errors = root.optJSONArray("errors")
|
||||
if (errors != null && errors.length() > 0) {
|
||||
throw IllegalStateException(firstGraphQlError(errors))
|
||||
}
|
||||
|
||||
val data = root.optJSONObject("data")
|
||||
?: throw IllegalStateException("Map API response is missing data")
|
||||
val locations = data.optJSONArray("locations") ?: JSONArray()
|
||||
|
||||
return buildList {
|
||||
for (index in 0 until locations.length()) {
|
||||
val location = locations.optJSONObject(index) ?: continue
|
||||
val id = location.optString("_id")
|
||||
val latitude = location.optDouble("latitude", Double.NaN)
|
||||
val longitude = location.optDouble("longitude", Double.NaN)
|
||||
if (id.isBlank() || latitude.isNaN() || longitude.isNaN()) {
|
||||
continue
|
||||
}
|
||||
|
||||
add(
|
||||
MapItem(
|
||||
id = id,
|
||||
title = location.optString("name").ifBlank { id },
|
||||
city = location.optString("city").ifBlank { null },
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
isOnline = location.optBoolean("isOnline", false)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun readResponseBody(connection: HttpURLConnection, responseCode: Int): String {
|
||||
val source = if (responseCode in 200..299) {
|
||||
connection.inputStream
|
||||
} else {
|
||||
connection.errorStream ?: connection.inputStream
|
||||
}
|
||||
|
||||
return source.bufferedReader().use(BufferedReader::readText)
|
||||
}
|
||||
|
||||
private fun firstGraphQlError(errors: JSONArray): String {
|
||||
val firstError = errors.optJSONObject(0)
|
||||
return firstError?.optString("message").orEmpty().ifBlank {
|
||||
"Map API returned an unknown GraphQL error"
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val API_URL = "https://api.airmq.cc"
|
||||
private const val LOCATIONS_QUERY = """
|
||||
query MapLocations {
|
||||
locations {
|
||||
_id
|
||||
name
|
||||
city
|
||||
latitude
|
||||
longitude
|
||||
isOnline
|
||||
}
|
||||
}
|
||||
"""
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package org.db3.airmq.features.map
|
||||
|
||||
data class MapItem(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val city: String?,
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val isOnline: Boolean
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.db3.airmq.features.map
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -12,6 +13,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -24,8 +26,9 @@ import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import org.db3.airmq.features.common.AirMqContainedButton
|
||||
import org.db3.airmq.sdk.map.domain.MapItem
|
||||
import org.osmdroid.config.Configuration
|
||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||
import org.osmdroid.util.GeoPoint
|
||||
@@ -34,9 +37,42 @@ import org.osmdroid.views.overlay.Marker
|
||||
|
||||
@Composable
|
||||
fun MapScreen(
|
||||
viewModel: MapViewModel = viewModel()
|
||||
viewModel: MapViewModel = hiltViewModel()
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(uiState) {
|
||||
Toast.makeText(context, uiState.items.count().toString(), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AirMQMap(uiState.items)
|
||||
|
||||
if (uiState.isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.2f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(40.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.errorMessage != null) {
|
||||
ErrorOverlay(
|
||||
message = uiState.errorMessage ?: "Unknown error",
|
||||
onRetry = viewModel::refresh
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AirMQMap(
|
||||
items: List<MapItem>
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
@@ -65,47 +101,27 @@ fun MapScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { mapView },
|
||||
update = { map ->
|
||||
map.overlays.removeAll { it is Marker }
|
||||
uiState.items.forEach { item ->
|
||||
val marker = Marker(map).apply {
|
||||
position = GeoPoint(item.latitude, item.longitude)
|
||||
title = listOfNotNull(item.title, item.city).joinToString(" - ")
|
||||
subDescription = if (item.isOnline) "Online" else "Offline"
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
}
|
||||
map.overlays.add(marker)
|
||||
AndroidView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
factory = { mapView },
|
||||
update = { map ->
|
||||
map.overlays.removeAll { it is Marker }
|
||||
items.forEach { item ->
|
||||
val marker = Marker(map).apply {
|
||||
position = GeoPoint(item.latitude, item.longitude)
|
||||
title = listOfNotNull(item.title, item.city).joinToString(" - ")
|
||||
subDescription = if (item.isOnline) "Online" else "Offline"
|
||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||
}
|
||||
|
||||
uiState.items.firstOrNull()?.let { first ->
|
||||
map.controller.animateTo(GeoPoint(first.latitude, first.longitude))
|
||||
}
|
||||
map.invalidate()
|
||||
map.overlays.add(marker)
|
||||
}
|
||||
)
|
||||
|
||||
if (uiState.isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.2f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(40.dp))
|
||||
items.firstOrNull()?.let { first ->
|
||||
map.controller.animateTo(GeoPoint(first.latitude, first.longitude))
|
||||
}
|
||||
map.invalidate()
|
||||
}
|
||||
|
||||
if (uiState.errorMessage != null) {
|
||||
ErrorOverlay(
|
||||
message = uiState.errorMessage ?: "Unknown error",
|
||||
onRetry = viewModel::refresh
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.db3.airmq.features.map
|
||||
|
||||
import org.db3.airmq.sdk.map.domain.MapItem
|
||||
|
||||
data class MapUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val items: List<MapItem> = emptyList(),
|
||||
|
||||
@@ -2,14 +2,18 @@ package org.db3.airmq.features.map
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.db3.airmq.sdk.map.MapService
|
||||
|
||||
class MapViewModel(
|
||||
private val mapApi: AirMqMapApi = AirMqMapApi()
|
||||
@HiltViewModel
|
||||
class MapViewModel @Inject constructor(
|
||||
private val mapService: MapService
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(MapUiState(isLoading = true))
|
||||
@@ -22,7 +26,7 @@ class MapViewModel(
|
||||
fun refresh() {
|
||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val result = runCatching { mapApi.fetchMapItems() }
|
||||
val result = runCatching { mapService.fetchMapItems() }
|
||||
_uiState.value = result.fold(
|
||||
onSuccess = { items ->
|
||||
MapUiState(
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package org.db3.airmq
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
class ExampleUnitTest {
|
||||
@Test
|
||||
fun addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user