diff --git a/app/src/main/java/com/indiana/zwl/presentation/AboutScreen.kt b/app/src/main/java/com/indiana/zwl/presentation/AboutScreen.kt index 6dba24b..26e071f 100644 --- a/app/src/main/java/com/indiana/zwl/presentation/AboutScreen.kt +++ b/app/src/main/java/com/indiana/zwl/presentation/AboutScreen.kt @@ -52,9 +52,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.indiana.zwl.BuildConfig import com.indiana.zwl.R +import com.indiana.zwl.domain.model.WaterDataState import com.indiana.zwl.presentation.theme.DarkForestBackground import com.indiana.zwl.presentation.theme.ForestGreenAccent import com.indiana.zwl.presentation.theme.ForestGreenText +import com.indiana.zwl.shared.data.water.WaterAttribution private const val BDL_PORTAL_URL = "https://www.bdl.lasy.gov.pl/portal/" private const val IBL_FIRE_URL = "https://bazapozarow.ibles.pl/" @@ -67,6 +69,7 @@ private const val INSTAGRAM_URL = "https://instagram.com/legalny.bushcraft" @Composable fun AboutScreen( onClose: () -> Unit, + waterDataState: WaterDataState? = null, modifier: Modifier = Modifier ) { val isLandscape = @@ -205,6 +208,50 @@ fun AboutScreen( } } + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + shape = RoundedCornerShape(12.dp) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Public, + contentDescription = null, + tint = ForestGreenAccent, + modifier = Modifier.size(20.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Dane źródeł wody (OSM)", + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + } + + Text( + text = "Punkty wody pitnej, źródeł, studni i napełniania butelek pochodzą z OpenStreetMap i odświeżają się automatycznie.", + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 19.sp + ) + + DataRow(label = "Wersja danych", value = waterVersionLabel(waterDataState?.dataVersion)) + DataRow(label = "Data pobrania", value = waterFetchedAtLabel(waterDataState?.lastCheckedAt)) + DataRow(label = "Liczba punktów", value = waterDataState?.count?.toString() ?: "—") + + Text( + text = WaterAttribution.OSM, + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), @@ -377,3 +424,38 @@ private fun SourceLinkRow(label: String, url: String) { ) } } + +@Composable +private fun DataRow(label: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = value, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface + ) + } +} + +private fun waterVersionLabel(version: Long?): String { + if (version == null || version <= 0L) return "—" + val year = version / 10000 + val month = (version / 100) % 100 + val day = version % 100 + return String.format(java.util.Locale.US, "%04d-%02d-%02d", year, month, day) +} + +private fun waterFetchedAtLabel(millis: Long?): String { + if (millis == null || millis <= 0L) return "zestaw wbudowany" + return java.text.SimpleDateFormat("yyyy-MM-dd HH:mm", java.util.Locale.getDefault()) + .format(java.util.Date(millis)) +} diff --git a/app/src/main/java/com/indiana/zwl/presentation/MainScreen.kt b/app/src/main/java/com/indiana/zwl/presentation/MainScreen.kt index ed3bdcf..e0b94f8 100644 --- a/app/src/main/java/com/indiana/zwl/presentation/MainScreen.kt +++ b/app/src/main/java/com/indiana/zwl/presentation/MainScreen.kt @@ -236,6 +236,7 @@ fun MainScreen( val savedPoints by viewModel.savedPoints.collectAsStateWithLifecycle() val showSavedPointList by viewModel.showSavedPointList.collectAsStateWithLifecycle() val selectedSavedPointInfo by viewModel.selectedSavedPointInfo.collectAsStateWithLifecycle() + val waterDataState by viewModel.waterDataState.collectAsStateWithLifecycle() val savedPointDetailViewModel: SavedPointDetailViewModel = hiltViewModel() val selectedSavedPointDetails by savedPointDetailViewModel.details.collectAsStateWithLifecycle() var selectedTab by rememberSaveable { mutableStateOf(0) } @@ -459,7 +460,10 @@ fun MainScreen( ) if (showAbout) { - AboutScreen(onClose = { showAbout = false }) + AboutScreen( + waterDataState = waterDataState, + onClose = { showAbout = false } + ) } if (showSavedPointList) { diff --git a/app/src/main/java/com/indiana/zwl/presentation/MainViewModel.kt b/app/src/main/java/com/indiana/zwl/presentation/MainViewModel.kt index 1749118..4f14da4 100644 --- a/app/src/main/java/com/indiana/zwl/presentation/MainViewModel.kt +++ b/app/src/main/java/com/indiana/zwl/presentation/MainViewModel.kt @@ -17,6 +17,7 @@ import com.indiana.zwl.domain.model.Zone import com.indiana.zwl.domain.model.Poi import com.indiana.zwl.domain.model.SavedPoint import com.indiana.zwl.domain.model.NewSavedPoint +import com.indiana.zwl.domain.model.WaterDataState import com.indiana.zwl.domain.model.WaterSource import com.indiana.zwl.shared.data.water.WaterSyncManager import com.indiana.zwl.domain.usecase.GetFireRiskUseCase @@ -288,6 +289,15 @@ class MainViewModel @Inject constructor( } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + private val _waterDataState = MutableStateFlow(null) + val waterDataState: StateFlow = _waterDataState.asStateFlow() + + fun refreshWaterDataState() { + viewModelScope.launch { + _waterDataState.value = waterSourceRepository.getDataState() + } + } + private val _showPoiGroups = MutableStateFlow( PoiUiGroup.entries.associateWith { sharedPrefs.getBoolean("show_poi_${it.key}", true) } ) @@ -451,6 +461,7 @@ class MainViewModel @Inject constructor( init { loadZonesAndInitializeEngine() + refreshWaterDataState() } fun setLocationPermissionGranted(granted: Boolean) { @@ -484,6 +495,7 @@ class MainViewModel @Inject constructor( viewModelScope.launch(ioDispatcher) { try { waterSyncManager.ensureBaselineIfEmpty() + refreshWaterDataState() } catch (e: Exception) { if (e is CancellationException) throw e e.printStackTrace() @@ -544,6 +556,7 @@ class MainViewModel @Inject constructor( viewModelScope.launch(ioDispatcher) { try { waterSyncManager.refreshIfStale() + refreshWaterDataState() } catch (e: Exception) { if (e is CancellationException) throw e e.printStackTrace() diff --git a/iosApp/iosApp/ViewModels/MainViewModel.swift b/iosApp/iosApp/ViewModels/MainViewModel.swift index b660156..5c29a3b 100644 --- a/iosApp/iosApp/ViewModels/MainViewModel.swift +++ b/iosApp/iosApp/ViewModels/MainViewModel.swift @@ -55,6 +55,7 @@ final class MainViewModel: NSObject, ObservableObject { @Published var bansGeoJson: String = "" @Published var poisGeoJson: String = "" @Published var waterGeoJson: String = "" + @Published var waterDataState: WaterDataState? // Layer toggles (persisted across launches like Android's // SharedPreferences `zwl_map_settings`; they reset to defaults only on @@ -373,6 +374,7 @@ final class MainViewModel: NSObject, ObservableObject { includeDrinking: showWaterDrinking, includeSprings: showWaterSprings, includeWells: showWaterWells)) ?? "" + waterDataState = (try? await app.waterDataState()) ?? nil guard let zones = try? await app.zonesGeoJson(), let bans = try? await app.bansGeoJson(), let pois = try? await app.poisGeoJson() else { return } diff --git a/iosApp/iosApp/Views/AboutView.swift b/iosApp/iosApp/Views/AboutView.swift index 347d0cc..cbdbe24 100644 --- a/iosApp/iosApp/Views/AboutView.swift +++ b/iosApp/iosApp/Views/AboutView.swift @@ -1,8 +1,10 @@ import SwiftUI +import shared /// "O aplikacji" (Android `AboutScreen` parity): legal disclaimer, data /// sources with tappable links, privacy note and the app version footer. struct AboutView: View { + let waterDataState: WaterDataState? @Environment(\.dismiss) private var dismiss private let bdlPortalUrl = URL(string: "https://www.bdl.lasy.gov.pl/portal/")! @@ -37,6 +39,19 @@ struct AboutView: View { sectionHeader("Źródła danych rządowych", systemImage: "globe") } + Section { + Text("Punkty wody pitnej, źródeł, studni i napełniania butelek pochodzą z OpenStreetMap i odświeżają się automatycznie.") + .font(.system(size: 13)) + dataRow("Wersja danych", waterDataState.map { waterVersionLabel($0.dataVersion) } ?? "—") + dataRow("Data pobrania", waterDataState.map { waterFetchedAtLabel($0.lastCheckedAt) } ?? "zestaw wbudowany") + dataRow("Liczba punktów", waterDataState.map { String($0.count) } ?? "—") + Text(WaterAttribution.shared.OSM) + .font(.system(size: 11)) + .foregroundColor(.secondary) + } header: { + sectionHeader("Dane źródeł wody (OSM)", systemImage: "drop") + } + Section { Text("Twoja lokalizacja służy wyłącznie do sprawdzenia stref i jest przetwarzana lokalnie na urządzeniu. Aby pokazać zagrożenie pożarowe, aplikacja wysyła anonimowe zapytanie z Twoimi współrzędnymi do publicznego API BDL — bez tworzenia profili i bez zapisu historii lokalizacji. Aplikacja nie zawiera reklam ani systemów analitycznych.") .font(.system(size: 13)) @@ -102,6 +117,32 @@ struct AboutView: View { Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "—" } + private func dataRow(_ label: String, _ value: String) -> some View { + HStack { + Text(label) + .font(.system(size: 13)) + .foregroundColor(.secondary) + Spacer() + Text(value) + .font(.system(size: 13, weight: .medium)) + } + } + + private func waterVersionLabel(_ version: Int64) -> String { + guard version > 0 else { return "—" } + let year = version / 10000 + let month = (version / 100) % 100 + let day = version % 100 + return String(format: "%04d-%02d-%02d", year, month, day) + } + + private func waterFetchedAtLabel(_ millis: Int64) -> String { + guard millis > 0 else { return "zestaw wbudowany" } + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd HH:mm" + return formatter.string(from: Date(timeIntervalSince1970: TimeInterval(millis) / 1000.0)) + } + private func sectionHeader(_ title: String, systemImage: String) -> some View { Label(title, systemImage: systemImage) .font(.system(size: 13, weight: .bold)) diff --git a/iosApp/iosApp/Views/MainView.swift b/iosApp/iosApp/Views/MainView.swift index 5f29dd9..75e7f12 100644 --- a/iosApp/iosApp/Views/MainView.swift +++ b/iosApp/iosApp/Views/MainView.swift @@ -114,7 +114,7 @@ struct MainView: View { OfflineAreasView(viewModel: viewModel) } .fullScreenCover(isPresented: $showAbout) { - AboutView() + AboutView(waterDataState: viewModel.waterDataState) } .alert("Obszar za duży", isPresented: Binding( get: { viewModel.downloadBlockedMessage != nil }, diff --git a/shared/src/iosMain/kotlin/com/indiana/zwl/shared/ios/ForestApp.kt b/shared/src/iosMain/kotlin/com/indiana/zwl/shared/ios/ForestApp.kt index bd330c0..e593603 100644 --- a/shared/src/iosMain/kotlin/com/indiana/zwl/shared/ios/ForestApp.kt +++ b/shared/src/iosMain/kotlin/com/indiana/zwl/shared/ios/ForestApp.kt @@ -9,6 +9,7 @@ import com.indiana.zwl.domain.model.NewSavedPoint import com.indiana.zwl.domain.model.Poi import com.indiana.zwl.domain.model.SavedPoint import com.indiana.zwl.domain.model.SoilCover +import com.indiana.zwl.domain.model.WaterDataState import com.indiana.zwl.domain.model.WaterSource import com.indiana.zwl.domain.model.Zone import com.indiana.zwl.domain.repository.ForestBanRepository @@ -246,6 +247,8 @@ class ForestApp( fun cachedWaterSources(): List = cachedWaterSources + suspend fun waterDataState(): WaterDataState? = waterSourceRepository.getDataState() + suspend fun syncZones(): Boolean = withContext(Dispatchers.Default) { try { val responseStr = arcgisApi.getZanocujWLesieZones()