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