diff --git a/.cursor/airmq-android.code-workspace b/.cursor/airmq-android.code-workspace new file mode 100644 index 0000000..3eb13b2 --- /dev/null +++ b/.cursor/airmq-android.code-workspace @@ -0,0 +1,11 @@ +{ + "folders": [ + { + "path": "../../airmq-android" + }, + { + "path": ".." + } + ], + "settings": {} +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/db3/airmq/features/city/CityViewModel.kt b/app/src/main/kotlin/org/db3/airmq/features/city/CityViewModel.kt index 8d8cc88..a4eafb3 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/city/CityViewModel.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/city/CityViewModel.kt @@ -79,7 +79,12 @@ class CityViewModel @Inject constructor( private fun selectCity(city: org.db3.airmq.sdk.city.domain.City) { viewModelScope.launch(Dispatchers.IO) { cityService.setSelectedCity(city.id) - _actions.tryEmit(CityScreenContract.Action.NavigateBack) + .onSuccess { _actions.tryEmit(CityScreenContract.Action.NavigateBack) } + .onFailure { + _actions.tryEmit( + CityScreenContract.Action.ShowToast(appContext.getString(R.string.toast_error)) + ) + } } } @@ -93,8 +98,8 @@ class CityViewModel @Inject constructor( private fun enableDetectAutomaticallyWithLocation(location: Location?) { viewModelScope.launch(Dispatchers.IO) { if (location != null) { - cityService.refreshCityFromLocation(location) cityService.setDetectAutomatically(true) + cityService.refreshCityFromLocation(location) val selectedCity = cityService.getSelectedCity() _uiState.value = _uiState.value.copy( detectAutomatically = true, diff --git a/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreen.kt b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreen.kt index d3aac9d..cb8c09c 100644 --- a/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreen.kt +++ b/app/src/main/kotlin/org/db3/airmq/features/manage/ManageScreen.kt @@ -1,5 +1,6 @@ package org.db3.airmq.features.manage +import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.Image import androidx.compose.foundation.clickable @@ -67,6 +68,7 @@ fun ManageScreen( viewModel: ManageViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsState() + Log.d("MANAGE_DEBUG", uiState.toString()) val lifecycleOwner = LocalLifecycleOwner.current DisposableEffect(lifecycleOwner, viewModel) { val observer = LifecycleEventObserver { _, event -> 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 74476c9..4dcefc5 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 @@ -301,21 +301,28 @@ private fun AirMQMap( } } + // Overlay rebuild must be keyed off composable inputs: AndroidView's `update` runs without + // recording snapshot reads when values are only read inside View.post {}, so map items can + // load without a rebuild until something else (e.g. pan → debounced rebuild) retriggers it. + LaunchedEffect(items, centerOnMarker, clusterEnabled) { + val snapshotItems = items + val snapshotCenter = centerOnMarker + val snapshotCluster = clusterEnabled + mapView.post { + rebuildAirMqMapOverlays( + map = mapView, + items = snapshotItems, + onMarkerClick = latestOnMarkerClick.value, + clusterEnabled = snapshotCluster, + centerOnMarker = snapshotCenter, + initialCameraDone = initialCameraDone + ) + } + } + AndroidView( modifier = modifier.fillMaxSize(), - factory = { mapView }, - update = { map -> - map.post { - rebuildAirMqMapOverlays( - map = map, - items = latestItems.value, - onMarkerClick = latestOnMarkerClick.value, - clusterEnabled = latestClusterEnabled.value, - centerOnMarker = latestCenterOnMarker.value, - initialCameraDone = initialCameraDone - ) - } - } + factory = { mapView } ) } diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityService.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityService.kt index a9f5cda..a889293 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityService.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityService.kt @@ -24,6 +24,7 @@ interface CityService { /** * If [DashboardCityContext.cityId] is missing, tries to resolve it from the local city DB * using the stored English name and updates preferences. + * Does not notify [observeDashboardCityContext] (avoids re-entrant emissions during dashboard load). */ suspend fun refreshDashboardCityIdentity() @@ -80,9 +81,9 @@ interface CityService { suspend fun setDetectAutomatically(enabled: Boolean) /** - * Refreshes the selected city from location. Used when detect automatically is on. + * Refreshes the selected city from location when [getDetectAutomatically] is true. * Resolves closest city from the cities DB and updates stored city. - * No-op if location is null or no matching city found. + * No-op if auto-detect is off, location is null, or no matching city found. */ suspend fun refreshCityFromLocation(location: Location?) diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityServiceImpl.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityServiceImpl.kt index 7f70ea5..ffd588c 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityServiceImpl.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/city/CityServiceImpl.kt @@ -101,6 +101,7 @@ class CityServiceImpl @Inject constructor( } override suspend fun refreshCityFromLocation(location: Location?) { + if (!getDetectAutomatically()) return if (location == null) return val cities = ensureCitiesInDb() val resolvedCity = findClosestCity(cities, location.latitude, location.longitude) @@ -121,7 +122,6 @@ class CityServiceImpl @Inject constructor( val city = cityLocalDataSource.getAllCities() .find { it.nameEn.equals(nameEn, ignoreCase = true) } ?: return prefs.edit().putString(KEY_DASHBOARD_CITY_ID, city.id).apply() - pushContextUpdate() } override suspend fun getResolvedDashboardCityContext(): DashboardCityContext { diff --git a/sdk/src/main/kotlin/org/db3/airmq/sdk/dashboard/DashboardMetricsRepositoryImpl.kt b/sdk/src/main/kotlin/org/db3/airmq/sdk/dashboard/DashboardMetricsRepositoryImpl.kt index 69a9808..7154662 100644 --- a/sdk/src/main/kotlin/org/db3/airmq/sdk/dashboard/DashboardMetricsRepositoryImpl.kt +++ b/sdk/src/main/kotlin/org/db3/airmq/sdk/dashboard/DashboardMetricsRepositoryImpl.kt @@ -114,7 +114,12 @@ class DashboardMetricsRepositoryImpl @Inject constructor( } private fun mapLastRow(row: CityAverageLastQuery.CityAverageLast): SensorSampleRow? { - val t = GraphqlDateTimeParser.parseToEpochMillis(row.time) ?: return null + val parsedTime = GraphqlDateTimeParser.parseToEpochMillis(row.time) + val hasAnyReading = row.Temp != null || row.Hum != null || row.Press != null || + row.PMS1 != null || row.PMS25 != null || row.PMS10 != null || + row.radRg != null || row.PPM != null || row.IKAV != null || + row.CO2 != null || row.VOC != null || row.AQI != null + val t = parsedTime ?: if (hasAnyReading) System.currentTimeMillis() else return null return SensorSampleRow( epochMillis = t, temp = row.Temp?.toFloat(),