diff --git a/.cursor/rules/app-recreation-core.mdc b/.cursor/rules/app-recreation-core.mdc index 2cf8f76..ef3aea7 100644 --- a/.cursor/rules/app-recreation-core.mdc +++ b/.cursor/rules/app-recreation-core.mdc @@ -16,6 +16,11 @@ alwaysApply: true - 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. +## 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 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. 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). -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. If a requested change conflicts with this baseline, ask for explicit approval before introducing an alternative. diff --git a/.gitignore b/.gitignore index aa724b7..2ef3244 100644 --- a/.gitignore +++ b/.gitignore @@ -7,9 +7,13 @@ /.idea/workspace.xml /.idea/navEditor.xml /.idea/assetWizardSettings.xml +.idea/inspectionProfiles/ .DS_Store /build /captures .externalNativeBuild .cxx local.properties +**/build/ +.kotlin/ +/.idea/**/workspace.xml diff --git a/.idea/gradle.xml b/.idea/gradle.xml index cdbc250..250d958 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -9,6 +9,7 @@ diff --git a/.idea/misc.xml b/.idea/misc.xml index 74dd639..991a888 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,7 +1,7 @@ - + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e9827ba..513c737 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,7 @@ plugins { alias(libs.plugins.android.application) + alias(libs.plugins.hilt.android) + alias(libs.plugins.ksp) alias(libs.plugins.kotlin.compose) } @@ -40,8 +42,15 @@ android { } dependencies { + // AirMQ SDK + implementation(project(":sdk")) + + // Android implementation(libs.androidx.core.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(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) @@ -49,13 +58,18 @@ dependencies { implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) 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(libs.firebase.analytics) implementation(libs.firebase.crashlytics) implementation(libs.firebase.messaging) - implementation(libs.google.maps.compose) - implementation(libs.play.services.maps) - implementation(libs.apollo.runtime) + implementation(libs.osmdroid.android) + + // Tests testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) diff --git a/app/src/androidTest/java/org/db3/airmq/ExampleInstrumentedTest.kt b/app/src/androidTest/java/org/db3/airmq/ExampleInstrumentedTest.kt deleted file mode 100644 index 6353ae9..0000000 --- a/app/src/androidTest/java/org/db3/airmq/ExampleInstrumentedTest.kt +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b310721..18fcee4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,10 @@ + + { - 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 { - 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 - } - } - """ - } -} diff --git a/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt b/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt index 1d431e2..8d8fc00 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/map/MapScreen.kt @@ -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 +) { 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 diff --git a/app/src/main/kotlin/org/db3/airmq/features/map/MapUiState.kt b/app/src/main/kotlin/org/db3/airmq/features/map/MapUiState.kt index 6550b8f..bc69cef 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/map/MapUiState.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/map/MapUiState.kt @@ -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 = emptyList(), diff --git a/app/src/main/kotlin/org/db3/airmq/features/map/MapViewModel.kt b/app/src/main/kotlin/org/db3/airmq/features/map/MapViewModel.kt index 74475e8..3f337b2 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/map/MapViewModel.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/map/MapViewModel.kt @@ -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( diff --git a/app/src/test/java/org/db3/airmq/ExampleUnitTest.kt b/app/src/test/java/org/db3/airmq/ExampleUnitTest.kt deleted file mode 100644 index 6177fe1..0000000 --- a/app/src/test/java/org/db3/airmq/ExampleUnitTest.kt +++ /dev/null @@ -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) - } -} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 18318be..456f85c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,10 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { 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.ksp) apply false + alias(libs.plugins.apollo) apply false + alias(libs.plugins.hilt.android) apply false } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 20e2a01..427b103 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,4 +20,6 @@ kotlin.code.style=official # 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, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true +android.disallowKotlinSourceSets=false +android.newDsl=false \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0a73ec1..345efbc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,14 +5,19 @@ junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" lifecycleRuntimeKtx = "2.10.0" +lifecycleViewmodel = "2.10.0" activityCompose = "1.12.4" kotlin = "2.0.21" composeBom = "2024.09.00" navigationCompose = "2.9.3" firebaseBom = "34.4.0" mapsCompose = "6.12.1" -playServicesMaps = "19.2.0" -apollo = "4.3.3" +osmdroid = "6.1.20" +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] 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-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-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-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } 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-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" } firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" } -google-maps-compose = { group = "com.google.maps.android", name = "maps-compose", version.ref = "mapsCompose" } -play-services-maps = { group = "com.google.android.gms", name = "play-services-maps", version.ref = "playServicesMaps" } +osmdroid-android = { group = "org.osmdroid", name = "osmdroid-android", version.ref = "osmdroid" } 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] 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-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" } diff --git a/sdk/build.gradle.kts b/sdk/build.gradle.kts new file mode 100644 index 0000000..bc9d44f --- /dev/null +++ b/sdk/build.gradle.kts @@ -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) +} diff --git a/sdk/consumer-rules.pro b/sdk/consumer-rules.pro new file mode 100644 index 0000000..736bb01 --- /dev/null +++ b/sdk/consumer-rules.pro @@ -0,0 +1 @@ +# Consumer ProGuard rules for sdk module. diff --git a/sdk/proguard-rules.pro b/sdk/proguard-rules.pro new file mode 100644 index 0000000..fb164d6 --- /dev/null +++ b/sdk/proguard-rules.pro @@ -0,0 +1 @@ +# Add project specific ProGuard rules here. diff --git a/sdk/src/main/AndroidManifest.xml b/sdk/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8072ee0 --- /dev/null +++ b/sdk/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/sdk/src/main/graphql/MapLocations.graphql b/sdk/src/main/graphql/MapLocations.graphql new file mode 100644 index 0000000..811e503 --- /dev/null +++ b/sdk/src/main/graphql/MapLocations.graphql @@ -0,0 +1,9 @@ +query MapMarkers { + getMarkers { + _id + name + latitude + longitude + text + } +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/di/SDKModule.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/di/SDKModule.kt new file mode 100644 index 0000000..60af50e --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/di/SDKModule.kt @@ -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 +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/map/ApolloMapItemMapper.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/map/ApolloMapItemMapper.kt new file mode 100644 index 0000000..7f6da1b --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/map/ApolloMapItemMapper.kt @@ -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 + ) + } +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/map/ApolloMapService.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/map/ApolloMapService.kt new file mode 100644 index 0000000..e9507bc --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/map/ApolloMapService.kt @@ -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 { + 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" + } +} diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/map/MapService.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/map/MapService.kt new file mode 100644 index 0000000..d32646e --- /dev/null +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/map/MapService.kt @@ -0,0 +1,7 @@ +package org.db3.airmq.sdk.map + +import org.db3.airmq.sdk.map.domain.MapItem + +interface MapService { + suspend fun fetchMapItems(): List +} diff --git a/app/src/main/kotlin/org/db3/airmq/features/map/MapItem.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/map/domain/MapItem.kt similarity index 81% rename from app/src/main/kotlin/org/db3/airmq/features/map/MapItem.kt rename to sdk/src/main/kotlin/org/db3/airmq/sdk/map/domain/MapItem.kt index 5dbad6b..848ec8d 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/map/MapItem.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/map/domain/MapItem.kt @@ -1,4 +1,4 @@ -package org.db3.airmq.features.map +package org.db3.airmq.sdk.map.domain data class MapItem( val id: String, diff --git a/settings.gradle.kts b/settings.gradle.kts index 2b2d93f..f4a83f4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,4 +24,5 @@ dependencyResolutionManagement { rootProject.name = "AirMQ" include(":app") +include(":sdk") \ No newline at end of file