dev #41

Merged
admin merged 22 commits from dev into main 2026-06-10 16:49:08 +02:00
19 changed files with 419 additions and 205 deletions
Showing only changes of commit e517720b03 - Show all commits

View File

@@ -0,0 +1,49 @@
package de.harheimertc.data
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ConnectivityMonitor @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val _online = MutableStateFlow(hasInternetAccess())
val online: StateFlow<Boolean> = _online.asStateFlow()
init {
scope.launch { poll() }
}
private suspend fun poll() {
while (currentCoroutineContext().isActive) {
val current = hasInternetAccess()
if (_online.value != current) {
_online.value = current
}
delay(10_000L)
}
}
private fun hasInternetAccess(): Boolean {
val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return false
val network = manager.activeNetwork ?: return false
val capabilities = manager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
}

View File

@@ -24,27 +24,27 @@ class GalleryRepository @Inject constructor(
@param:ApplicationContext private val context: Context, @param:ApplicationContext private val context: Context,
) { ) {
suspend fun hasPublicImages(): Result<Boolean> = runCatching { suspend fun hasPublicImages(): Result<Boolean> = runCatching {
retryOnNetworkFailure {
val response = api.galerieList(page = 1, perPage = 1) val response = api.galerieList(page = 1, perPage = 1)
if (!response.isSuccessful) error("HTTP ${response.code()}") if (!response.isSuccessful) error("HTTP ${response.code()}")
response.body()?.images.orEmpty().isNotEmpty() response.body()?.images.orEmpty().isNotEmpty()
} }
}
suspend fun fetchImages(page: Int = 1, perPage: Int = 60): Result<GalleryPage> { suspend fun fetchImages(page: Int = 1, perPage: Int = 60): Result<GalleryPage> {
return try { return runCatching {
retryOnNetworkFailure {
val resp = api.galerieList(page = page, perPage = perPage) val resp = api.galerieList(page = page, perPage = perPage)
if (resp.isSuccessful) { if (resp.isSuccessful) {
val body = resp.body() val body = resp.body()
Result.success(
GalleryPage( GalleryPage(
images = body?.images.orEmpty().map { it.toGalleryImage() }, images = body?.images.orEmpty().map { it.toGalleryImage() },
pagination = body?.pagination ?: GalleryPaginationDto(), pagination = body?.pagination ?: GalleryPaginationDto(),
),
) )
} else { } else {
Result.failure(Exception("HTTP ${resp.code()}")) error("HTTP ${resp.code()}")
}
} }
} catch (e: Exception) {
Result.failure(e)
} }
} }

View File

@@ -31,6 +31,7 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
val diagnostics = mutableListOf<String>() val diagnostics = mutableListOf<String>()
val termine = runCatching { val termine = runCatching {
retryOnNetworkFailure {
val response = api.termine() val response = api.termine()
if (!response.isSuccessful) { if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty() val errorBody = response.errorBody()?.string().orEmpty()
@@ -44,6 +45,7 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
error("Termine konnten nicht geladen werden (HTTP ${response.code()}).") error("Termine konnten nicht geladen werden (HTTP ${response.code()}).")
} }
response.body()?.termine.orEmpty() response.body()?.termine.orEmpty()
}
}.onFailure { error -> }.onFailure { error ->
captureLoadIssue("fetchHomeData.termine", error) captureLoadIssue("fetchHomeData.termine", error)
if (diagnostics.none { it.contains("GET /api/termine") }) { if (diagnostics.none { it.contains("GET /api/termine") }) {
@@ -58,6 +60,7 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
}.getOrDefault(emptyList()) }.getOrDefault(emptyList())
val spielplanResponse = runCatching { val spielplanResponse = runCatching {
retryOnNetworkFailure {
val response = api.spielplan() val response = api.spielplan()
if (!response.isSuccessful) { if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty() val errorBody = response.errorBody()?.string().orEmpty()
@@ -71,6 +74,7 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
error("Spielplan konnte nicht geladen werden (HTTP ${response.code()}).") error("Spielplan konnte nicht geladen werden (HTTP ${response.code()}).")
} }
response.body() response.body()
}
}.onFailure { error -> }.onFailure { error ->
captureLoadIssue("fetchHomeData.spielplan", error) captureLoadIssue("fetchHomeData.spielplan", error)
if (diagnostics.none { it.contains("GET /api/spielplan") }) { if (diagnostics.none { it.contains("GET /api/spielplan") }) {
@@ -86,6 +90,7 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
val spiele = spielplanResponse?.data.orEmpty() val spiele = spielplanResponse?.data.orEmpty()
val news = runCatching { val news = runCatching {
retryOnNetworkFailure {
val response = api.publicNews() val response = api.publicNews()
if (!response.isSuccessful) { if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty() val errorBody = response.errorBody()?.string().orEmpty()
@@ -99,6 +104,7 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
error("News konnten nicht geladen werden (HTTP ${response.code()}).") error("News konnten nicht geladen werden (HTTP ${response.code()}).")
} }
response.body()?.news.orEmpty() response.body()?.news.orEmpty()
}
}.onFailure { error -> }.onFailure { error ->
captureLoadIssue("fetchHomeData.news", error) captureLoadIssue("fetchHomeData.news", error)
if (diagnostics.none { it.contains("GET /api/news-public") }) { if (diagnostics.none { it.contains("GET /api/news-public") }) {
@@ -113,6 +119,7 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
}.getOrDefault(emptyList()) }.getOrDefault(emptyList())
val homepageSections = runCatching { val homepageSections = runCatching {
retryOnNetworkFailure {
val response = api.config() val response = api.config()
if (!response.isSuccessful) { if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty() val errorBody = response.errorBody()?.string().orEmpty()
@@ -126,6 +133,7 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
error("Konfiguration konnte nicht geladen werden (HTTP ${response.code()}).") error("Konfiguration konnte nicht geladen werden (HTTP ${response.code()}).")
} }
response.body()?.homepage?.sections.orEmpty() response.body()?.homepage?.sections.orEmpty()
}
}.onFailure { error -> }.onFailure { error ->
captureLoadIssue("fetchHomeData.config", error) captureLoadIssue("fetchHomeData.config", error)
if (diagnostics.none { it.contains("GET /api/config") }) { if (diagnostics.none { it.contains("GET /api/config") }) {
@@ -140,6 +148,7 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
}.getOrDefault(emptyList()) }.getOrDefault(emptyList())
val heroImageUrl = runCatching { val heroImageUrl = runCatching {
retryOnNetworkFailure {
val response = api.heroImages() val response = api.heroImages()
if (!response.isSuccessful) { if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty() val errorBody = response.errorBody()?.string().orEmpty()
@@ -154,6 +163,7 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
} }
val variants = response.body()?.variants.orEmpty() val variants = response.body()?.variants.orEmpty()
pickRandomHeroImage(variants) pickRandomHeroImage(variants)
}
}.onFailure { error -> }.onFailure { error ->
captureLoadIssue("fetchHomeData.heroImages", error) captureLoadIssue("fetchHomeData.heroImages", error)
if (diagnostics.none { it.contains("GET /api/hero-images") }) { if (diagnostics.none { it.contains("GET /api/hero-images") }) {
@@ -187,9 +197,11 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
} }
suspend fun fetchSpielplanForSeason(season: String): Result<SpielplanResponse> = runCatching { suspend fun fetchSpielplanForSeason(season: String): Result<SpielplanResponse> = runCatching {
retryOnNetworkFailure {
val response = api.spielplan(season) val response = api.spielplan(season)
if (!response.isSuccessful) error("HTTP ${response.code()}") if (!response.isSuccessful) error("HTTP ${response.code()}")
response.body() ?: error("Leere Antwort") response.body() ?: error("Leere Antwort")
}
}.onFailure { error -> }.onFailure { error ->
Sentry.withScope { scope -> Sentry.withScope { scope ->
scope.setTag("repository", "HomeRepository") scope.setTag("repository", "HomeRepository")

View File

@@ -23,7 +23,7 @@ class LoginRepository @Inject constructor(
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching { suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching {
val endpoint = "api/auth/login" val endpoint = "api/auth/login"
val requestPreview = "{email=\"${maskEmail(email)}\", client=\"android\", deviceName=\"Harheimer TC Android-App\"}" val requestPreview = "{email=\"${maskEmail(email)}\", client=\"android\", deviceName=\"Harheimer TC Android-App\"}"
val response = api.login(LoginRequest(email.trim(), password)) val response = retryOnNetworkFailure { api.login(LoginRequest(email.trim(), password)) }
if (!response.isSuccessful) { if (!response.isSuccessful) {
val body = response.errorBody()?.string().orEmpty() val body = response.errorBody()?.string().orEmpty()
val serverMessage = extractServerMessage(body) val serverMessage = extractServerMessage(body)
@@ -71,11 +71,11 @@ class LoginRepository @Inject constructor(
return@runCatching AuthStatusResponse() return@runCatching AuthStatusResponse()
} }
var response = api.authStatus() var response = retryOnNetworkFailure { api.authStatus() }
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.") if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
var status = response.body() ?: AuthStatusResponse() var status = response.body() ?: AuthStatusResponse()
if (!status.isLoggedIn && authRepository.getRefreshToken() != null && sessionRefresher.refreshAccessToken()) { if (!status.isLoggedIn && authRepository.getRefreshToken() != null && sessionRefresher.refreshAccessToken()) {
response = api.authStatus() response = retryOnNetworkFailure { api.authStatus() }
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.") if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
status = response.body() ?: AuthStatusResponse() status = response.body() ?: AuthStatusResponse()
} }
@@ -93,16 +93,20 @@ class LoginRepository @Inject constructor(
} }
suspend fun resetPassword(email: String): Result<AuthMessageResponse> = runCatching { suspend fun resetPassword(email: String): Result<AuthMessageResponse> = runCatching {
retryOnNetworkFailure {
val response = api.resetPassword(ResetPasswordRequest(email.trim())) val response = api.resetPassword(ResetPasswordRequest(email.trim()))
if (!response.isSuccessful) error("Anfrage konnte nicht gesendet werden.") if (!response.isSuccessful) error("Anfrage konnte nicht gesendet werden.")
response.body() ?: error("Leere Antwort") response.body() ?: error("Leere Antwort")
} }
}
suspend fun register(request: RegistrationRequest): Result<AuthMessageResponse> = runCatching { suspend fun register(request: RegistrationRequest): Result<AuthMessageResponse> = runCatching {
retryOnNetworkFailure {
val response = api.register(request) val response = api.register(request)
if (!response.isSuccessful) error("Registrierung fehlgeschlagen.") if (!response.isSuccessful) error("Registrierung fehlgeschlagen.")
response.body() ?: error("Leere Antwort") response.body() ?: error("Leere Antwort")
} }
}
private fun extractServerMessage(raw: String): String? { private fun extractServerMessage(raw: String): String? {
if (raw.isBlank()) return null if (raw.isBlank()) return null

View File

@@ -26,16 +26,20 @@ data class Mannschaft(
@Singleton @Singleton
class MannschaftenRepository @Inject constructor(private val api: ApiService) { class MannschaftenRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchMannschaften(season: String? = null): Result<List<Mannschaft>> = runCatching { suspend fun fetchMannschaften(season: String? = null): Result<List<Mannschaft>> = runCatching {
retryOnNetworkFailure {
val response = api.mannschaften(season) val response = api.mannschaften(season)
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.") if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
parseCsv(response.body()?.string().orEmpty()) parseCsv(response.body()?.string().orEmpty())
} }
}
suspend fun fetchSeasons(): Result<MannschaftenSeasonsResponse> = runCatching { suspend fun fetchSeasons(): Result<MannschaftenSeasonsResponse> = runCatching {
retryOnNetworkFailure {
val response = api.mannschaftenSeasons() val response = api.mannschaftenSeasons()
if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.") if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.")
response.body() ?: error("Saisons konnten nicht geladen werden.") response.body() ?: error("Saisons konnten nicht geladen werden.")
} }
}
private fun parseCsv(csv: String): List<Mannschaft> = csv.lineSequence() private fun parseCsv(csv: String): List<Mannschaft> = csv.lineSequence()
.filter(String::isNotBlank) .filter(String::isNotBlank)

View File

@@ -0,0 +1,33 @@
package de.harheimertc.repositories
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import javax.net.ssl.SSLException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
internal suspend fun <T> retryOnNetworkFailure(
retryDelayMillis: Long = 10_000L,
block: suspend () -> T,
): T {
while (true) {
try {
return block()
} catch (error: Throwable) {
if (error is CancellationException) throw error
if (!error.isRetryableNetworkError()) throw error
delay(retryDelayMillis)
}
}
}
private fun Throwable.isRetryableNetworkError(): Boolean = when (this) {
is UnknownHostException,
is ConnectException,
is NoRouteToHostException,
is SocketTimeoutException,
is SSLException -> true
else -> false
}

View File

@@ -8,10 +8,12 @@ import javax.inject.Inject
class NewsletterRepository @Inject constructor(private val api: ApiService) { class NewsletterRepository @Inject constructor(private val api: ApiService) {
suspend fun groups(): Result<NewsletterGroupsResponse> = runCatching { suspend fun groups(): Result<NewsletterGroupsResponse> = runCatching {
retryOnNetworkFailure {
val response = api.publicNewsletterGroups() val response = api.publicNewsletterGroups()
if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.") if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort vom Server.") response.body() ?: error("Leere Antwort vom Server.")
} }
}
suspend fun subscribe(groupId: String, email: String, name: String?): Result<AuthMessageResponse> = runCatching { suspend fun subscribe(groupId: String, email: String, name: String?): Result<AuthMessageResponse> = runCatching {
val response = api.subscribeNewsletter(NewsletterSubscriptionRequest(groupId, email.trim(), name?.trim().orEmpty())) val response = api.subscribeNewsletter(NewsletterSubscriptionRequest(groupId, email.trim(), name?.trim().orEmpty()))
@@ -26,8 +28,10 @@ class NewsletterRepository @Inject constructor(private val api: ApiService) {
} }
suspend fun confirm(token: String): Result<AuthMessageResponse> = runCatching { suspend fun confirm(token: String): Result<AuthMessageResponse> = runCatching {
retryOnNetworkFailure {
val response = api.confirmNewsletter(token) val response = api.confirmNewsletter(token)
if (!response.isSuccessful) error("Newsletter-Bestätigung fehlgeschlagen.") if (!response.isSuccessful) error("Newsletter-Bestätigung fehlgeschlagen.")
response.body() ?: AuthMessageResponse(success = true, message = "Newsletter-Anmeldung bestätigt.") response.body() ?: AuthMessageResponse(success = true, message = "Newsletter-Anmeldung bestätigt.")
} }
} }
}

View File

@@ -28,6 +28,7 @@ class PasskeyRepository @Inject constructor(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
) { ) {
suspend fun login(context: Context, email: String?): Result<LoginResponse> = runCatching { suspend fun login(context: Context, email: String?): Result<LoginResponse> = runCatching {
retryOnNetworkFailure {
val optionsResponse = api.passkeyAuthenticationOptions( val optionsResponse = api.passkeyAuthenticationOptions(
PasskeyAuthenticationOptionsRequest(email = email?.trim()?.takeIf(String::isNotBlank)), PasskeyAuthenticationOptionsRequest(email = email?.trim()?.takeIf(String::isNotBlank)),
) )
@@ -59,15 +60,19 @@ class PasskeyRepository @Inject constructor(
?: error("Der Server hat kein Zugriffstoken geliefert.") ?: error("Der Server hat kein Zugriffstoken geliefert.")
authRepository.setSession(token, body.refreshToken, body.sessionId) authRepository.setSession(token, body.refreshToken, body.sessionId)
body body
}
}.recoverCredentialCancellation("Passkey-Anmeldung abgebrochen.") }.recoverCredentialCancellation("Passkey-Anmeldung abgebrochen.")
suspend fun list(): Result<PasskeysResponse> = runCatching { suspend fun list(): Result<PasskeysResponse> = runCatching {
retryOnNetworkFailure {
val response = api.passkeys() val response = api.passkeys()
if (!response.isSuccessful) error("Passkeys konnten nicht geladen werden.") if (!response.isSuccessful) error("Passkeys konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort") response.body() ?: error("Leere Antwort")
} }
}
suspend fun add(context: Context, name: String = "Android-App"): Result<AuthMessageResponse> = runCatching { suspend fun add(context: Context, name: String = "Android-App"): Result<AuthMessageResponse> = runCatching {
retryOnNetworkFailure {
val optionsResponse = api.passkeyRegistrationOptions(PasskeyRegistrationOptionsRequest()) val optionsResponse = api.passkeyRegistrationOptions(PasskeyRegistrationOptionsRequest())
if (!optionsResponse.isSuccessful) error("Passkey-Erstellung konnte nicht gestartet werden.") if (!optionsResponse.isSuccessful) error("Passkey-Erstellung konnte nicht gestartet werden.")
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options") val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
@@ -90,6 +95,7 @@ class PasskeyRepository @Inject constructor(
) )
if (!response.isSuccessful) error("Passkey konnte nicht hinzugefügt werden.") if (!response.isSuccessful) error("Passkey konnte nicht hinzugefügt werden.")
response.body() ?: error("Leere Antwort") response.body() ?: error("Leere Antwort")
}
}.recoverCredentialCancellation("Passkey-Erstellung abgebrochen.") }.recoverCredentialCancellation("Passkey-Erstellung abgebrochen.")
suspend fun remove(credentialId: String): Result<AuthMessageResponse> = runCatching { suspend fun remove(credentialId: String): Result<AuthMessageResponse> = runCatching {

View File

@@ -9,14 +9,18 @@ import javax.inject.Singleton
@Singleton @Singleton
class ProfileRepository @Inject constructor(private val api: ApiService) { class ProfileRepository @Inject constructor(private val api: ApiService) {
suspend fun load(): Result<ProfileResponse> = runCatching { suspend fun load(): Result<ProfileResponse> = runCatching {
retryOnNetworkFailure {
val response = api.profile() val response = api.profile()
if (!response.isSuccessful) error("Profil konnte nicht geladen werden.") if (!response.isSuccessful) error("Profil konnte nicht geladen werden.")
response.body() ?: error("Leere Antwort") response.body() ?: error("Leere Antwort")
} }
}
suspend fun save(request: ProfileUpdateRequest): Result<ProfileResponse> = runCatching { suspend fun save(request: ProfileUpdateRequest): Result<ProfileResponse> = runCatching {
retryOnNetworkFailure {
val response = api.updateProfile(request) val response = api.updateProfile(request)
if (!response.isSuccessful) error("Profil konnte nicht gespeichert werden.") if (!response.isSuccessful) error("Profil konnte nicht gespeichert werden.")
response.body() ?: error("Leere Antwort") response.body() ?: error("Leere Antwort")
} }
} }
}

View File

@@ -31,12 +31,15 @@ data class MeisterschaftResult(
@Singleton @Singleton
class PublicPagesRepository @Inject constructor(private val api: ApiService) { class PublicPagesRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching { suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
retryOnNetworkFailure {
val response = api.config() val response = api.config()
if (!response.isSuccessful) error("Inhalte konnten nicht geladen werden.") if (!response.isSuccessful) error("Inhalte konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort") response.body() ?: error("Leere Antwort")
} }
}
suspend fun fetchSpielsysteme(): Result<List<Spielsystem>> = runCatching { suspend fun fetchSpielsysteme(): Result<List<Spielsystem>> = runCatching {
retryOnNetworkFailure {
val response = api.spielsysteme() val response = api.spielsysteme()
if (!response.isSuccessful) error("Spielsysteme konnten nicht geladen werden.") if (!response.isSuccessful) error("Spielsysteme konnten nicht geladen werden.")
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values -> parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
@@ -52,8 +55,10 @@ class PublicPagesRepository @Inject constructor(private val api: ApiService) {
) )
} }
} }
}
suspend fun fetchVereinsmeisterschaften(): Result<List<MeisterschaftResult>> = runCatching { suspend fun fetchVereinsmeisterschaften(): Result<List<MeisterschaftResult>> = runCatching {
retryOnNetworkFailure {
val response = api.vereinsmeisterschaften() val response = api.vereinsmeisterschaften()
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.") if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.")
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values -> parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
@@ -71,6 +76,7 @@ class PublicPagesRepository @Inject constructor(private val api: ApiService) {
} }
} }
} }
}
private fun parseCsv(csv: String): List<List<String>> = private fun parseCsv(csv: String): List<List<String>> =
csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList() csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList()

View File

@@ -9,14 +9,17 @@ import javax.inject.Singleton
@Singleton @Singleton
class SpielplanRepository @Inject constructor(private val api: ApiService) { class SpielplanRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchSpielplan(season: String? = null): Result<SpielplanResponse> = runCatching { suspend fun fetchSpielplan(season: String? = null): Result<SpielplanResponse> = runCatching {
retryOnNetworkFailure {
val response = api.spielplan(season) val response = api.spielplan(season)
if (!response.isSuccessful) error("HTTP ${response.code()}") if (!response.isSuccessful) error("HTTP ${response.code()}")
val body = response.body() ?: error("Leere Antwort") val body = response.body() ?: error("Leere Antwort")
if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.") if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.")
body body
} }
}
suspend fun fetchTeamTable(team: String, season: String? = null): Result<TeamTableResponse> = runCatching { suspend fun fetchTeamTable(team: String, season: String? = null): Result<TeamTableResponse> = runCatching {
retryOnNetworkFailure {
val response = api.spielplanTable(team, season) val response = api.spielplanTable(team, season)
if (!response.isSuccessful) error("HTTP ${response.code()}") if (!response.isSuccessful) error("HTTP ${response.code()}")
val body = response.body() ?: error("Leere Antwort") val body = response.body() ?: error("Leere Antwort")
@@ -24,3 +27,4 @@ class SpielplanRepository @Inject constructor(private val api: ApiService) {
body body
} }
} }
}

View File

@@ -8,8 +8,10 @@ import javax.inject.Singleton
@Singleton @Singleton
class TermineRepository @Inject constructor(private val api: ApiService) { class TermineRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchTermine(): Result<List<TerminDto>> = runCatching { suspend fun fetchTermine(): Result<List<TerminDto>> = runCatching {
retryOnNetworkFailure {
val response = api.termine() val response = api.termine()
if (!response.isSuccessful) error("HTTP ${response.code()}") if (!response.isSuccessful) error("HTTP ${response.code()}")
response.body()?.termine.orEmpty() response.body()?.termine.orEmpty()
} }
} }
}

View File

@@ -8,8 +8,10 @@ import javax.inject.Singleton
@Singleton @Singleton
class TrainingRepository @Inject constructor(private val api: ApiService) { class TrainingRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching { suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
retryOnNetworkFailure {
val response = api.config() val response = api.config()
if (!response.isSuccessful) error("Trainingsinformationen konnten nicht geladen werden.") if (!response.isSuccessful) error("Trainingsinformationen konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort") response.body() ?: error("Leere Antwort")
} }
} }
}

View File

@@ -2,10 +2,16 @@ package de.harheimertc.ui.navigation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
@@ -35,6 +41,16 @@ fun NavGraph(
BoxWithConstraints(modifier = Modifier.fillMaxSize()) { BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val persistentNavigation = maxWidth >= 600.dp val persistentNavigation = maxWidth >= 600.dp
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
navigationState.connectionNote?.let { message ->
Surface(color = Color(0xFFFFF4E5), modifier = Modifier.fillMaxWidth()) {
Text(
text = message,
color = Color(0xFF7C2D12),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
)
}
}
if (persistentNavigation) { if (persistentNavigation) {
AppNavigationHeader( AppNavigationHeader(
selectedRoute = currentRoute, selectedRoute = currentRoute,

View File

@@ -3,6 +3,7 @@ package de.harheimertc.ui.navigation
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ConnectivityMonitor
import de.harheimertc.repositories.AuthRepository import de.harheimertc.repositories.AuthRepository
import de.harheimertc.repositories.GalleryRepository import de.harheimertc.repositories.GalleryRepository
import de.harheimertc.repositories.LoginRepository import de.harheimertc.repositories.LoginRepository
@@ -19,6 +20,7 @@ data class NavigationUiState(
val hasGalleryImages: Boolean = false, val hasGalleryImages: Boolean = false,
val loggedIn: Boolean = false, val loggedIn: Boolean = false,
val roles: Set<String> = emptySet(), val roles: Set<String> = emptySet(),
val connectionNote: String? = null,
) { ) {
val isAdmin: Boolean get() = "admin" in roles val isAdmin: Boolean get() = "admin" in roles
val canAccessNewsletter: Boolean get() = roles.any { it in setOf("admin", "vorstand", "newsletter") } val canAccessNewsletter: Boolean get() = roles.any { it in setOf("admin", "vorstand", "newsletter") }
@@ -32,12 +34,22 @@ class NavigationViewModel @Inject constructor(
private val galleryRepository: GalleryRepository, private val galleryRepository: GalleryRepository,
private val loginRepository: LoginRepository, private val loginRepository: LoginRepository,
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val connectivityMonitor: ConnectivityMonitor,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(NavigationUiState()) private val _state = MutableStateFlow(NavigationUiState())
val state: StateFlow<NavigationUiState> = _state val state: StateFlow<NavigationUiState> = _state
init { init {
loadNavigationData() loadNavigationData()
viewModelScope.launch {
var wasOnline: Boolean? = null
connectivityMonitor.online.collect { online ->
_state.value = _state.value.copy(
connectionNote = if (online) null else "Keine Verbindung. Die App versucht alle 10 Sekunden erneut zu laden.",
)
wasOnline = online
}
}
} }
fun loadNavigationData() { fun loadNavigationData() {
@@ -52,6 +64,7 @@ class NavigationViewModel @Inject constructor(
hasGalleryImages = gallery.await(), hasGalleryImages = gallery.await(),
loggedIn = hasStoredSession || status.isLoggedIn, loggedIn = hasStoredSession || status.isLoggedIn,
roles = (status.roles + status.user?.roles.orEmpty()).toSet(), roles = (status.roles + status.user?.roles.orEmpty()).toSet(),
connectionNote = null,
) )
} }
} }
@@ -63,6 +76,7 @@ class NavigationViewModel @Inject constructor(
_state.value = _state.value.copy( _state.value = _state.value.copy(
loggedIn = hasStoredSession || status.isLoggedIn, loggedIn = hasStoredSession || status.isLoggedIn,
roles = (status.roles + status.user?.roles.orEmpty()).toSet(), roles = (status.roles + status.user?.roles.orEmpty()).toSet(),
connectionNote = _state.value.connectionNote,
) )
} }
} }
@@ -73,6 +87,7 @@ class NavigationViewModel @Inject constructor(
_state.value = _state.value.copy( _state.value = _state.value.copy(
loggedIn = false, loggedIn = false,
roles = emptySet(), roles = emptySet(),
connectionNote = _state.value.connectionNote,
) )
onComplete() onComplete()
} }

View File

@@ -3,6 +3,7 @@ package de.harheimertc.ui.screens.cms
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ConnectivityMonitor
import de.harheimertc.data.CmsUserDto import de.harheimertc.data.CmsUserDto
import de.harheimertc.data.ConfigResponse import de.harheimertc.data.ConfigResponse
import de.harheimertc.data.ContactRequestDto import de.harheimertc.data.ContactRequestDto
@@ -43,12 +44,22 @@ data class CmsUiState(
@HiltViewModel @HiltViewModel
class CmsViewModel @Inject constructor( class CmsViewModel @Inject constructor(
private val repository: CmsRepository, private val repository: CmsRepository,
private val connectivityMonitor: ConnectivityMonitor,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(CmsUiState()) private val _state = MutableStateFlow(CmsUiState())
val state: StateFlow<CmsUiState> = _state val state: StateFlow<CmsUiState> = _state
init { init {
load() load()
viewModelScope.launch {
var wasOnline: Boolean? = null
connectivityMonitor.online.collect { online ->
if (online && wasOnline == false) {
load()
}
wasOnline = online
}
}
} }
fun load() { fun load() {

View File

@@ -3,6 +3,7 @@ package de.harheimertc.ui.screens.memberarea
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ConnectivityMonitor
import de.harheimertc.data.AuthStatusResponse import de.harheimertc.data.AuthStatusResponse
import de.harheimertc.data.MemberDto import de.harheimertc.data.MemberDto
import de.harheimertc.data.NewsDto import de.harheimertc.data.NewsDto
@@ -24,12 +25,22 @@ data class MembersUiState(
@HiltViewModel @HiltViewModel
class MembersViewModel @Inject constructor( class MembersViewModel @Inject constructor(
private val repository: MemberAreaRepository, private val repository: MemberAreaRepository,
private val connectivityMonitor: ConnectivityMonitor,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(MembersUiState()) private val _state = MutableStateFlow(MembersUiState())
val state: StateFlow<MembersUiState> = _state val state: StateFlow<MembersUiState> = _state
init { init {
load() load()
viewModelScope.launch {
var wasOnline: Boolean? = null
connectivityMonitor.online.collect { online ->
if (online && wasOnline == false) {
load()
}
wasOnline = online
}
}
} }
fun updateQuery(query: String) { fun updateQuery(query: String) {
@@ -87,12 +98,22 @@ data class MemberNewsUiState(
@HiltViewModel @HiltViewModel
class MemberNewsViewModel @Inject constructor( class MemberNewsViewModel @Inject constructor(
private val repository: MemberAreaRepository, private val repository: MemberAreaRepository,
private val connectivityMonitor: ConnectivityMonitor,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(MemberNewsUiState()) private val _state = MutableStateFlow(MemberNewsUiState())
val state: StateFlow<MemberNewsUiState> = _state val state: StateFlow<MemberNewsUiState> = _state
init { init {
load() load()
viewModelScope.launch {
var wasOnline: Boolean? = null
connectivityMonitor.online.collect { online ->
if (online && wasOnline == false) {
load()
}
wasOnline = online
}
}
} }
fun load() { fun load() {
@@ -118,12 +139,22 @@ data class QttrUiState(
class QttrViewModel @Inject constructor( class QttrViewModel @Inject constructor(
private val repository: MemberAreaRepository, private val repository: MemberAreaRepository,
private val loginRepository: LoginRepository, private val loginRepository: LoginRepository,
private val connectivityMonitor: ConnectivityMonitor,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(QttrUiState()) private val _state = MutableStateFlow(QttrUiState())
val state: StateFlow<QttrUiState> = _state val state: StateFlow<QttrUiState> = _state
init { init {
load() load()
viewModelScope.launch {
var wasOnline: Boolean? = null
connectivityMonitor.online.collect { online ->
if (online && wasOnline == false) {
load()
}
wasOnline = online
}
}
} }
fun load() { fun load() {

View File

@@ -3,6 +3,7 @@ package de.harheimertc.ui.screens.memberarea
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ConnectivityMonitor
import de.harheimertc.data.BirthdayDto import de.harheimertc.data.BirthdayDto
import de.harheimertc.repositories.MemberAreaRepository import de.harheimertc.repositories.MemberAreaRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -19,12 +20,22 @@ data class MemberAreaUiState(
@HiltViewModel @HiltViewModel
class MemberAreaViewModel @Inject constructor( class MemberAreaViewModel @Inject constructor(
private val repository: MemberAreaRepository, private val repository: MemberAreaRepository,
private val connectivityMonitor: ConnectivityMonitor,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(MemberAreaUiState()) private val _state = MutableStateFlow(MemberAreaUiState())
val state: StateFlow<MemberAreaUiState> = _state val state: StateFlow<MemberAreaUiState> = _state
init { init {
loadBirthdays() loadBirthdays()
viewModelScope.launch {
var wasOnline: Boolean? = null
connectivityMonitor.online.collect { online ->
if (online && wasOnline == false) {
loadBirthdays()
}
wasOnline = online
}
}
} }
fun loadBirthdays() { fun loadBirthdays() {

View File

@@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
PRODUCTION_API_BASE_URL=https://harheimertc.de/ PRODUCTION_API_BASE_URL=https://harheimertc.de/
# Android app versioning for Play Store uploads # Android app versioning for Play Store uploads
ANDROID_VERSION_CODE=20 ANDROID_VERSION_CODE=21
ANDROID_VERSION_NAME=0.9.15 ANDROID_VERSION_NAME=0.9.16
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping. # Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
RELEASE_MINIFY_ENABLED=false RELEASE_MINIFY_ENABLED=false