From 320010b94eebcaf773af8c6acbcd02a79b0072dc Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 15 May 2026 08:10:01 +0200 Subject: [PATCH] feat(Networking): enhance offline handling and localization for diary and members data - Added `ACCESS_NETWORK_STATE` permission in AndroidManifest.xml to monitor network connectivity. - Introduced `NetworkConnectivityHolder` to manage network state and trigger data refresh when connectivity is restored. - Updated `DiaryManager` and `MembersManager` to support offline caching, allowing users to view previously loaded data when offline. - Enhanced localization by adding new keys for offline cache hints in both German and English, improving user experience during connectivity issues. - Updated UI components to display offline cache messages, ensuring users are informed when data is being served from cache. --- mobile-app/composeApp/build.gradle.kts | 4 +- .../src/androidMain/AndroidManifest.xml | 1 + .../tt_tagebuch/app/AppDependencies.kt | 2 + .../app/net/NetworkConnectivityHolder.kt | 44 ++++++++++++++++ .../de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt | 50 +++++++++++++++++++ mobile-app/gradle/libs.versions.toml | 3 ++ .../tt_tagebuch/shared/i18n/MobileStrings.kt | 15 ++++++ .../tt_tagebuch/shared/state/DiaryManager.kt | 26 ++++++++-- .../tt_tagebuch/shared/state/DiaryState.kt | 4 ++ .../shared/state/MembersManager.kt | 26 ++++++++-- .../tt_tagebuch/shared/state/MembersState.kt | 2 + scripts/generate-mobile-i18n.js | 29 +++++++++++ 12 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/net/NetworkConnectivityHolder.kt diff --git a/mobile-app/composeApp/build.gradle.kts b/mobile-app/composeApp/build.gradle.kts index 051c05eb..a656c132 100644 --- a/mobile-app/composeApp/build.gradle.kts +++ b/mobile-app/composeApp/build.gradle.kts @@ -58,8 +58,8 @@ android { applicationId = "de.tsschulz.tt_tagebuch" minSdk = libs.versions.android.minSdk.get().toInt() targetSdk = libs.versions.android.targetSdk.get().toInt() - versionCode = 4 - versionName = "1.2.0" + versionCode = libs.versions.appVersionCode.get().toInt() + versionName = libs.versions.appVersionName.get() buildConfigField("String", "BACKEND_BASE_URL", "\"$backendBaseUrl\"") buildConfigField("String", "SOCKET_BASE_URL", "\"$socketBaseUrl\"") } diff --git a/mobile-app/composeApp/src/androidMain/AndroidManifest.xml b/mobile-app/composeApp/src/androidMain/AndroidManifest.xml index ba6a58d1..892f0c70 100644 --- a/mobile-app/composeApp/src/androidMain/AndroidManifest.xml +++ b/mobile-app/composeApp/src/androidMain/AndroidManifest.xml @@ -2,6 +2,7 @@ + = _connected.asStateFlow() + + private val callback = object : ConnectivityManager.NetworkCallback() { + override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) { + _connected.value = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } + + override fun onLost(network: Network) { + _connected.value = readConnected() + } + + override fun onAvailable(network: Network) { + _connected.value = readConnected() + } + } + + init { + cm.registerDefaultNetworkCallback(callback) + _connected.value = readConnected() + } + + private fun readConnected(): Boolean { + val network = cm.activeNetwork ?: return false + val caps = cm.getNetworkCapabilities(network) ?: return false + return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } +} diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt index 4ced2bdf..d0599972 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt @@ -257,6 +257,40 @@ private fun MainTabs(dependencies: AppDependencies) { val useWideMainNav = LocalConfiguration.current.screenWidthDp >= MAIN_NAV_RAIL_MIN_WIDTH_DP val clubState by dependencies.clubManager.state.collectAsState() val visibleTabs = visibleMainTabs(clubState.currentPermissions) + val networkConnected by dependencies.networkConnectivity.connected.collectAsState() + var lastNetworkConnected by remember { mutableStateOf(true) } + + /** Tagebuch- und Mitglieder-Listen vorladen, sobald Verein + Rechte feststehen. */ + LaunchedEffect(clubState.currentClubId, clubState.currentPermissions) { + val id = clubState.currentClubId ?: return@LaunchedEffect + val perms = clubState.currentPermissions ?: return@LaunchedEffect + dependencies.applicationScope.launch { + if (perms.canReadDiary()) { + dependencies.diaryManager.loadDates(id) + } + if (perms.canReadMembers()) { + dependencies.membersManager.loadMembers(id) + } + } + } + + /** Nach Netzwerk-Wiederkehr Listen neu laden (Server ist Quelle der Wahrheit). */ + LaunchedEffect(networkConnected, clubState.currentClubId, clubState.currentPermissions) { + val id = clubState.currentClubId ?: return@LaunchedEffect + val perms = clubState.currentPermissions ?: return@LaunchedEffect + val wasConnected = lastNetworkConnected + lastNetworkConnected = networkConnected + if (!wasConnected && networkConnected) { + dependencies.applicationScope.launch { + if (perms.canReadDiary()) { + dependencies.diaryManager.loadDates(id) + } + if (perms.canReadMembers()) { + dependencies.membersManager.loadMembers(id) + } + } + } + } LaunchedEffect(selectedTab) { if (selectedTab != MainTab.Settings) { @@ -1462,6 +1496,14 @@ private fun DiaryListScreen( .padding(horizontal = ScreenHorizontalPadding, vertical = 16.dp), ) { Header(tr("diary.title", "Tagebuch")) + if (diaryState.fromOfflineCache) { + Text( + tr("mobile.offlineCacheHint", "Keine Verbindung – zuletzt geladene Daten."), + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.65f), + modifier = Modifier.padding(bottom = 8.dp), + ) + } Text( tr("diary.listHintCompact", "Trainingstag wählen und direkt bearbeiten."), style = MaterialTheme.typography.body2, @@ -4502,6 +4544,14 @@ private fun MembersScreen( .padding(horizontal = ScreenHorizontalPadding, vertical = 16.dp), ) { Header(tr("members.title", "Mitglieder")) + if (membersState.fromOfflineCache) { + Text( + trStr("mobile.offlineCacheHint", "Keine Verbindung – zuletzt geladene Daten."), + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.65f), + modifier = Modifier.padding(bottom = 8.dp), + ) + } Row( modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), diff --git a/mobile-app/gradle/libs.versions.toml b/mobile-app/gradle/libs.versions.toml index 9e4fb8c9..93f5734d 100644 --- a/mobile-app/gradle/libs.versions.toml +++ b/mobile-app/gradle/libs.versions.toml @@ -1,4 +1,7 @@ [versions] +# composeApp (Play Store / „Über die App“-Build) +appVersionCode = "5" +appVersionName = "1.2.1" agp = "9.2.1" android-compileSdk = "35" android-minSdk = "24" diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/i18n/MobileStrings.kt b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/i18n/MobileStrings.kt index de3fa134..360c2377 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/i18n/MobileStrings.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/i18n/MobileStrings.kt @@ -1341,6 +1341,7 @@ object MobileStrings { "mobile.noMembers" to "Keine Mitglieder gefunden", "mobile.noResults" to "Keine Treffer", "mobile.noTimes" to "Keine Zeiten", + "mobile.offlineCacheHint" to "Keine Verbindung – zuletzt geladene Daten.", "mobile.participationTop" to "Top Teilnahmen", "mobile.refresh" to "Aktualisieren", "mobile.requestedAccess" to "Angefragt", @@ -3853,6 +3854,7 @@ object MobileStrings { "mobile.noMembers" to "Keine Mitglieder gefunden", "mobile.noResults" to "Keine Treffer", "mobile.noTimes" to "Keine Zeiten", + "mobile.offlineCacheHint" to "Keine Verbindung – zuletzt geladene Daten.", "mobile.participationTop" to "Top Teilnahmen", "mobile.refresh" to "Aktualisieren", "mobile.requestedAccess" to "Angefragt", @@ -6366,6 +6368,7 @@ object MobileStrings { "mobile.noMembers" to "Keine Mitglieder gefunden", "mobile.noResults" to "Keine Treffer", "mobile.noTimes" to "Keine Zeiten", + "mobile.offlineCacheHint" to "Keine Verbindung – zuletzt geladene Daten.", "mobile.participationTop" to "Top Teilnahmen", "mobile.refresh" to "Aktualisieren", "mobile.requestedAccess" to "Angefragt", @@ -8872,6 +8875,7 @@ object MobileStrings { "mobile.noMembers" to "No members found", "mobile.noResults" to "No results", "mobile.noTimes" to "No times", + "mobile.offlineCacheHint" to "No connection – showing last loaded data.", "mobile.participationTop" to "Top attendance", "mobile.refresh" to "Refresh", "mobile.requestedAccess" to "Requested", @@ -11378,6 +11382,7 @@ object MobileStrings { "mobile.noMembers" to "No members found", "mobile.noResults" to "No results", "mobile.noTimes" to "No times", + "mobile.offlineCacheHint" to "No connection – showing last loaded data.", "mobile.participationTop" to "Top attendance", "mobile.refresh" to "Refresh", "mobile.requestedAccess" to "Requested", @@ -13884,6 +13889,7 @@ object MobileStrings { "mobile.noMembers" to "No members found", "mobile.noResults" to "No results", "mobile.noTimes" to "No times", + "mobile.offlineCacheHint" to "No connection – showing last loaded data.", "mobile.participationTop" to "Top attendance", "mobile.refresh" to "Refresh", "mobile.requestedAccess" to "Requested", @@ -16390,6 +16396,7 @@ object MobileStrings { "mobile.noMembers" to "Keine Mitglieder gefunden", "mobile.noResults" to "Keine Treffer", "mobile.noTimes" to "Keine Zeiten", + "mobile.offlineCacheHint" to "Sin conexión: se muestran los datos cargados por última vez.", "mobile.participationTop" to "Top Teilnahmen", "mobile.refresh" to "Aktualisieren", "mobile.requestedAccess" to "Angefragt", @@ -18896,6 +18903,7 @@ object MobileStrings { "mobile.noMembers" to "Keine Mitglieder gefunden", "mobile.noResults" to "Keine Treffer", "mobile.noTimes" to "Keine Zeiten", + "mobile.offlineCacheHint" to "Walang koneksyon – ipinapakita ang huling na-load na datos.", "mobile.participationTop" to "Top Teilnahmen", "mobile.refresh" to "Aktualisieren", "mobile.requestedAccess" to "Angefragt", @@ -21402,6 +21410,7 @@ object MobileStrings { "mobile.noMembers" to "Keine Mitglieder gefunden", "mobile.noResults" to "Keine Treffer", "mobile.noTimes" to "Keine Zeiten", + "mobile.offlineCacheHint" to "Pas de connexion – affichage des données du dernier chargement.", "mobile.participationTop" to "Top Teilnahmen", "mobile.refresh" to "Aktualisieren", "mobile.requestedAccess" to "Angefragt", @@ -23908,6 +23917,7 @@ object MobileStrings { "mobile.noMembers" to "Keine Mitglieder gefunden", "mobile.noResults" to "Keine Treffer", "mobile.noTimes" to "Keine Zeiten", + "mobile.offlineCacheHint" to "Nessuna connessione – dati dell’ultimo caricamento.", "mobile.participationTop" to "Top Teilnahmen", "mobile.refresh" to "Aktualisieren", "mobile.requestedAccess" to "Angefragt", @@ -26414,6 +26424,7 @@ object MobileStrings { "mobile.noMembers" to "Keine Mitglieder gefunden", "mobile.noResults" to "Keine Treffer", "mobile.noTimes" to "Keine Zeiten", + "mobile.offlineCacheHint" to "ネットワークに接続されていません。最後に読み込んだデータを表示しています。", "mobile.participationTop" to "Top Teilnahmen", "mobile.refresh" to "Aktualisieren", "mobile.requestedAccess" to "Angefragt", @@ -28920,6 +28931,7 @@ object MobileStrings { "mobile.noMembers" to "Keine Mitglieder gefunden", "mobile.noResults" to "Keine Treffer", "mobile.noTimes" to "Keine Zeiten", + "mobile.offlineCacheHint" to "Brak połączenia – wyświetlane ostatnio wczytane dane.", "mobile.participationTop" to "Top Teilnahmen", "mobile.refresh" to "Aktualisieren", "mobile.requestedAccess" to "Angefragt", @@ -31426,6 +31438,7 @@ object MobileStrings { "mobile.noMembers" to "Keine Mitglieder gefunden", "mobile.noResults" to "Keine Treffer", "mobile.noTimes" to "Keine Zeiten", + "mobile.offlineCacheHint" to "ไม่มีการเชื่อมต่อ – แสดงข้อมูลที่โหลดล่าสุด", "mobile.participationTop" to "Top Teilnahmen", "mobile.refresh" to "Aktualisieren", "mobile.requestedAccess" to "Angefragt", @@ -33932,6 +33945,7 @@ object MobileStrings { "mobile.noMembers" to "Keine Mitglieder gefunden", "mobile.noResults" to "Keine Treffer", "mobile.noTimes" to "Keine Zeiten", + "mobile.offlineCacheHint" to "Walang koneksyon – ipinapakita ang huling na-load na datos.", "mobile.participationTop" to "Top Teilnahmen", "mobile.refresh" to "Aktualisieren", "mobile.requestedAccess" to "Angefragt", @@ -36438,6 +36452,7 @@ object MobileStrings { "mobile.noMembers" to "Keine Mitglieder gefunden", "mobile.noResults" to "Keine Treffer", "mobile.noTimes" to "Keine Zeiten", + "mobile.offlineCacheHint" to "无网络连接,显示上次加载的数据。", "mobile.participationTop" to "Top Teilnahmen", "mobile.refresh" to "Aktualisieren", "mobile.requestedAccess" to "Angefragt", diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/DiaryManager.kt b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/DiaryManager.kt index 2f9c735f..9cd84fb5 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/DiaryManager.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/DiaryManager.kt @@ -224,12 +224,32 @@ class DiaryManager( } suspend fun loadDates(clubId: Int) { - _state.value = _state.value.copy(isLoading = true, error = null) + val prev = _state.value + val base = if (prev.lastLoadedClubId != null && prev.lastLoadedClubId != clubId) { + prev.copy(dates = emptyList(), lastLoadedClubId = null, fromOfflineCache = false, error = null) + } else { + prev + } + _state.value = base.copy(isLoading = true, error = null) try { val dates = diaryApi.listDates(clubId) - _state.value = _state.value.copy(isLoading = false, dates = dates) + _state.value = base.copy( + isLoading = false, + dates = dates, + lastLoadedClubId = clubId, + fromOfflineCache = false, + error = null, + ) } catch (t: Throwable) { - _state.value = _state.value.copy(isLoading = false, error = t.toUserMessage("Fehler beim Laden des Tagebuchs")) + if (base.lastLoadedClubId == clubId && base.dates.isNotEmpty()) { + _state.value = base.copy(isLoading = false, fromOfflineCache = true, error = null) + } else { + _state.value = base.copy( + isLoading = false, + error = t.toUserMessage("Fehler beim Laden des Tagebuchs"), + fromOfflineCache = false, + ) + } } } diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/DiaryState.kt b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/DiaryState.kt index f841fdab..2d1faec6 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/DiaryState.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/DiaryState.kt @@ -6,5 +6,9 @@ data class DiaryState( val dates: List = emptyList(), val isLoading: Boolean = false, val error: String? = null, + /** Zuletzt erfolgreich geladener Verein (für Offline-Cache). */ + val lastLoadedClubId: Int? = null, + /** `true`, wenn die angezeigte Tagebuch-Liste von einem fehlgeschlagenen Refresh stammt. */ + val fromOfflineCache: Boolean = false, ) diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/MembersManager.kt b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/MembersManager.kt index 814ea1bc..7aa25f3d 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/MembersManager.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/MembersManager.kt @@ -32,7 +32,13 @@ class MembersManager( val state: StateFlow = _state.asStateFlow() suspend fun loadMembers(clubId: Int) { - _state.value = _state.value.copy(isLoading = true, error = null) + val prev = _state.value + val base = if (prev.lastLoadedClubId != null && prev.lastLoadedClubId != clubId) { + prev.copy(members = emptyList(), lastLoadedClubId = null, fromOfflineCache = false, error = null) + } else { + prev + } + _state.value = base.copy(isLoading = true, error = null) try { val members = membersApi.listMembers(clubId) val merged = runCatching { trainingStatsApi.getStats(clubId) } @@ -41,9 +47,23 @@ class MembersManager( onFailure = { members }, ) .sortedWith(compareBy { it.lastName.lowercase() }.thenBy { it.firstName.lowercase() }) - _state.value = _state.value.copy(members = merged, isLoading = false) + _state.value = base.copy( + members = merged, + isLoading = false, + lastLoadedClubId = clubId, + fromOfflineCache = false, + error = null, + ) } catch (t: Throwable) { - _state.value = _state.value.copy(isLoading = false, error = t.toUserMessage("Mitglieder konnten nicht geladen werden")) + if (base.lastLoadedClubId == clubId && base.members.isNotEmpty()) { + _state.value = base.copy(isLoading = false, fromOfflineCache = true, error = null) + } else { + _state.value = base.copy( + isLoading = false, + error = t.toUserMessage("Mitglieder konnten nicht geladen werden"), + fromOfflineCache = false, + ) + } } } diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/MembersState.kt b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/MembersState.kt index 488338d8..de29fd39 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/MembersState.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/state/MembersState.kt @@ -6,5 +6,7 @@ data class MembersState( val members: List = emptyList(), val isLoading: Boolean = false, val error: String? = null, + val lastLoadedClubId: Int? = null, + val fromOfflineCache: Boolean = false, ) diff --git a/scripts/generate-mobile-i18n.js b/scripts/generate-mobile-i18n.js index 36d1ce02..a67863e3 100644 --- a/scripts/generate-mobile-i18n.js +++ b/scripts/generate-mobile-i18n.js @@ -75,6 +75,7 @@ const MOBILE_STRINGS = { 'mobile.noMembers': 'Keine Mitglieder gefunden', 'mobile.noResults': 'Keine Treffer', 'mobile.noTimes': 'Keine Zeiten', + 'mobile.offlineCacheHint': 'Keine Verbindung – zuletzt geladene Daten.', 'mobile.participationTop': 'Top Teilnahmen', 'mobile.refresh': 'Aktualisieren', 'mobile.requestedAccess': 'Angefragt', @@ -115,6 +116,7 @@ const MOBILE_STRINGS = { 'mobile.noMembers': 'No members found', 'mobile.noResults': 'No results', 'mobile.noTimes': 'No times', + 'mobile.offlineCacheHint': 'No connection – showing last loaded data.', 'mobile.participationTop': 'Top attendance', 'mobile.refresh': 'Refresh', 'mobile.requestedAccess': 'Requested', @@ -131,6 +133,33 @@ const MOBILE_STRINGS = { 'mobile.inactive': 'Inactive', 'mobile.user': 'User', }, + es: { + 'mobile.offlineCacheHint': 'Sin conexión: se muestran los datos cargados por última vez.', + }, + fr: { + 'mobile.offlineCacheHint': 'Pas de connexion – affichage des données du dernier chargement.', + }, + it: { + 'mobile.offlineCacheHint': 'Nessuna connessione – dati dell’ultimo caricamento.', + }, + pl: { + 'mobile.offlineCacheHint': 'Brak połączenia – wyświetlane ostatnio wczytane dane.', + }, + ja: { + 'mobile.offlineCacheHint': 'ネットワークに接続されていません。最後に読み込んだデータを表示しています。', + }, + th: { + 'mobile.offlineCacheHint': 'ไม่มีการเชื่อมต่อ – แสดงข้อมูลที่โหลดล่าสุด', + }, + tl: { + 'mobile.offlineCacheHint': 'Walang koneksyon – ipinapakita ang huling na-load na datos.', + }, + fil: { + 'mobile.offlineCacheHint': 'Walang koneksyon – ipinapakita ang huling na-load na datos.', + }, + zh: { + 'mobile.offlineCacheHint': '无网络连接,显示上次加载的数据。', + }, }; /** Kalender-Tab (CalendarScreen.kt): eigene Übersetzungen pro Locale, damit nicht nur Deutsch aus dem Basis-Flat greift. */