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

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