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:
@@ -16,6 +16,11 @@ alwaysApply: true
|
|||||||
- When referencing legacy implementation details, copy behavior intentionally into the new project rather than editing old files.
|
- When referencing legacy implementation details, copy behavior intentionally into the new project rather than editing old files.
|
||||||
- If a task appears to require modifying the old project, stop and propose an equivalent change in `airmq-android-2026` instead.
|
- If a task appears to require modifying the old project, stop and propose an equivalent change in `airmq-android-2026` instead.
|
||||||
|
|
||||||
|
## Naming convention
|
||||||
|
|
||||||
|
- Use `AirMQ` everywhere for product naming.
|
||||||
|
- Never use mixed-case variants in text, docs, comments, identifiers, filenames, or symbols.
|
||||||
|
|
||||||
## Baseline architecture and platform stack
|
## Baseline architecture and platform stack
|
||||||
|
|
||||||
Use this stack as the default foundation for all implementation work in `airmq-android-2026`:
|
Use this stack as the default foundation for all implementation work in `airmq-android-2026`:
|
||||||
@@ -23,7 +28,7 @@ Use this stack as the default foundation for all implementation work in `airmq-a
|
|||||||
1. Jetpack Compose for UI.
|
1. Jetpack Compose for UI.
|
||||||
2. Compose Navigation 3 as the in-app navigation system.
|
2. Compose Navigation 3 as the in-app navigation system.
|
||||||
3. Firebase for mobile backend capabilities (for example auth, analytics, crash reporting, messaging, or remote config as needed).
|
3. Firebase for mobile backend capabilities (for example auth, analytics, crash reporting, messaging, or remote config as needed).
|
||||||
4. Google Maps platform for map rendering and map-related interactions.
|
4. OpenStreetMap platform for map rendering and map-related interactions.
|
||||||
5. Apollo GraphQL for GraphQL schema integration, client generation, and network operations.
|
5. Apollo GraphQL for GraphQL schema integration, client generation, and network operations.
|
||||||
|
|
||||||
If a requested change conflicts with this baseline, ask for explicit approval before introducing an alternative.
|
If a requested change conflicts with this baseline, ask for explicit approval before introducing an alternative.
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -7,9 +7,13 @@
|
|||||||
/.idea/workspace.xml
|
/.idea/workspace.xml
|
||||||
/.idea/navEditor.xml
|
/.idea/navEditor.xml
|
||||||
/.idea/assetWizardSettings.xml
|
/.idea/assetWizardSettings.xml
|
||||||
|
.idea/inspectionProfiles/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/build
|
/build
|
||||||
/captures
|
/captures
|
||||||
.externalNativeBuild
|
.externalNativeBuild
|
||||||
.cxx
|
.cxx
|
||||||
local.properties
|
local.properties
|
||||||
|
**/build/
|
||||||
|
.kotlin/
|
||||||
|
/.idea/**/workspace.xml
|
||||||
|
|||||||
1
.idea/gradle.xml
generated
1
.idea/gradle.xml
generated
@@ -9,6 +9,7 @@
|
|||||||
<set>
|
<set>
|
||||||
<option value="$PROJECT_DIR$" />
|
<option value="$PROJECT_DIR$" />
|
||||||
<option value="$PROJECT_DIR$/app" />
|
<option value="$PROJECT_DIR$/app" />
|
||||||
|
<option value="$PROJECT_DIR$/sdk" />
|
||||||
</set>
|
</set>
|
||||||
</option>
|
</option>
|
||||||
</GradleProjectSettings>
|
</GradleProjectSettings>
|
||||||
|
|||||||
2
.idea/misc.xml
generated
2
.idea/misc.xml
generated
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="temurin-21" project-jdk-type="JavaSDK">
|
||||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||||
</component>
|
</component>
|
||||||
<component name="ProjectType">
|
<component name="ProjectType">
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.hilt.android)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,8 +42,15 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
// AirMQ SDK
|
||||||
|
implementation(project(":sdk"))
|
||||||
|
|
||||||
|
// Android
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
@@ -49,13 +58,18 @@ dependencies {
|
|||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
implementation(libs.hilt.android)
|
||||||
|
implementation(libs.androidx.hilt.navigation.compose)
|
||||||
|
ksp(libs.hilt.compiler)
|
||||||
|
|
||||||
|
// Firebase
|
||||||
implementation(platform(libs.firebase.bom))
|
implementation(platform(libs.firebase.bom))
|
||||||
implementation(libs.firebase.analytics)
|
implementation(libs.firebase.analytics)
|
||||||
implementation(libs.firebase.crashlytics)
|
implementation(libs.firebase.crashlytics)
|
||||||
implementation(libs.firebase.messaging)
|
implementation(libs.firebase.messaging)
|
||||||
implementation(libs.google.maps.compose)
|
implementation(libs.osmdroid.android)
|
||||||
implementation(libs.play.services.maps)
|
|
||||||
implementation(libs.apollo.runtime)
|
// Tests
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|||||||
@@ -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"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
android:name=".AirMQApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_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.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.ui.Modifier
|
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
|
import org.db3.airmq.ui.theme.AirMQTheme
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
AirMQTheme {
|
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,5 +1,6 @@
|
|||||||
package org.db3.airmq.features.map
|
package org.db3.airmq.features.map
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@@ -12,6 +13,7 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -24,8 +26,9 @@ import androidx.compose.ui.viewinterop.AndroidView
|
|||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
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.features.common.AirMqContainedButton
|
||||||
|
import org.db3.airmq.sdk.map.domain.MapItem
|
||||||
import org.osmdroid.config.Configuration
|
import org.osmdroid.config.Configuration
|
||||||
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
|
||||||
import org.osmdroid.util.GeoPoint
|
import org.osmdroid.util.GeoPoint
|
||||||
@@ -34,9 +37,42 @@ import org.osmdroid.views.overlay.Marker
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MapScreen(
|
fun MapScreen(
|
||||||
viewModel: MapViewModel = viewModel()
|
viewModel: MapViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
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 context = LocalContext.current
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
|
||||||
@@ -65,47 +101,27 @@ fun MapScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
AndroidView(
|
||||||
AndroidView(
|
modifier = Modifier.fillMaxSize(),
|
||||||
modifier = Modifier.fillMaxSize(),
|
factory = { mapView },
|
||||||
factory = { mapView },
|
update = { map ->
|
||||||
update = { map ->
|
map.overlays.removeAll { it is Marker }
|
||||||
map.overlays.removeAll { it is Marker }
|
items.forEach { item ->
|
||||||
uiState.items.forEach { item ->
|
val marker = Marker(map).apply {
|
||||||
val marker = Marker(map).apply {
|
position = GeoPoint(item.latitude, item.longitude)
|
||||||
position = GeoPoint(item.latitude, item.longitude)
|
title = listOfNotNull(item.title, item.city).joinToString(" - ")
|
||||||
title = listOfNotNull(item.title, item.city).joinToString(" - ")
|
subDescription = if (item.isOnline) "Online" else "Offline"
|
||||||
subDescription = if (item.isOnline) "Online" else "Offline"
|
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
||||||
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
|
|
||||||
}
|
|
||||||
map.overlays.add(marker)
|
|
||||||
}
|
}
|
||||||
|
map.overlays.add(marker)
|
||||||
uiState.items.firstOrNull()?.let { first ->
|
|
||||||
map.controller.animateTo(GeoPoint(first.latitude, first.longitude))
|
|
||||||
}
|
|
||||||
map.invalidate()
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
if (uiState.isLoading) {
|
items.firstOrNull()?.let { first ->
|
||||||
Box(
|
map.controller.animateTo(GeoPoint(first.latitude, first.longitude))
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(Color.Black.copy(alpha = 0.2f)),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.size(40.dp))
|
|
||||||
}
|
}
|
||||||
|
map.invalidate()
|
||||||
}
|
}
|
||||||
|
)
|
||||||
if (uiState.errorMessage != null) {
|
|
||||||
ErrorOverlay(
|
|
||||||
message = uiState.errorMessage ?: "Unknown error",
|
|
||||||
onRetry = viewModel::refresh
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package org.db3.airmq.features.map
|
package org.db3.airmq.features.map
|
||||||
|
|
||||||
|
import org.db3.airmq.sdk.map.domain.MapItem
|
||||||
|
|
||||||
data class MapUiState(
|
data class MapUiState(
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val items: List<MapItem> = emptyList(),
|
val items: List<MapItem> = emptyList(),
|
||||||
|
|||||||
@@ -2,14 +2,18 @@ package org.db3.airmq.features.map
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.db3.airmq.sdk.map.MapService
|
||||||
|
|
||||||
class MapViewModel(
|
@HiltViewModel
|
||||||
private val mapApi: AirMqMapApi = AirMqMapApi()
|
class MapViewModel @Inject constructor(
|
||||||
|
private val mapService: MapService
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(MapUiState(isLoading = true))
|
private val _uiState = MutableStateFlow(MapUiState(isLoading = true))
|
||||||
@@ -22,7 +26,7 @@ class MapViewModel(
|
|||||||
fun refresh() {
|
fun refresh() {
|
||||||
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val result = runCatching { mapApi.fetchMapItems() }
|
val result = runCatching { mapService.fetchMapItems() }
|
||||||
_uiState.value = result.fold(
|
_uiState.value = result.fold(
|
||||||
onSuccess = { items ->
|
onSuccess = { items ->
|
||||||
MapUiState(
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.android.library) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
alias(libs.plugins.kotlin.compose) apply false
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
alias(libs.plugins.ksp) apply false
|
||||||
|
alias(libs.plugins.apollo) apply false
|
||||||
|
alias(libs.plugins.hilt.android) apply false
|
||||||
}
|
}
|
||||||
@@ -20,4 +20,6 @@ kotlin.code.style=official
|
|||||||
# Enables namespacing of each library's R class so that its R class includes only the
|
# Enables namespacing of each library's R class so that its R class includes only the
|
||||||
# resources declared in the library itself and none from the library's dependencies,
|
# resources declared in the library itself and none from the library's dependencies,
|
||||||
# thereby reducing the size of the R class for that library
|
# thereby reducing the size of the R class for that library
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
|
android.disallowKotlinSourceSets=false
|
||||||
|
android.newDsl=false
|
||||||
@@ -5,14 +5,19 @@ junit = "4.13.2"
|
|||||||
junitVersion = "1.3.0"
|
junitVersion = "1.3.0"
|
||||||
espressoCore = "3.7.0"
|
espressoCore = "3.7.0"
|
||||||
lifecycleRuntimeKtx = "2.10.0"
|
lifecycleRuntimeKtx = "2.10.0"
|
||||||
|
lifecycleViewmodel = "2.10.0"
|
||||||
activityCompose = "1.12.4"
|
activityCompose = "1.12.4"
|
||||||
kotlin = "2.0.21"
|
kotlin = "2.0.21"
|
||||||
composeBom = "2024.09.00"
|
composeBom = "2024.09.00"
|
||||||
navigationCompose = "2.9.3"
|
navigationCompose = "2.9.3"
|
||||||
firebaseBom = "34.4.0"
|
firebaseBom = "34.4.0"
|
||||||
mapsCompose = "6.12.1"
|
mapsCompose = "6.12.1"
|
||||||
playServicesMaps = "19.2.0"
|
osmdroid = "6.1.20"
|
||||||
apollo = "4.3.3"
|
apollo = "5.0.0-alpha.4"
|
||||||
|
hilt = "2.57.2"
|
||||||
|
hiltNavigationCompose = "1.2.0"
|
||||||
|
okhttpLogging = "4.12.0"
|
||||||
|
ksp = "2.0.21-1.0.27"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@@ -20,6 +25,9 @@ junit = { group = "junit", name = "junit", version.ref = "junit" }
|
|||||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodel" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodel" }
|
||||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
@@ -34,12 +42,20 @@ firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.r
|
|||||||
firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" }
|
firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" }
|
||||||
firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" }
|
firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" }
|
||||||
firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" }
|
firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" }
|
||||||
google-maps-compose = { group = "com.google.maps.android", name = "maps-compose", version.ref = "mapsCompose" }
|
osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" }
|
||||||
play-services-maps = { group = "com.google.android.gms", name = "play-services-maps", version.ref = "playServicesMaps" }
|
|
||||||
apollo-runtime = { group = "com.apollographql.apollo", name = "apollo-runtime", version.ref = "apollo" }
|
apollo-runtime = { group = "com.apollographql.apollo", name = "apollo-runtime", version.ref = "apollo" }
|
||||||
|
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
|
||||||
|
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
|
||||||
|
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
|
||||||
|
okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttpLogging" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
apollo = { id = "com.apollographql.apollo", version.ref = "apollo" }
|
||||||
|
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
|
||||||
|
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||||
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
|
|
||||||
|
|||||||
49
sdk/build.gradle.kts
Normal file
49
sdk/build.gradle.kts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.library)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
|
alias(libs.plugins.apollo)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "org.db3.airmq.sdk"
|
||||||
|
compileSdk {
|
||||||
|
version = release(36) {
|
||||||
|
minorApiLevel = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 24
|
||||||
|
consumerProguardFiles("consumer-rules.pro")
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apollo {
|
||||||
|
service("service") {
|
||||||
|
packageName.set("org.db3.airmq.sdk")
|
||||||
|
schemaFiles.from(file("../app/src/main/graphql/schema.graphqls"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.apollo.runtime)
|
||||||
|
implementation(libs.hilt.android)
|
||||||
|
ksp(libs.hilt.compiler)
|
||||||
|
}
|
||||||
1
sdk/consumer-rules.pro
Normal file
1
sdk/consumer-rules.pro
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Consumer ProGuard rules for sdk module.
|
||||||
1
sdk/proguard-rules.pro
vendored
Normal file
1
sdk/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
2
sdk/src/main/AndroidManifest.xml
Normal file
2
sdk/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest />
|
||||||
9
sdk/src/main/graphql/MapLocations.graphql
Normal file
9
sdk/src/main/graphql/MapLocations.graphql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
query MapMarkers {
|
||||||
|
getMarkers {
|
||||||
|
_id
|
||||||
|
name
|
||||||
|
latitude
|
||||||
|
longitude
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
33
sdk/src/main/kotlin/org/db3/airmq/sdk/di/SDKModule.kt
Normal file
33
sdk/src/main/kotlin/org/db3/airmq/sdk/di/SDKModule.kt
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package org.db3.airmq.sdk.di
|
||||||
|
|
||||||
|
import com.apollographql.apollo.ApolloClient
|
||||||
|
import dagger.Binds
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import org.db3.airmq.sdk.map.ApolloMapService
|
||||||
|
import org.db3.airmq.sdk.map.MapService
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object SDKModule {
|
||||||
|
private const val API_URL = "https://api.airmq.cc"
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideApolloClient(): ApolloClient {
|
||||||
|
return ApolloClient.Builder()
|
||||||
|
.serverUrl(API_URL)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
abstract class SDKBindModule {
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindMapService(impl: ApolloMapService): MapService
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package org.db3.airmq.sdk.map
|
||||||
|
|
||||||
|
import javax.inject.Inject
|
||||||
|
import org.db3.airmq.sdk.MapMarkersQuery
|
||||||
|
import org.db3.airmq.sdk.map.domain.MapItem
|
||||||
|
|
||||||
|
class ApolloMapItemMapper @Inject constructor() {
|
||||||
|
fun toDomain(marker: MapMarkersQuery.GetMarker, index: Int): MapItem? {
|
||||||
|
val latitude = marker.latitude ?: return null
|
||||||
|
val longitude = marker.longitude ?: return null
|
||||||
|
val id = marker._id?.ifBlank { null } ?: "marker-$index"
|
||||||
|
return MapItem(
|
||||||
|
id = id,
|
||||||
|
title = marker.name?.ifBlank { null } ?: marker.text?.ifBlank { null } ?: id,
|
||||||
|
city = null,
|
||||||
|
latitude = latitude,
|
||||||
|
longitude = longitude,
|
||||||
|
isOnline = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package org.db3.airmq.sdk.map
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.apollographql.apollo.ApolloClient
|
||||||
|
import javax.inject.Inject
|
||||||
|
import org.db3.airmq.sdk.MapMarkersQuery
|
||||||
|
import org.db3.airmq.sdk.map.domain.MapItem
|
||||||
|
|
||||||
|
class ApolloMapService @Inject constructor(
|
||||||
|
private val apolloClient: ApolloClient,
|
||||||
|
private val mapper: ApolloMapItemMapper
|
||||||
|
) : MapService {
|
||||||
|
override suspend fun fetchMapItems(): List<MapItem> {
|
||||||
|
Log.d(TAG, "Executing MapMarkers Apollo query")
|
||||||
|
val response = apolloClient.query(MapMarkersQuery()).execute()
|
||||||
|
|
||||||
|
response.errors?.firstOrNull()?.let { gqlError ->
|
||||||
|
Log.e(TAG, "MapMarkers Apollo query failed: ${gqlError.message}")
|
||||||
|
throw IllegalStateException(gqlError.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
val mappedItems = response.data?.getMarkers
|
||||||
|
.orEmpty()
|
||||||
|
.filterNotNull()
|
||||||
|
.mapIndexedNotNull { index, marker -> mapper.toDomain(marker, index) }
|
||||||
|
Log.d(TAG, "MapMarkers Apollo query returned ${mappedItems.size} items")
|
||||||
|
return mappedItems
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private const val TAG = "ApolloMapService"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
sdk/src/main/kotlin/org/db3/airmq/sdk/map/MapService.kt
Normal file
7
sdk/src/main/kotlin/org/db3/airmq/sdk/map/MapService.kt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package org.db3.airmq.sdk.map
|
||||||
|
|
||||||
|
import org.db3.airmq.sdk.map.domain.MapItem
|
||||||
|
|
||||||
|
interface MapService {
|
||||||
|
suspend fun fetchMapItems(): List<MapItem>
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package org.db3.airmq.features.map
|
package org.db3.airmq.sdk.map.domain
|
||||||
|
|
||||||
data class MapItem(
|
data class MapItem(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -24,4 +24,5 @@ dependencyResolutionManagement {
|
|||||||
|
|
||||||
rootProject.name = "AirMQ"
|
rootProject.name = "AirMQ"
|
||||||
include(":app")
|
include(":app")
|
||||||
|
include(":sdk")
|
||||||
|
|
||||||
Reference in New Issue
Block a user