feat(Networking): enhance offline handling and localization for diary and members data
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s

- 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.
This commit is contained in:
Torsten Schulz (local)
2026-05-15 08:10:01 +02:00
parent 2f15827658
commit 320010b94e
12 changed files with 198 additions and 8 deletions

View File

@@ -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\"")
}

View File

@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".app.MainApplication"

View File

@@ -2,6 +2,7 @@ package de.tsschulz.tt_tagebuch.app
import android.content.Context
import de.tsschulz.tt_tagebuch.BuildConfig
import de.tsschulz.tt_tagebuch.app.net.NetworkConnectivityHolder
import de.tsschulz.tt_tagebuch.shared.api.BillingApi
import de.tsschulz.tt_tagebuch.shared.api.CalendarHolidayApi
import de.tsschulz.tt_tagebuch.shared.api.AccidentApi
@@ -68,6 +69,7 @@ class AppDependencies(context: Context) {
* (z. B. Club wählen → anderer Screen), im Gegensatz zu [rememberCoroutineScope].
*/
val applicationScope = CoroutineScope(applicationJob + Dispatchers.Main.immediate)
val networkConnectivity = NetworkConnectivityHolder(context.applicationContext)
val apiConfig = ApiConfig(baseUrl = BuildConfig.BACKEND_BASE_URL)
val unauthorizedEvents = MutableStateFlow(0)
private val tokenProvider = MutableTokenProvider()

View File

@@ -0,0 +1,44 @@
package de.tsschulz.tt_tagebuch.app.net
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Beobachtet die Standard-Netzwerkverbindung (z. B. für Reconnect-Refresh von Tagebuch/Mitgliedern).
*/
class NetworkConnectivityHolder(context: Context) {
private val appContext = context.applicationContext
private val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val _connected = MutableStateFlow(readConnected())
val connected: StateFlow<Boolean> = _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)
}
}

View File

@@ -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),

View File

@@ -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"

View File

@@ -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 dellultimo 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",

View File

@@ -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,
)
}
}
}

View File

@@ -6,5 +6,9 @@ data class DiaryState(
val dates: List<DiaryDate> = 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,
)

View File

@@ -32,7 +32,13 @@ class MembersManager(
val state: StateFlow<MembersState> = _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<Member> { 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,
)
}
}
}

View File

@@ -6,5 +6,7 @@ data class MembersState(
val members: List<Member> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val lastLoadedClubId: Int? = null,
val fromOfflineCache: Boolean = false,
)

View File

@@ -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 dellultimo 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. */