Compare commits
22 Commits
test/fix-v
...
5da11d2e4d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5da11d2e4d | ||
|
|
e8a50e55ca | ||
|
|
530e544542 | ||
|
|
300dce9835 | ||
|
|
a98def915e | ||
|
|
7aa7970f2e | ||
|
|
e517720b03 | ||
|
|
402913d877 | ||
|
|
2014abe660 | ||
|
|
80834d8652 | ||
|
|
7bc98c03e4 | ||
|
|
bf1caefde4 | ||
|
|
6983186caf | ||
|
|
7c93966878 | ||
|
|
31d20f1bff | ||
|
|
6507afea5f | ||
|
|
387ce6e08e | ||
|
|
f822fc8a8e | ||
|
|
67c746f18b | ||
|
|
1e65cb47da | ||
|
|
ec96e21517 | ||
|
|
46f80df165 |
@@ -6,10 +6,27 @@ on:
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
clean: true
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Ensure clean workspace
|
||||
run: |
|
||||
git reset --hard HEAD
|
||||
git clean -fdx
|
||||
|
||||
- name: Debug dependency files
|
||||
run: |
|
||||
echo "commit: $(git rev-parse HEAD)"
|
||||
echo "branch ref: ${GITHUB_REF:-unknown}"
|
||||
echo "package.json checksum:" && sha256sum package.json
|
||||
echo "package-lock.json checksum:" && sha256sum package-lock.json
|
||||
echo "eslint entries in package.json:" && rg '"eslint"' package.json || true
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -72,7 +89,11 @@ jobs:
|
||||
rm -f gitleaks.tar.gz
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: |
|
||||
if ! npm ci; then
|
||||
echo "WARNING: npm ci fehlgeschlagen (Lockfile-Drift?). Fallback auf npm install."
|
||||
npm install
|
||||
fi
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
@@ -98,9 +119,8 @@ jobs:
|
||||
./osv-scanner --lockfile ./package-lock.json
|
||||
|
||||
deploy-production:
|
||||
needs: analyze
|
||||
runs-on: ubuntu-latest
|
||||
if: success() && github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Prepare SSH
|
||||
run: |
|
||||
|
||||
1
.gitignore
vendored
@@ -93,6 +93,7 @@ dist
|
||||
/android-app/.kotlin/
|
||||
/android-app/**/build/
|
||||
/android-app/local.properties
|
||||
/android-app/gradle-local.properties
|
||||
|
||||
# Build output (but keep production data!)
|
||||
.output
|
||||
|
||||
@@ -36,13 +36,12 @@ Ausgabe in:
|
||||
|
||||
## 3) Screenshots (anonymisiert)
|
||||
|
||||
### Grobe Anforderungen (Telefon)
|
||||
- Mindestens 2 Screenshots
|
||||
- PNG oder JPEG
|
||||
- Seitenlaenge je Seite zwischen 320 px und 3840 px
|
||||
### Zielgroessen fuer Store-Upload
|
||||
- Telefon (Portrait): 1080 x 1920
|
||||
- Medium 7" Tablet (Portrait): 1200 x 1920
|
||||
- 10" Tablet (Portrait): 1600 x 2560
|
||||
|
||||
Empfehlung fuer Android-Phone:
|
||||
- 1080 x 1920 (Portrait)
|
||||
Alle Dateien als PNG oder JPEG.
|
||||
|
||||
### Anonymisierung
|
||||
|
||||
@@ -61,10 +60,33 @@ Beispiel:
|
||||
'68,118,520,72;70,706,560,98'
|
||||
```
|
||||
|
||||
### Zielprofile erzeugen (Telefon, 7", 10")
|
||||
|
||||
Aus allen Dateien in `android-app/playstore-assets/anon` werden die drei Profile erzeugt:
|
||||
|
||||
```bash
|
||||
./scripts/playstore-screenshot-sizes.sh
|
||||
```
|
||||
|
||||
Optional mit eigenen Ordnern:
|
||||
|
||||
```bash
|
||||
./scripts/playstore-screenshot-sizes.sh \
|
||||
--input-dir android-app/playstore-assets/anon \
|
||||
--output-dir android-app/playstore-assets/final
|
||||
```
|
||||
|
||||
Output:
|
||||
- android-app/playstore-assets/final/phone
|
||||
- android-app/playstore-assets/final/tablet-7
|
||||
- android-app/playstore-assets/final/tablet-10
|
||||
|
||||
## 4) Upload in Play Console
|
||||
|
||||
- Datenschutzerklaerung: URL eintragen
|
||||
- Konto-Loeschung: URL eintragen
|
||||
- App-Icon: playstore-icon-512.png
|
||||
- Feature Graphic: playstore-feature-graphic-1024x500.png
|
||||
- Screenshots: anonymisierte PNG/JPEG hochladen
|
||||
- Screenshots Telefon: Dateien aus `.../final/phone`
|
||||
- Screenshots 7" Tablet: Dateien aus `.../final/tablet-7`
|
||||
- Screenshots 10" Tablet: Dateien aus `.../final/tablet-10`
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("com.google.devtools.ksp")
|
||||
@@ -5,12 +7,17 @@ plugins {
|
||||
id("com.google.dagger.hilt.android")
|
||||
}
|
||||
|
||||
if (file("google-services.json").exists()) {
|
||||
apply(plugin = "com.google.gms.google-services")
|
||||
}
|
||||
|
||||
val localApiBaseUrl = providers.gradleProperty("LOCAL_API_BASE_URL")
|
||||
.orElse("https://harheimertc.tsschulz.de/")
|
||||
.get()
|
||||
val productionApiBaseUrl = providers.gradleProperty("PRODUCTION_API_BASE_URL")
|
||||
.orElse("https://harheimertc.de/")
|
||||
.get()
|
||||
val expectedProductionApiBaseUrl = "https://harheimertc.de/"
|
||||
val sentryDsn = providers.gradleProperty("SENTRY_DSN")
|
||||
.orElse("")
|
||||
.get()
|
||||
@@ -25,15 +32,50 @@ val releaseMinifyEnabled = providers.gradleProperty("RELEASE_MINIFY_ENABLED")
|
||||
.orElse("true")
|
||||
.get()
|
||||
.toBoolean()
|
||||
val releaseStoreFile = providers.gradleProperty("RELEASE_STORE_FILE").orNull
|
||||
val releaseStorePassword = providers.gradleProperty("RELEASE_STORE_PASSWORD").orNull
|
||||
val releaseKeyAlias = providers.gradleProperty("RELEASE_KEY_ALIAS").orNull
|
||||
val releaseKeyPassword = providers.gradleProperty("RELEASE_KEY_PASSWORD").orNull
|
||||
|
||||
val localSigningProperties = Properties().apply {
|
||||
val localSigningFile = rootProject.file("gradle-local.properties")
|
||||
if (localSigningFile.exists()) {
|
||||
localSigningFile.inputStream().use { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun signingProperty(name: String): String? =
|
||||
providers.gradleProperty(name).orNull
|
||||
?: providers.environmentVariable(name).orNull
|
||||
?: localSigningProperties.getProperty(name)
|
||||
|
||||
val releaseStoreFile = signingProperty("RELEASE_STORE_FILE")
|
||||
val releaseStorePassword = signingProperty("RELEASE_STORE_PASSWORD")
|
||||
val releaseKeyAlias = signingProperty("RELEASE_KEY_ALIAS")
|
||||
val releaseKeyPassword = signingProperty("RELEASE_KEY_PASSWORD")
|
||||
val hasReleaseSigning = !releaseStoreFile.isNullOrBlank() &&
|
||||
!releaseStorePassword.isNullOrBlank() &&
|
||||
!releaseKeyAlias.isNullOrBlank() &&
|
||||
!releaseKeyPassword.isNullOrBlank()
|
||||
|
||||
val ensureReleaseSigning = tasks.register("ensureReleaseSigning") {
|
||||
doFirst {
|
||||
if (!hasReleaseSigning) {
|
||||
throw GradleException(
|
||||
"Production release signing is not configured. " +
|
||||
"Set RELEASE_STORE_FILE, RELEASE_STORE_PASSWORD, RELEASE_KEY_ALIAS and RELEASE_KEY_PASSWORD " +
|
||||
"(e.g. via ~/.gradle/gradle.properties, environment variables, or android-app/gradle-local.properties)."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val ensureProductionApiBaseUrl = tasks.register("ensureProductionApiBaseUrl") {
|
||||
doFirst {
|
||||
if (productionApiBaseUrl != expectedProductionApiBaseUrl) {
|
||||
throw GradleException(
|
||||
"Production Play Store builds must use $expectedProductionApiBaseUrl, but PRODUCTION_API_BASE_URL is $productionApiBaseUrl."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "de.harheimertc"
|
||||
compileSdk = 35
|
||||
@@ -135,6 +177,8 @@ val packageNativeDebugSymbolsForProductionRelease = tasks.register<Zip>("package
|
||||
val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
|
||||
group = "distribution"
|
||||
description = "Builds production release artifacts and collects AAB, mapping, and native symbols for Play Console upload."
|
||||
dependsOn(ensureReleaseSigning)
|
||||
dependsOn(ensureProductionApiBaseUrl)
|
||||
dependsOn(":app:bundleProductionRelease")
|
||||
dependsOn(packageNativeDebugSymbolsForProductionRelease)
|
||||
|
||||
@@ -161,6 +205,13 @@ val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
|
||||
}
|
||||
}
|
||||
|
||||
tasks.matching {
|
||||
it.name in setOf("bundleProductionRelease", "assembleProductionRelease")
|
||||
}.configureEach {
|
||||
dependsOn(ensureReleaseSigning)
|
||||
dependsOn(ensureProductionApiBaseUrl)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
@@ -206,6 +257,9 @@ dependencies {
|
||||
// Crash reporting
|
||||
implementation("io.sentry:sentry-android:8.42.0")
|
||||
|
||||
// Push notifications
|
||||
implementation("com.google.firebase:firebase-messaging:25.0.2")
|
||||
|
||||
// Room
|
||||
implementation("androidx.room:room-runtime:2.6.1")
|
||||
ksp("androidx.room:room-compiler:2.6.1")
|
||||
@@ -215,6 +269,7 @@ dependencies {
|
||||
implementation("androidx.work:work-runtime-ktx:2.8.1")
|
||||
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")
|
||||
|
||||
// Testing (skeleton)
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
|
||||
37
android-app/app/proguard-rules.pro
vendored
@@ -1,2 +1,37 @@
|
||||
# Project-specific R8/ProGuard rules for release builds.
|
||||
# Keep this file intentionally minimal and add rules only when needed.
|
||||
|
||||
# Keep reflection/generic metadata used by Retrofit + Moshi.
|
||||
-keepattributes Signature,InnerClasses,EnclosingMethod
|
||||
-keepattributes RuntimeVisibleAnnotations,RuntimeVisibleParameterAnnotations,AnnotationDefault
|
||||
-keep class kotlin.Metadata { *; }
|
||||
|
||||
# Keep Retrofit service interfaces and HTTP method annotations.
|
||||
-keep,allowobfuscation interface * {
|
||||
@retrofit2.http.* <methods>;
|
||||
}
|
||||
|
||||
# Retrofit + R8 full mode: keep interfaces with HTTP methods and suspend continuation generics.
|
||||
-if interface * { @retrofit2.http.* <methods>; }
|
||||
-keep,allowobfuscation interface <1>
|
||||
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
|
||||
|
||||
# Avoid Retrofit generic signature loss on release builds for our API interface.
|
||||
-keep interface de.harheimertc.data.ApiService { *; }
|
||||
-keepclassmembers interface de.harheimertc.data.ApiService { *; }
|
||||
|
||||
# Keep app DTO/request/response models used via Moshi reflection.
|
||||
-keep class de.harheimertc.data.*Dto { *; }
|
||||
-keep class de.harheimertc.data.*Request { *; }
|
||||
-keep class de.harheimertc.data.*Response { *; }
|
||||
|
||||
# Keep fields annotated with @Json names.
|
||||
-keepclassmembers class * {
|
||||
@com.squareup.moshi.Json <fields>;
|
||||
}
|
||||
|
||||
# Keep WorkManager + Room generated classes used reflectively at startup.
|
||||
-keep class * extends androidx.work.ListenableWorker {
|
||||
<init>(android.content.Context, androidx.work.WorkerParameters);
|
||||
}
|
||||
-keep class androidx.work.impl.WorkDatabase_Impl { *; }
|
||||
-keep class * extends androidx.room.RoomDatabase { *; }
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".HarheimerApplication"
|
||||
@@ -16,6 +17,13 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service
|
||||
android:name=".notifications.HarheimerMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.files"
|
||||
|
||||
@@ -6,6 +6,7 @@ import coil.ImageLoaderFactory
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import de.harheimertc.notifications.HarheimerNotifications
|
||||
import io.sentry.Sentry
|
||||
import okhttp3.OkHttpClient
|
||||
import javax.inject.Inject
|
||||
@@ -19,6 +20,7 @@ class HarheimerApplication : Application(), ImageLoaderFactory {
|
||||
override fun onCreate() {
|
||||
Log.d("HILT", "HarheimerApplication.onCreate called")
|
||||
super.onCreate()
|
||||
HarheimerNotifications.createChannels(this)
|
||||
if (BuildConfig.SENTRY_DSN.isNotBlank()) {
|
||||
Sentry.init { options ->
|
||||
options.dsn = BuildConfig.SENTRY_DSN
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package de.harheimertc
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import de.harheimertc.ui.navigation.NavGraph
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.notifications.HarheimerNotifications
|
||||
import de.harheimertc.ui.theme.HarheimerTheme
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import de.harheimertc.ui.navigation.NavigationViewModel
|
||||
@@ -17,12 +21,25 @@ import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val notificationPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
Log.i("NOTIFICATIONS", "POST_NOTIFICATIONS granted=$granted")
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestNotificationPermissionIfNeeded()
|
||||
setContent {
|
||||
App()
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestNotificationPermissionIfNeeded() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !HarheimerNotifications.hasNotificationPermission(this)) {
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -37,6 +37,12 @@ data class SpielplanResponse(
|
||||
val seasons: List<SeasonDto> = emptyList(),
|
||||
)
|
||||
data class SeasonDto(val slug: String = "", val label: String = "")
|
||||
data class MannschaftenSeasonsResponse(
|
||||
val success: Boolean = false,
|
||||
val seasons: List<String> = emptyList(),
|
||||
val currentSeason: String = "",
|
||||
val defaultSeason: String = "",
|
||||
)
|
||||
data class SpielDto(
|
||||
@param:Json(name = "Termin") val termin: String = "",
|
||||
@param:Json(name = "HeimMannschaft") val heimMannschaft: String = "",
|
||||
@@ -81,7 +87,7 @@ data class LeagueTableRowDto(
|
||||
)
|
||||
data class NewsPublicResponse(val news: List<NewsDto> = emptyList())
|
||||
data class NewsDto(
|
||||
val id: Int? = null,
|
||||
val id: String? = null,
|
||||
val title: String = "",
|
||||
val content: String = "",
|
||||
val created: String? = null,
|
||||
@@ -96,7 +102,7 @@ data class NewsResponse(
|
||||
val news: List<NewsDto> = emptyList(),
|
||||
)
|
||||
data class NewsSaveRequest(
|
||||
val id: Int? = null,
|
||||
val id: String? = null,
|
||||
val title: String,
|
||||
val content: String,
|
||||
val isPublic: Boolean = false,
|
||||
@@ -251,6 +257,30 @@ data class ProfileUpdateRequest(
|
||||
val currentPassword: String? = null,
|
||||
val newPassword: String? = null,
|
||||
)
|
||||
data class NotificationSettingsDto(
|
||||
val newNews: Boolean = false,
|
||||
val newEvents: Boolean = false,
|
||||
val eventsToday: Boolean = false,
|
||||
val eventsTomorrow: Boolean = false,
|
||||
val ownTeamMatches: Boolean = false,
|
||||
val allTeamMatches: Boolean = false,
|
||||
val birthdays: Boolean = false,
|
||||
val newContactRequest: Boolean = false,
|
||||
val newUserRegistration: Boolean = false,
|
||||
val selectedTeamSlugs: List<String> = emptyList(),
|
||||
val selectedTeamSeason: String? = null,
|
||||
val notificationTime: String = "09:00",
|
||||
)
|
||||
data class NotificationSettingsResponse(
|
||||
val success: Boolean = false,
|
||||
val message: String? = null,
|
||||
val settings: NotificationSettingsDto = NotificationSettingsDto(),
|
||||
)
|
||||
data class PushTokenRequest(
|
||||
val token: String,
|
||||
val platform: String = "android",
|
||||
val appVersion: String? = null,
|
||||
)
|
||||
data class BirthdayDto(
|
||||
val name: String = "",
|
||||
val dayMonth: String = "",
|
||||
@@ -260,6 +290,28 @@ data class BirthdaysResponse(
|
||||
val success: Boolean = false,
|
||||
val birthdays: List<BirthdayDto> = emptyList(),
|
||||
)
|
||||
data class QttrSourceDto(
|
||||
val url: String = "",
|
||||
)
|
||||
data class QttrRowDto(
|
||||
val rank: Int? = null,
|
||||
val playerNumber: Int? = null,
|
||||
val gender: String? = null,
|
||||
val playerName: String = "",
|
||||
val clubName: String = "",
|
||||
val currentQttr: Int? = null,
|
||||
val previousQttr: Int? = null,
|
||||
val birthdate: String? = null,
|
||||
)
|
||||
data class QttrValuesResponse(
|
||||
val format: String = "",
|
||||
val importedAt: String = "",
|
||||
val source: QttrSourceDto = QttrSourceDto(),
|
||||
val title: String? = null,
|
||||
val headerCount: Int = 0,
|
||||
val rowCount: Int = 0,
|
||||
val rows: List<QttrRowDto> = emptyList(),
|
||||
)
|
||||
data class MemberDto(
|
||||
val id: String? = null,
|
||||
val name: String = "",
|
||||
@@ -387,6 +439,15 @@ data class HomepageSectionConfigDto(
|
||||
data class HomepageDto(
|
||||
val sections: List<HomepageSectionDto> = emptyList(),
|
||||
)
|
||||
data class HeroImageVariantDto(
|
||||
val key: String = "",
|
||||
val mobileWebp: String = "",
|
||||
val desktopWebp: String = "",
|
||||
val fallback: String = "",
|
||||
)
|
||||
data class HeroImagesResponse(
|
||||
val variants: List<HeroImageVariantDto> = emptyList(),
|
||||
)
|
||||
data class SeitenDto(
|
||||
val ueberUns: String = "",
|
||||
val geschichte: String = "",
|
||||
@@ -548,14 +609,20 @@ interface ApiService {
|
||||
suspend fun saveNews(@Body request: NewsSaveRequest): Response<AuthMessageResponse>
|
||||
|
||||
@DELETE("/api/news")
|
||||
suspend fun deleteNews(@Query("id") id: Int): Response<AuthMessageResponse>
|
||||
suspend fun deleteNews(@Query("id") id: String): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/mannschaften")
|
||||
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
|
||||
|
||||
@GET("/api/mannschaften/seasons")
|
||||
suspend fun mannschaftenSeasons(): Response<MannschaftenSeasonsResponse>
|
||||
|
||||
@GET("/api/config")
|
||||
suspend fun config(): Response<ConfigResponse>
|
||||
|
||||
@GET("/api/hero-images")
|
||||
suspend fun heroImages(): Response<HeroImagesResponse>
|
||||
|
||||
@PUT("/api/config")
|
||||
suspend fun updateConfig(@Body request: ConfigResponse): Response<ConfigResponse>
|
||||
|
||||
@@ -617,9 +684,21 @@ interface ApiService {
|
||||
@retrofit2.http.PUT("/api/profile")
|
||||
suspend fun updateProfile(@Body request: ProfileUpdateRequest): Response<ProfileResponse>
|
||||
|
||||
@GET("/api/profile/notifications")
|
||||
suspend fun notificationSettings(): Response<NotificationSettingsResponse>
|
||||
|
||||
@retrofit2.http.PUT("/api/profile/notifications")
|
||||
suspend fun updateNotificationSettings(@Body request: NotificationSettingsDto): Response<NotificationSettingsResponse>
|
||||
|
||||
@POST("/api/profile/push-token")
|
||||
suspend fun registerPushToken(@Body request: PushTokenRequest): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/birthdays")
|
||||
suspend fun birthdays(): Response<BirthdaysResponse>
|
||||
|
||||
@GET("/api/mitgliederbereich/qttr")
|
||||
suspend fun qttrValues(): Response<QttrValuesResponse>
|
||||
|
||||
@GET("/api/members")
|
||||
suspend fun members(): Response<MembersResponse>
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
package de.harheimertc.data
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import java.security.GeneralSecurityException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -14,8 +16,10 @@ class SecureOfflineCache @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val moshi: Moshi,
|
||||
) {
|
||||
private val tag = "SecureOfflineCache"
|
||||
private companion object {
|
||||
const val KEY_BIRTHDAYS = "birthdays"
|
||||
const val KEY_QTTR_VALUES = "qttr_values"
|
||||
const val KEY_MEMBERS = "members"
|
||||
const val KEY_MEMBER_NEWS = "member_news"
|
||||
const val KEY_CMS_CONFIG = "cms_config"
|
||||
@@ -28,6 +32,10 @@ class SecureOfflineCache @Inject constructor(
|
||||
}
|
||||
|
||||
private val preferences by lazy {
|
||||
buildEncryptedPreferences()
|
||||
}
|
||||
|
||||
private fun buildEncryptedPreferences() = try {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
@@ -38,11 +46,36 @@ class SecureOfflineCache @Inject constructor(
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
} catch (error: GeneralSecurityException) {
|
||||
recoverEncryptedPreferences(error)
|
||||
} catch (error: RuntimeException) {
|
||||
recoverEncryptedPreferences(error)
|
||||
}
|
||||
|
||||
private fun recoverEncryptedPreferences(error: Throwable) = try {
|
||||
Log.w(tag, "EncryptedSharedPreferences defekt, Offline-Cache wird neu angelegt", error)
|
||||
context.deleteSharedPreferences("harheimertc_offline_cache")
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"harheimertc_offline_cache",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
} catch (retryError: Throwable) {
|
||||
Log.e(tag, "Offline-Cache konnte nicht wiederhergestellt werden", retryError)
|
||||
throw retryError
|
||||
}
|
||||
|
||||
fun putBirthdays(response: BirthdaysResponse) = put(KEY_BIRTHDAYS, response, BirthdaysResponse::class.java)
|
||||
fun getBirthdays(maxAgeMillis: Long? = null): BirthdaysResponse? = get(KEY_BIRTHDAYS, BirthdaysResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun putQttrValues(response: QttrValuesResponse) = put(KEY_QTTR_VALUES, response, QttrValuesResponse::class.java)
|
||||
fun getQttrValues(maxAgeMillis: Long? = null): QttrValuesResponse? = get(KEY_QTTR_VALUES, QttrValuesResponse::class.java, maxAgeMillis)
|
||||
|
||||
fun putMembers(response: MembersResponse) = put(KEY_MEMBERS, response, MembersResponse::class.java)
|
||||
fun getMembers(maxAgeMillis: Long? = null): MembersResponse? = get(KEY_MEMBERS, MembersResponse::class.java, maxAgeMillis)
|
||||
|
||||
@@ -93,6 +126,7 @@ class SecureOfflineCache @Inject constructor(
|
||||
KEY_NEWSLETTER_GROUPS,
|
||||
KEY_PASSWORD_RESET_DIAGNOSTICS,
|
||||
KEY_MEMBER_NEWS,
|
||||
KEY_QTTR_VALUES,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package de.harheimertc.notifications
|
||||
|
||||
import android.util.Log
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.harheimertc.repositories.PushTokenRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class HarheimerMessagingService : FirebaseMessagingService() {
|
||||
@Inject
|
||||
lateinit var pushTokenRepository: PushTokenRepository
|
||||
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
override fun onNewToken(token: String) {
|
||||
super.onNewToken(token)
|
||||
serviceScope.launch {
|
||||
pushTokenRepository.registerToken(token)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessageReceived(message: RemoteMessage) {
|
||||
super.onMessageReceived(message)
|
||||
val title = message.notification?.title
|
||||
?: message.data["title"]
|
||||
?: "Harheimer TC"
|
||||
val body = message.notification?.body
|
||||
?: message.data["body"]
|
||||
?: message.data["message"]
|
||||
?: return
|
||||
val notificationId = message.data["notificationId"]?.toIntOrNull()
|
||||
?: message.messageId?.hashCode()
|
||||
?: System.currentTimeMillis().toInt()
|
||||
val shown = HarheimerNotifications.showBasicNotification(this, notificationId, title, body)
|
||||
Log.d("HarheimerMessaging", "Push message received type=${message.data["type"]}, shown=$shown")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package de.harheimertc.notifications
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import de.harheimertc.R
|
||||
|
||||
object HarheimerNotifications {
|
||||
const val DEFAULT_CHANNEL_ID = "harheimer_tc_updates"
|
||||
|
||||
fun createChannels(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val channel = NotificationChannel(
|
||||
DEFAULT_CHANNEL_ID,
|
||||
"Harheimer TC",
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description = "Benachrichtigungen des Harheimer TC"
|
||||
}
|
||||
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
fun hasNotificationPermission(context: Context): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
fun showBasicNotification(
|
||||
context: Context,
|
||||
notificationId: Int,
|
||||
title: String,
|
||||
message: String,
|
||||
): Boolean {
|
||||
if (!hasNotificationPermission(context)) return false
|
||||
val notification = NotificationCompat.Builder(context, DEFAULT_CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle(title)
|
||||
.setContentText(message)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.harheimertc.security.DeviceKeyManager
|
||||
import java.security.GeneralSecurityException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -13,10 +15,15 @@ class AuthRepositoryImpl @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val deviceKeyManager: DeviceKeyManager,
|
||||
) : AuthRepository {
|
||||
private val tag = "AuthRepository"
|
||||
private val tokenKey = "auth_token"
|
||||
private val refreshTokenKey = "auth_refresh_token"
|
||||
private val sessionIdKey = "auth_session_id"
|
||||
private val preferences by lazy {
|
||||
buildEncryptedPreferences()
|
||||
}
|
||||
|
||||
private fun buildEncryptedPreferences() = try {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
@@ -27,6 +34,28 @@ class AuthRepositoryImpl @Inject constructor(
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
} catch (error: GeneralSecurityException) {
|
||||
recoverEncryptedPreferences(error)
|
||||
} catch (error: RuntimeException) {
|
||||
recoverEncryptedPreferences(error)
|
||||
}
|
||||
|
||||
private fun recoverEncryptedPreferences(error: Throwable) = try {
|
||||
Log.w(tag, "EncryptedSharedPreferences defekt, Session wird neu angelegt", error)
|
||||
context.deleteSharedPreferences("harheimertc_auth")
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"harheimertc_auth",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
} catch (retryError: Throwable) {
|
||||
Log.e(tag, "EncryptedSharedPreferences konnte nicht wiederhergestellt werden", retryError)
|
||||
throw retryError
|
||||
}
|
||||
|
||||
override fun getToken(): String? = preferences.getString(tokenKey, null)
|
||||
|
||||
@@ -267,7 +267,7 @@ class CmsRepository @Inject constructor(
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun deleteNews(id: Int): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
suspend fun deleteNews(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val response = api.deleteNews(id)
|
||||
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
|
||||
cache.clearCmsNewsCache()
|
||||
|
||||
@@ -24,27 +24,27 @@ class GalleryRepository @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) {
|
||||
suspend fun hasPublicImages(): Result<Boolean> = runCatching {
|
||||
val response = api.galerieList(page = 1, perPage = 1)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
response.body()?.images.orEmpty().isNotEmpty()
|
||||
retryOnNetworkFailure {
|
||||
val response = api.galerieList(page = 1, perPage = 1)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
response.body()?.images.orEmpty().isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchImages(page: Int = 1, perPage: Int = 60): Result<GalleryPage> {
|
||||
return try {
|
||||
val resp = api.galerieList(page = page, perPage = perPage)
|
||||
if (resp.isSuccessful) {
|
||||
val body = resp.body()
|
||||
Result.success(
|
||||
return runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val resp = api.galerieList(page = page, perPage = perPage)
|
||||
if (resp.isSuccessful) {
|
||||
val body = resp.body()
|
||||
GalleryPage(
|
||||
images = body?.images.orEmpty().map { it.toGalleryImage() },
|
||||
pagination = body?.pagination ?: GalleryPaginationDto(),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Result.failure(Exception("HTTP ${resp.code()}"))
|
||||
)
|
||||
} else {
|
||||
error("HTTP ${resp.code()}")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.harheimertc.data.HomepageSectionDto
|
||||
import java.security.GeneralSecurityException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -15,10 +17,15 @@ class HomeLayoutPreferences @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val moshi: Moshi,
|
||||
) {
|
||||
private val tag = "HomeLayoutPreferences"
|
||||
private val sectionListType = Types.newParameterizedType(List::class.java, HomepageSectionDto::class.java)
|
||||
private val sectionListAdapter = moshi.adapter<List<HomepageSectionDto>>(sectionListType)
|
||||
|
||||
private val preferences by lazy {
|
||||
buildEncryptedPreferences()
|
||||
}
|
||||
|
||||
private fun buildEncryptedPreferences() = try {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
@@ -29,6 +36,28 @@ class HomeLayoutPreferences @Inject constructor(
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
} catch (error: GeneralSecurityException) {
|
||||
recoverEncryptedPreferences(error)
|
||||
} catch (error: RuntimeException) {
|
||||
recoverEncryptedPreferences(error)
|
||||
}
|
||||
|
||||
private fun recoverEncryptedPreferences(error: Throwable) = try {
|
||||
Log.w(tag, "EncryptedSharedPreferences defekt, Home-Layout wird neu angelegt", error)
|
||||
context.deleteSharedPreferences("harheimertc_home_layout")
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
"harheimertc_home_layout",
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
} catch (retryError: Throwable) {
|
||||
Log.e(tag, "Home-Layout-Preferences konnten nicht wiederhergestellt werden", retryError)
|
||||
throw retryError
|
||||
}
|
||||
|
||||
fun getSections(): List<HomepageSectionDto>? {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.HomepageSectionDto
|
||||
import de.harheimertc.data.HeroImageVariantDto
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.SeasonDto
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.data.SpielplanResponse
|
||||
import de.harheimertc.data.TerminDto
|
||||
import io.sentry.Sentry
|
||||
import kotlin.random.Random
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -17,20 +21,162 @@ data class HomeData(
|
||||
val selectedSpielplanSeason: String?,
|
||||
val news: List<NewsDto>,
|
||||
val homepageSections: List<HomepageSectionDto>,
|
||||
val heroImageUrl: String? = null,
|
||||
val diagnostics: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class HomeRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchHomeData(): Result<HomeData> = runCatching {
|
||||
val termine = api.termine().body()?.termine.orEmpty()
|
||||
val spielplanResponse = api.spielplan().body()
|
||||
val spiele = spielplanResponse?.data.orEmpty()
|
||||
val news = api.publicNews().body()?.news.orEmpty()
|
||||
val homepageSections = runCatching {
|
||||
val configResponse = api.config()
|
||||
if (!configResponse.isSuccessful) return@runCatching emptyList()
|
||||
configResponse.body()?.homepage?.sections.orEmpty()
|
||||
val diagnostics = mutableListOf<String>()
|
||||
|
||||
val termine = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.termine()
|
||||
if (!response.isSuccessful) {
|
||||
val errorBody = response.errorBody()?.string().orEmpty()
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/termine",
|
||||
requestPayload = "none",
|
||||
httpCode = response.code(),
|
||||
responseBody = errorBody,
|
||||
throwable = null,
|
||||
)
|
||||
error("Termine konnten nicht geladen werden (HTTP ${response.code()}).")
|
||||
}
|
||||
response.body()?.termine.orEmpty()
|
||||
}
|
||||
}.onFailure { error ->
|
||||
captureLoadIssue("fetchHomeData.termine", error)
|
||||
if (diagnostics.none { it.contains("GET /api/termine") }) {
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/termine",
|
||||
requestPayload = "none",
|
||||
httpCode = null,
|
||||
responseBody = null,
|
||||
throwable = error,
|
||||
)
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
val spielplanResponse = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.spielplan()
|
||||
if (!response.isSuccessful) {
|
||||
val errorBody = response.errorBody()?.string().orEmpty()
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/spielplan",
|
||||
requestPayload = "none",
|
||||
httpCode = response.code(),
|
||||
responseBody = errorBody,
|
||||
throwable = null,
|
||||
)
|
||||
error("Spielplan konnte nicht geladen werden (HTTP ${response.code()}).")
|
||||
}
|
||||
response.body()
|
||||
}
|
||||
}.onFailure { error ->
|
||||
captureLoadIssue("fetchHomeData.spielplan", error)
|
||||
if (diagnostics.none { it.contains("GET /api/spielplan") }) {
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/spielplan",
|
||||
requestPayload = "none",
|
||||
httpCode = null,
|
||||
responseBody = null,
|
||||
throwable = error,
|
||||
)
|
||||
}
|
||||
}.getOrNull()
|
||||
val spiele = spielplanResponse?.data.orEmpty()
|
||||
|
||||
val news = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.publicNews()
|
||||
if (!response.isSuccessful) {
|
||||
val errorBody = response.errorBody()?.string().orEmpty()
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/news-public",
|
||||
requestPayload = "none",
|
||||
httpCode = response.code(),
|
||||
responseBody = errorBody,
|
||||
throwable = null,
|
||||
)
|
||||
error("News konnten nicht geladen werden (HTTP ${response.code()}).")
|
||||
}
|
||||
response.body()?.news.orEmpty()
|
||||
}
|
||||
}.onFailure { error ->
|
||||
captureLoadIssue("fetchHomeData.news", error)
|
||||
if (diagnostics.none { it.contains("GET /api/news-public") }) {
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/news-public",
|
||||
requestPayload = "none",
|
||||
httpCode = null,
|
||||
responseBody = null,
|
||||
throwable = error,
|
||||
)
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
val homepageSections = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.config()
|
||||
if (!response.isSuccessful) {
|
||||
val errorBody = response.errorBody()?.string().orEmpty()
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/config",
|
||||
requestPayload = "none",
|
||||
httpCode = response.code(),
|
||||
responseBody = errorBody,
|
||||
throwable = null,
|
||||
)
|
||||
error("Konfiguration konnte nicht geladen werden (HTTP ${response.code()}).")
|
||||
}
|
||||
response.body()?.homepage?.sections.orEmpty()
|
||||
}
|
||||
}.onFailure { error ->
|
||||
captureLoadIssue("fetchHomeData.config", error)
|
||||
if (diagnostics.none { it.contains("GET /api/config") }) {
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/config",
|
||||
requestPayload = "none",
|
||||
httpCode = null,
|
||||
responseBody = null,
|
||||
throwable = error,
|
||||
)
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
val heroImageUrl = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.heroImages()
|
||||
if (!response.isSuccessful) {
|
||||
val errorBody = response.errorBody()?.string().orEmpty()
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/hero-images",
|
||||
requestPayload = "none",
|
||||
httpCode = response.code(),
|
||||
responseBody = errorBody,
|
||||
throwable = null,
|
||||
)
|
||||
error("Hero-Bilder konnten nicht geladen werden (HTTP ${response.code()}).")
|
||||
}
|
||||
val variants = response.body()?.variants.orEmpty()
|
||||
pickRandomHeroImage(variants)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
captureLoadIssue("fetchHomeData.heroImages", error)
|
||||
if (diagnostics.none { it.contains("GET /api/hero-images") }) {
|
||||
diagnostics += buildDiagnostic(
|
||||
endpoint = "GET /api/hero-images",
|
||||
requestPayload = "none",
|
||||
httpCode = null,
|
||||
responseBody = null,
|
||||
throwable = error,
|
||||
)
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
HomeData(
|
||||
termine = termine,
|
||||
spiele = spiele,
|
||||
@@ -38,12 +184,77 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
|
||||
selectedSpielplanSeason = spielplanResponse?.season,
|
||||
news = news,
|
||||
homepageSections = homepageSections,
|
||||
heroImageUrl = heroImageUrl,
|
||||
diagnostics = diagnostics,
|
||||
)
|
||||
}.onFailure { error ->
|
||||
Sentry.withScope { scope ->
|
||||
scope.setTag("repository", "HomeRepository")
|
||||
scope.setTag("operation", "fetchHomeData")
|
||||
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchSpielplanForSeason(season: String): Result<SpielplanResponse> = runCatching {
|
||||
val response = api.spielplan(season)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
retryOnNetworkFailure {
|
||||
val response = api.spielplan(season)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}.onFailure { error ->
|
||||
Sentry.withScope { scope ->
|
||||
scope.setTag("repository", "HomeRepository")
|
||||
scope.setTag("operation", "fetchSpielplanForSeason")
|
||||
scope.setExtra("season", season)
|
||||
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun captureLoadIssue(operation: String, error: Throwable) {
|
||||
Sentry.withScope { scope ->
|
||||
scope.setTag("repository", "HomeRepository")
|
||||
scope.setTag("operation", operation)
|
||||
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildDiagnostic(
|
||||
endpoint: String,
|
||||
requestPayload: String,
|
||||
httpCode: Int?,
|
||||
responseBody: String?,
|
||||
throwable: Throwable?,
|
||||
): String {
|
||||
val responsePreview = responseBody?.trim()?.take(500).orEmpty().ifBlank { "none" }
|
||||
val throwableInfo = throwable?.let { "${it::class.simpleName}: ${it.message}" }.orEmpty().ifBlank { "none" }
|
||||
return buildString {
|
||||
append("Endpoint: ").append(endpoint).append('\n')
|
||||
append("URL: ").append(BuildConfig.API_BASE_URL).append(endpoint.substringAfter(' ')).append('\n')
|
||||
append("Request: ").append(requestPayload).append('\n')
|
||||
append("HTTP: ").append(httpCode?.toString() ?: "none").append('\n')
|
||||
append("Response: ").append(responsePreview).append('\n')
|
||||
append("Throwable: ").append(throwableInfo)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pickRandomHeroImage(variants: List<HeroImageVariantDto>): String? {
|
||||
if (variants.isEmpty()) return null
|
||||
val valid = variants.filter { it.fallback.isNotBlank() }
|
||||
if (valid.isEmpty()) return null
|
||||
val selected = valid[Random.nextInt(valid.size)]
|
||||
return toAbsoluteUrl(selected.fallback)
|
||||
}
|
||||
|
||||
private fun toAbsoluteUrl(pathOrUrl: String): String {
|
||||
if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) {
|
||||
return pathOrUrl
|
||||
}
|
||||
|
||||
val base = BuildConfig.API_BASE_URL.trimEnd('/')
|
||||
val path = if (pathOrUrl.startsWith('/')) pathOrUrl else "/$pathOrUrl"
|
||||
return "$base$path"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.LoginRequest
|
||||
import de.harheimertc.data.LoginResponse
|
||||
@@ -9,6 +10,7 @@ import de.harheimertc.data.LogoutRequest
|
||||
import de.harheimertc.data.RegistrationRequest
|
||||
import de.harheimertc.data.ResetPasswordRequest
|
||||
import de.harheimertc.data.SessionRefresher
|
||||
import io.sentry.Sentry
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -19,13 +21,41 @@ class LoginRepository @Inject constructor(
|
||||
private val sessionRefresher: SessionRefresher,
|
||||
) {
|
||||
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching {
|
||||
val response = api.login(LoginRequest(email.trim(), password))
|
||||
if (!response.isSuccessful) error("Anmeldung fehlgeschlagen. Bitte prüfen Sie Ihre Zugangsdaten.")
|
||||
val endpoint = "api/auth/login"
|
||||
val requestPreview = "{email=\"${maskEmail(email)}\", client=\"android\", deviceName=\"Harheimer TC Android-App\"}"
|
||||
val response = retryOnNetworkFailure { api.login(LoginRequest(email.trim(), password)) }
|
||||
if (!response.isSuccessful) {
|
||||
val body = response.errorBody()?.string().orEmpty()
|
||||
val serverMessage = extractServerMessage(body)
|
||||
val fallback = when (response.code()) {
|
||||
401 -> "Ungueltige Anmeldedaten"
|
||||
403 -> "Konto nicht freigeschaltet"
|
||||
429 -> "Zu viele Anmeldeversuche. Bitte spaeter erneut versuchen."
|
||||
else -> "Anmeldung fehlgeschlagen"
|
||||
}
|
||||
val diagnostic = buildString {
|
||||
append("\n\nDiagnose:\n")
|
||||
append("URL: ").append(BuildConfig.API_BASE_URL).append(endpoint).append('\n')
|
||||
append("Request: ").append(requestPreview).append('\n')
|
||||
append("HTTP: ").append(response.code()).append('\n')
|
||||
append("Response: ").append(body.take(500).ifBlank { "none" }).append('\n')
|
||||
append("Server message: ").append(serverMessage ?: "none")
|
||||
}
|
||||
error("$fallback (HTTP ${response.code()})${serverMessage?.let { ": $it" } ?: ""}$diagnostic")
|
||||
}
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
|
||||
?: error("Der Server hat kein Zugriffstoken geliefert.")
|
||||
authRepository.setSession(token, body.refreshToken, body.sessionId)
|
||||
body
|
||||
}.onFailure { error ->
|
||||
Sentry.withScope { scope ->
|
||||
scope.setTag("repository", "LoginRepository")
|
||||
scope.setTag("operation", "login")
|
||||
scope.setExtra("emailDomain", email.substringAfter('@', "unknown"))
|
||||
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun logout(): Result<Unit> = runCatching {
|
||||
@@ -41,27 +71,55 @@ class LoginRepository @Inject constructor(
|
||||
return@runCatching AuthStatusResponse()
|
||||
}
|
||||
|
||||
var response = api.authStatus()
|
||||
var response = retryOnNetworkFailure { api.authStatus() }
|
||||
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
|
||||
var status = response.body() ?: AuthStatusResponse()
|
||||
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.")
|
||||
status = response.body() ?: AuthStatusResponse()
|
||||
}
|
||||
if (!status.isLoggedIn && authRepository.getRefreshToken() == null) authRepository.clearSession()
|
||||
status
|
||||
}.onFailure { error ->
|
||||
Sentry.withScope { scope ->
|
||||
scope.setTag("repository", "LoginRepository")
|
||||
scope.setTag("operation", "status")
|
||||
scope.setExtra("hasAccessToken", (!authRepository.getToken().isNullOrBlank()).toString())
|
||||
scope.setExtra("hasRefreshToken", (authRepository.getRefreshToken() != null).toString())
|
||||
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resetPassword(email: String): Result<AuthMessageResponse> = runCatching {
|
||||
val response = api.resetPassword(ResetPasswordRequest(email.trim()))
|
||||
if (!response.isSuccessful) error("Anfrage konnte nicht gesendet werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
retryOnNetworkFailure {
|
||||
val response = api.resetPassword(ResetPasswordRequest(email.trim()))
|
||||
if (!response.isSuccessful) error("Anfrage konnte nicht gesendet werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun register(request: RegistrationRequest): Result<AuthMessageResponse> = runCatching {
|
||||
val response = api.register(request)
|
||||
if (!response.isSuccessful) error("Registrierung fehlgeschlagen.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
retryOnNetworkFailure {
|
||||
val response = api.register(request)
|
||||
if (!response.isSuccessful) error("Registrierung fehlgeschlagen.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractServerMessage(raw: String): String? {
|
||||
if (raw.isBlank()) return null
|
||||
val msgRegex = Regex("\"message\"\\s*:\\s*\"([^\"]+)\"")
|
||||
return msgRegex.find(raw)?.groupValues?.getOrNull(1)
|
||||
}
|
||||
|
||||
private fun maskEmail(rawEmail: String): String {
|
||||
val email = rawEmail.trim()
|
||||
if (!email.contains('@')) return "hidden"
|
||||
val local = email.substringBefore('@')
|
||||
val domain = email.substringAfter('@')
|
||||
val localMasked = if (local.length <= 2) "**" else local.take(2) + "***"
|
||||
return "$localMasked@$domain"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.MannschaftenSeasonsResponse
|
||||
import de.harheimertc.data.SeasonDto
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -24,9 +26,19 @@ data class Mannschaft(
|
||||
@Singleton
|
||||
class MannschaftenRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchMannschaften(season: String? = null): Result<List<Mannschaft>> = runCatching {
|
||||
val response = api.mannschaften(season)
|
||||
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
|
||||
parseCsv(response.body()?.string().orEmpty())
|
||||
retryOnNetworkFailure {
|
||||
val response = api.mannschaften(season)
|
||||
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
|
||||
parseCsv(response.body()?.string().orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchSeasons(): Result<MannschaftenSeasonsResponse> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.mannschaftenSeasons()
|
||||
if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.")
|
||||
response.body() ?: error("Saisons konnten nicht geladen werden.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseCsv(csv: String): List<Mannschaft> = csv.lineSequence()
|
||||
|
||||
@@ -5,6 +5,7 @@ import de.harheimertc.data.BirthdaysResponse
|
||||
import de.harheimertc.data.MembersResponse
|
||||
import de.harheimertc.data.NewsResponse
|
||||
import de.harheimertc.data.NewsSaveRequest
|
||||
import de.harheimertc.data.QttrValuesResponse
|
||||
import de.harheimertc.data.SecureOfflineCache
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -24,6 +25,18 @@ class MemberAreaRepository @Inject constructor(
|
||||
fallbackMessage = "Geburtstage konnten nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun qttrValues(): Result<QttrValuesResponse> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
val response = api.qttrValues()
|
||||
if (!response.isSuccessful) error("QTTR-Werte konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
},
|
||||
save = cache::putQttrValues,
|
||||
cached = { cache.getQttrValues(24L * 60L * 60L * 1000L) },
|
||||
fallbackMessage = "QTTR-Werte konnten nicht geladen werden.",
|
||||
)
|
||||
|
||||
suspend fun members(): Result<MembersResponse> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
@@ -88,7 +101,7 @@ class MemberAreaRepository @Inject constructor(
|
||||
response.body() ?: emptyMap()
|
||||
}
|
||||
|
||||
suspend fun deleteNews(id: Int): Result<Unit> = runCatching {
|
||||
suspend fun deleteNews(id: String): Result<Unit> = runCatching {
|
||||
val response = api.deleteNews(id)
|
||||
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -8,9 +8,11 @@ import javax.inject.Inject
|
||||
|
||||
class NewsletterRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun groups(): Result<NewsletterGroupsResponse> = runCatching {
|
||||
val response = api.publicNewsletterGroups()
|
||||
if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
retryOnNetworkFailure {
|
||||
val response = api.publicNewsletterGroups()
|
||||
if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort vom Server.")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun subscribe(groupId: String, email: String, name: String?): Result<AuthMessageResponse> = runCatching {
|
||||
@@ -26,8 +28,10 @@ class NewsletterRepository @Inject constructor(private val api: ApiService) {
|
||||
}
|
||||
|
||||
suspend fun confirm(token: String): Result<AuthMessageResponse> = runCatching {
|
||||
val response = api.confirmNewsletter(token)
|
||||
if (!response.isSuccessful) error("Newsletter-Bestätigung fehlgeschlagen.")
|
||||
response.body() ?: AuthMessageResponse(success = true, message = "Newsletter-Anmeldung bestätigt.")
|
||||
retryOnNetworkFailure {
|
||||
val response = api.confirmNewsletter(token)
|
||||
if (!response.isSuccessful) error("Newsletter-Bestätigung fehlgeschlagen.")
|
||||
response.body() ?: AuthMessageResponse(success = true, message = "Newsletter-Anmeldung bestätigt.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.NotificationSettingsDto
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val DEFAULT_NOTIFICATION_TIME = "09:00"
|
||||
|
||||
data class NotificationPreferences(
|
||||
val newNews: Boolean = false,
|
||||
val newEvents: Boolean = false,
|
||||
val eventsToday: Boolean = false,
|
||||
val eventsTomorrow: Boolean = false,
|
||||
val ownTeamMatches: Boolean = false,
|
||||
val allTeamMatches: Boolean = false,
|
||||
val birthdays: Boolean = false,
|
||||
val newContactRequest: Boolean = false,
|
||||
val newUserRegistration: Boolean = false,
|
||||
val selectedTeamSlugs: Set<String> = emptySet(),
|
||||
val selectedTeamSeason: String? = null,
|
||||
val notificationTime: String = DEFAULT_NOTIFICATION_TIME,
|
||||
)
|
||||
|
||||
@Singleton
|
||||
class NotificationPreferencesRepository @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val api: ApiService,
|
||||
) {
|
||||
private val preferences by lazy {
|
||||
context.getSharedPreferences("harheimertc_notification_preferences", Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
fun loadLocal(): NotificationPreferences = NotificationPreferences(
|
||||
newNews = preferences.getBoolean(KEY_NEW_NEWS, false),
|
||||
newEvents = preferences.getBoolean(KEY_NEW_EVENTS, false),
|
||||
eventsToday = preferences.getBoolean(KEY_EVENTS_TODAY, false),
|
||||
eventsTomorrow = preferences.getBoolean(KEY_EVENTS_TOMORROW, false),
|
||||
ownTeamMatches = preferences.getBoolean(KEY_OWN_TEAM_MATCHES, false),
|
||||
allTeamMatches = preferences.getBoolean(KEY_ALL_TEAM_MATCHES, false),
|
||||
birthdays = preferences.getBoolean(KEY_BIRTHDAYS, false),
|
||||
newContactRequest = preferences.getBoolean(KEY_NEW_CONTACT_REQUEST, false),
|
||||
newUserRegistration = preferences.getBoolean(KEY_NEW_USER_REGISTRATION, false),
|
||||
selectedTeamSlugs = preferences.getStringSet(KEY_SELECTED_TEAM_SLUGS, emptySet()).orEmpty(),
|
||||
selectedTeamSeason = preferences.getString(KEY_SELECTED_TEAM_SEASON, null)?.takeIf { it.isNotBlank() },
|
||||
notificationTime = preferences.getString(KEY_NOTIFICATION_TIME, DEFAULT_NOTIFICATION_TIME) ?: DEFAULT_NOTIFICATION_TIME,
|
||||
)
|
||||
|
||||
suspend fun loadRemote(): Result<NotificationPreferences> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.notificationSettings()
|
||||
if (!response.isSuccessful) error("Benachrichtigungseinstellungen konnten nicht geladen werden.")
|
||||
val settings = response.body()?.settings?.toPreferences() ?: error("Leere Antwort")
|
||||
saveLocal(settings)
|
||||
settings
|
||||
}
|
||||
}
|
||||
|
||||
fun saveLocal(settings: NotificationPreferences) {
|
||||
preferences.edit()
|
||||
.putBoolean(KEY_NEW_NEWS, settings.newNews)
|
||||
.putBoolean(KEY_NEW_EVENTS, settings.newEvents)
|
||||
.putBoolean(KEY_EVENTS_TODAY, settings.eventsToday)
|
||||
.putBoolean(KEY_EVENTS_TOMORROW, settings.eventsTomorrow)
|
||||
.putBoolean(KEY_OWN_TEAM_MATCHES, settings.ownTeamMatches)
|
||||
.putBoolean(KEY_ALL_TEAM_MATCHES, settings.allTeamMatches)
|
||||
.putBoolean(KEY_BIRTHDAYS, settings.birthdays)
|
||||
.putBoolean(KEY_NEW_CONTACT_REQUEST, settings.newContactRequest)
|
||||
.putBoolean(KEY_NEW_USER_REGISTRATION, settings.newUserRegistration)
|
||||
.putStringSet(KEY_SELECTED_TEAM_SLUGS, settings.selectedTeamSlugs)
|
||||
.putString(KEY_SELECTED_TEAM_SEASON, settings.selectedTeamSeason)
|
||||
.putString(KEY_NOTIFICATION_TIME, settings.notificationTime)
|
||||
.apply()
|
||||
}
|
||||
|
||||
suspend fun saveRemote(settings: NotificationPreferences): Result<NotificationPreferences> {
|
||||
saveLocal(settings)
|
||||
return runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.updateNotificationSettings(settings.toDto())
|
||||
if (!response.isSuccessful) error("Benachrichtigungseinstellungen konnten nicht gespeichert werden.")
|
||||
val saved = response.body()?.settings?.toPreferences() ?: error("Leere Antwort")
|
||||
saveLocal(saved)
|
||||
saved
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val KEY_NEW_NEWS = "new_news"
|
||||
const val KEY_NEW_EVENTS = "new_events"
|
||||
const val KEY_EVENTS_TODAY = "events_today"
|
||||
const val KEY_EVENTS_TOMORROW = "events_tomorrow"
|
||||
const val KEY_OWN_TEAM_MATCHES = "own_team_matches"
|
||||
const val KEY_ALL_TEAM_MATCHES = "all_team_matches"
|
||||
const val KEY_BIRTHDAYS = "birthdays"
|
||||
const val KEY_NEW_CONTACT_REQUEST = "new_contact_request"
|
||||
const val KEY_NEW_USER_REGISTRATION = "new_user_registration"
|
||||
const val KEY_SELECTED_TEAM_SLUGS = "selected_team_slugs"
|
||||
const val KEY_SELECTED_TEAM_SEASON = "selected_team_season"
|
||||
const val KEY_NOTIFICATION_TIME = "notification_time"
|
||||
}
|
||||
}
|
||||
|
||||
private fun NotificationSettingsDto.toPreferences(): NotificationPreferences = NotificationPreferences(
|
||||
newNews = newNews,
|
||||
newEvents = newEvents,
|
||||
eventsToday = eventsToday,
|
||||
eventsTomorrow = eventsTomorrow,
|
||||
ownTeamMatches = ownTeamMatches,
|
||||
allTeamMatches = allTeamMatches,
|
||||
birthdays = birthdays,
|
||||
newContactRequest = newContactRequest,
|
||||
newUserRegistration = newUserRegistration,
|
||||
selectedTeamSlugs = selectedTeamSlugs.toSet(),
|
||||
selectedTeamSeason = selectedTeamSeason,
|
||||
notificationTime = notificationTime,
|
||||
)
|
||||
|
||||
private fun NotificationPreferences.toDto(): NotificationSettingsDto = NotificationSettingsDto(
|
||||
newNews = newNews,
|
||||
newEvents = newEvents,
|
||||
eventsToday = eventsToday,
|
||||
eventsTomorrow = eventsTomorrow,
|
||||
ownTeamMatches = ownTeamMatches,
|
||||
allTeamMatches = allTeamMatches,
|
||||
birthdays = birthdays,
|
||||
newContactRequest = newContactRequest,
|
||||
newUserRegistration = newUserRegistration,
|
||||
selectedTeamSlugs = selectedTeamSlugs.toList(),
|
||||
selectedTeamSeason = selectedTeamSeason,
|
||||
notificationTime = notificationTime,
|
||||
)
|
||||
@@ -28,68 +28,74 @@ class PasskeyRepository @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
) {
|
||||
suspend fun login(context: Context, email: String?): Result<LoginResponse> = runCatching {
|
||||
val optionsResponse = api.passkeyAuthenticationOptions(
|
||||
PasskeyAuthenticationOptionsRequest(email = email?.trim()?.takeIf(String::isNotBlank)),
|
||||
)
|
||||
if (!optionsResponse.isSuccessful) error("Passkey-Anmeldung konnte nicht gestartet werden.")
|
||||
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
|
||||
?: error("Der Server hat keine Passkey-Optionen geliefert.")
|
||||
retryOnNetworkFailure {
|
||||
val optionsResponse = api.passkeyAuthenticationOptions(
|
||||
PasskeyAuthenticationOptionsRequest(email = email?.trim()?.takeIf(String::isNotBlank)),
|
||||
)
|
||||
if (!optionsResponse.isSuccessful) error("Passkey-Anmeldung konnte nicht gestartet werden.")
|
||||
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
|
||||
?: error("Der Server hat keine Passkey-Optionen geliefert.")
|
||||
|
||||
val credentialManager = CredentialManager.create(context)
|
||||
val credentialResponse = credentialManager.getCredential(
|
||||
context = context,
|
||||
request = GetCredentialRequest(
|
||||
credentialOptions = listOf(GetPublicKeyCredentialOption(optionsJson)),
|
||||
),
|
||||
)
|
||||
val credential = credentialResponse.credential as? PublicKeyCredential
|
||||
?: error("Der ausgewählte Zugang ist kein Passkey.")
|
||||
val credentialManager = CredentialManager.create(context)
|
||||
val credentialResponse = credentialManager.getCredential(
|
||||
context = context,
|
||||
request = GetCredentialRequest(
|
||||
credentialOptions = listOf(GetPublicKeyCredentialOption(optionsJson)),
|
||||
),
|
||||
)
|
||||
val credential = credentialResponse.credential as? PublicKeyCredential
|
||||
?: error("Der ausgewählte Zugang ist kein Passkey.")
|
||||
|
||||
val response = api.passkeyLogin(
|
||||
JSONObject()
|
||||
.put("credential", JSONObject(credential.authenticationResponseJson))
|
||||
.put("client", "android")
|
||||
.put("deviceName", "Harheimer TC Android-App")
|
||||
.toString()
|
||||
.toRequestBody(MediaTypes.json),
|
||||
)
|
||||
if (!response.isSuccessful) error("Passkey-Anmeldung fehlgeschlagen.")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
|
||||
?: error("Der Server hat kein Zugriffstoken geliefert.")
|
||||
authRepository.setSession(token, body.refreshToken, body.sessionId)
|
||||
body
|
||||
val response = api.passkeyLogin(
|
||||
JSONObject()
|
||||
.put("credential", JSONObject(credential.authenticationResponseJson))
|
||||
.put("client", "android")
|
||||
.put("deviceName", "Harheimer TC Android-App")
|
||||
.toString()
|
||||
.toRequestBody(MediaTypes.json),
|
||||
)
|
||||
if (!response.isSuccessful) error("Passkey-Anmeldung fehlgeschlagen.")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
|
||||
?: error("Der Server hat kein Zugriffstoken geliefert.")
|
||||
authRepository.setSession(token, body.refreshToken, body.sessionId)
|
||||
body
|
||||
}
|
||||
}.recoverCredentialCancellation("Passkey-Anmeldung abgebrochen.")
|
||||
|
||||
suspend fun list(): Result<PasskeysResponse> = runCatching {
|
||||
val response = api.passkeys()
|
||||
if (!response.isSuccessful) error("Passkeys konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
retryOnNetworkFailure {
|
||||
val response = api.passkeys()
|
||||
if (!response.isSuccessful) error("Passkeys konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun add(context: Context, name: String = "Android-App"): Result<AuthMessageResponse> = runCatching {
|
||||
val optionsResponse = api.passkeyRegistrationOptions(PasskeyRegistrationOptionsRequest())
|
||||
if (!optionsResponse.isSuccessful) error("Passkey-Erstellung konnte nicht gestartet werden.")
|
||||
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
|
||||
?: error("Der Server hat keine Passkey-Optionen geliefert.")
|
||||
retryOnNetworkFailure {
|
||||
val optionsResponse = api.passkeyRegistrationOptions(PasskeyRegistrationOptionsRequest())
|
||||
if (!optionsResponse.isSuccessful) error("Passkey-Erstellung konnte nicht gestartet werden.")
|
||||
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
|
||||
?: error("Der Server hat keine Passkey-Optionen geliefert.")
|
||||
|
||||
val credentialManager = CredentialManager.create(context)
|
||||
val credentialResponse = credentialManager.createCredential(
|
||||
context = context,
|
||||
request = CreatePublicKeyCredentialRequest(optionsJson),
|
||||
) as? CreatePublicKeyCredentialResponse
|
||||
?: error("Der erstellte Zugang ist kein Passkey.")
|
||||
val credentialManager = CredentialManager.create(context)
|
||||
val credentialResponse = credentialManager.createCredential(
|
||||
context = context,
|
||||
request = CreatePublicKeyCredentialRequest(optionsJson),
|
||||
) as? CreatePublicKeyCredentialResponse
|
||||
?: error("Der erstellte Zugang ist kein Passkey.")
|
||||
|
||||
val response = api.registerPasskey(
|
||||
JSONObject()
|
||||
.put("credential", JSONObject(credentialResponse.registrationResponseJson))
|
||||
.put("name", name)
|
||||
.put("client", "android")
|
||||
.toString()
|
||||
.toRequestBody(MediaTypes.json),
|
||||
)
|
||||
if (!response.isSuccessful) error("Passkey konnte nicht hinzugefügt werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
val response = api.registerPasskey(
|
||||
JSONObject()
|
||||
.put("credential", JSONObject(credentialResponse.registrationResponseJson))
|
||||
.put("name", name)
|
||||
.put("client", "android")
|
||||
.toString()
|
||||
.toRequestBody(MediaTypes.json),
|
||||
)
|
||||
if (!response.isSuccessful) error("Passkey konnte nicht hinzugefügt werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}.recoverCredentialCancellation("Passkey-Erstellung abgebrochen.")
|
||||
|
||||
suspend fun remove(credentialId: String): Result<AuthMessageResponse> = runCatching {
|
||||
|
||||
@@ -9,14 +9,18 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class ProfileRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun load(): Result<ProfileResponse> = runCatching {
|
||||
val response = api.profile()
|
||||
if (!response.isSuccessful) error("Profil konnte nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
retryOnNetworkFailure {
|
||||
val response = api.profile()
|
||||
if (!response.isSuccessful) error("Profil konnte nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun save(request: ProfileUpdateRequest): Result<ProfileResponse> = runCatching {
|
||||
val response = api.updateProfile(request)
|
||||
if (!response.isSuccessful) error("Profil konnte nicht gespeichert werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
retryOnNetworkFailure {
|
||||
val response = api.updateProfile(request)
|
||||
if (!response.isSuccessful) error("Profil konnte nicht gespeichert werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,43 +31,49 @@ data class MeisterschaftResult(
|
||||
@Singleton
|
||||
class PublicPagesRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
|
||||
val response = api.config()
|
||||
if (!response.isSuccessful) error("Inhalte konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
retryOnNetworkFailure {
|
||||
val response = api.config()
|
||||
if (!response.isSuccessful) error("Inhalte konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchSpielsysteme(): Result<List<Spielsystem>> = runCatching {
|
||||
val response = api.spielsysteme()
|
||||
if (!response.isSuccessful) error("Spielsysteme konnten nicht geladen werden.")
|
||||
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||
if (values.size < 8) return@mapNotNull null
|
||||
Spielsystem(
|
||||
name = values[0],
|
||||
description = values[1],
|
||||
teamSize = values[2],
|
||||
category = values[3],
|
||||
sequence = values[5],
|
||||
gameCount = values[6],
|
||||
features = values[7],
|
||||
)
|
||||
retryOnNetworkFailure {
|
||||
val response = api.spielsysteme()
|
||||
if (!response.isSuccessful) error("Spielsysteme konnten nicht geladen werden.")
|
||||
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||
if (values.size < 8) return@mapNotNull null
|
||||
Spielsystem(
|
||||
name = values[0],
|
||||
description = values[1],
|
||||
teamSize = values[2],
|
||||
category = values[3],
|
||||
sequence = values[5],
|
||||
gameCount = values[6],
|
||||
features = values[7],
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchVereinsmeisterschaften(): Result<List<MeisterschaftResult>> = runCatching {
|
||||
val response = api.vereinsmeisterschaften()
|
||||
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.")
|
||||
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||
if (values.size < 6) return@mapNotNull null
|
||||
MeisterschaftResult(
|
||||
year = values[0],
|
||||
category = values[1],
|
||||
rank = values[2],
|
||||
playerOne = values[3],
|
||||
playerTwo = values[4],
|
||||
note = values[5],
|
||||
imageOne = values.getOrElse(6) { "" },
|
||||
imageTwo = values.getOrElse(7) { "" },
|
||||
)
|
||||
retryOnNetworkFailure {
|
||||
val response = api.vereinsmeisterschaften()
|
||||
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.")
|
||||
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||
if (values.size < 6) return@mapNotNull null
|
||||
MeisterschaftResult(
|
||||
year = values[0],
|
||||
category = values[1],
|
||||
rank = values[2],
|
||||
playerOne = values[3],
|
||||
playerTwo = values[4],
|
||||
note = values[5],
|
||||
imageOne = values.getOrElse(6) { "" },
|
||||
imageTwo = values.getOrElse(7) { "" },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import android.util.Log
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.data.ApiService
|
||||
import de.harheimertc.data.PushTokenRequest
|
||||
import kotlinx.coroutines.tasks.await
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class PushTokenRepository @Inject constructor(
|
||||
private val api: ApiService,
|
||||
) {
|
||||
suspend fun registerCurrentDevice(): Result<Unit> = runCatching {
|
||||
val token = FirebaseMessaging.getInstance().token.await()
|
||||
registerToken(token).getOrThrow()
|
||||
}
|
||||
|
||||
suspend fun registerToken(token: String): Result<Unit> = runCatching {
|
||||
if (token.isBlank()) return@runCatching
|
||||
retryOnNetworkFailure {
|
||||
val response = api.registerPushToken(
|
||||
PushTokenRequest(
|
||||
token = token,
|
||||
appVersion = "${BuildConfig.VERSION_NAME}+${BuildConfig.VERSION_CODE}",
|
||||
),
|
||||
)
|
||||
if (!response.isSuccessful) error("Push-Token konnte nicht registriert werden.")
|
||||
}
|
||||
}.onFailure { error ->
|
||||
Log.w("PushTokenRepository", "Push-Token Registrierung fehlgeschlagen", error)
|
||||
}
|
||||
}
|
||||
@@ -9,18 +9,22 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class SpielplanRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchSpielplan(season: String? = null): Result<SpielplanResponse> = runCatching {
|
||||
val response = api.spielplan(season)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.")
|
||||
body
|
||||
retryOnNetworkFailure {
|
||||
val response = api.spielplan(season)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.")
|
||||
body
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchTeamTable(team: String, season: String? = null): Result<TeamTableResponse> = runCatching {
|
||||
val response = api.spielplanTable(team, season)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
if (!body.success) error(body.message ?: "Tabelle konnte nicht geladen werden.")
|
||||
body
|
||||
retryOnNetworkFailure {
|
||||
val response = api.spielplanTable(team, season)
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
if (!body.success) error(body.message ?: "Tabelle konnte nicht geladen werden.")
|
||||
body
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class TermineRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchTermine(): Result<List<TerminDto>> = runCatching {
|
||||
val response = api.termine()
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
response.body()?.termine.orEmpty()
|
||||
retryOnNetworkFailure {
|
||||
val response = api.termine()
|
||||
if (!response.isSuccessful) error("HTTP ${response.code()}")
|
||||
response.body()?.termine.orEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class TrainingRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
|
||||
val response = api.config()
|
||||
if (!response.isSuccessful) error("Trainingsinformationen konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
retryOnNetworkFailure {
|
||||
val response = api.config()
|
||||
if (!response.isSuccessful) error("Trainingsinformationen konnten nicht geladen werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -25,6 +27,7 @@ import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.R
|
||||
@@ -40,14 +43,17 @@ private enum class MenuSection {
|
||||
TRAINING,
|
||||
NEWSLETTER,
|
||||
INTERN,
|
||||
CMS,
|
||||
}
|
||||
|
||||
private data class MenuTarget(val label: String, val route: String)
|
||||
private const val LOGOUT_ROUTE = "__logout__"
|
||||
|
||||
@Composable
|
||||
fun AppNavigationHeader(
|
||||
selectedRoute: String?,
|
||||
onNavigate: (String) -> Unit,
|
||||
onLogout: () -> Unit = {},
|
||||
webTabletNavigation: Boolean = false,
|
||||
navigationState: NavigationUiState = NavigationUiState(),
|
||||
) {
|
||||
@@ -59,9 +65,9 @@ fun AppNavigationHeader(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (webTabletNavigation) {
|
||||
WebTabletNavigation(selectedRoute, onNavigate, navigationState)
|
||||
WebTabletNavigation(selectedRoute, onNavigate, onLogout, navigationState)
|
||||
} else {
|
||||
CompactNavigation(selectedRoute, onNavigate, navigationState)
|
||||
CompactNavigation(selectedRoute, onNavigate, onLogout, navigationState)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,18 +76,79 @@ fun AppNavigationHeader(
|
||||
private fun CompactNavigation(
|
||||
selectedRoute: String?,
|
||||
onNavigate: (String) -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
navigationState: NavigationUiState = NavigationUiState(),
|
||||
) {
|
||||
BrandRow(onLogin = { onNavigate(Destinations.Login.route) })
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
if (navigationState.isAdmin || navigationState.canAccessNewsletter || navigationState.canAccessContactRequests) {
|
||||
CompactLink("CMS", Destinations.Cms.route, selectedRoute, onNavigate, Modifier.weight(1f))
|
||||
val routeSection = menuSection(selectedRoute)
|
||||
val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
|
||||
val section = routeSection ?: sectionOverride.value
|
||||
val subItems = submenu(section, navigationState)
|
||||
|
||||
val mainScroll = rememberScrollState()
|
||||
val subScroll = rememberScrollState()
|
||||
|
||||
BrandRow(
|
||||
loggedIn = navigationState.loggedIn,
|
||||
onLogin = { onNavigate(Destinations.Login.route) },
|
||||
onLogout = onLogout,
|
||||
)
|
||||
|
||||
ScrollableMenuRow(scrollState = mainScroll) {
|
||||
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||
CompactSectionLink("Verein", MenuSection.VEREIN, section) { sectionOverride.value = MenuSection.VEREIN }
|
||||
CompactSectionLink("Mannschaften", MenuSection.MANNSCHAFTEN, section) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
|
||||
CompactSectionLink("Training", MenuSection.TRAINING, section) { sectionOverride.value = MenuSection.TRAINING }
|
||||
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
|
||||
CompactSectionLink("Newsletter", MenuSection.NEWSLETTER, section) { sectionOverride.value = MenuSection.NEWSLETTER }
|
||||
if (navigationState.showGallery) {
|
||||
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.VEREIN }
|
||||
}
|
||||
if (navigationState.loggedIn) {
|
||||
CompactSectionLink("Intern", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN }
|
||||
if (navigationState.canAccessCms) {
|
||||
CompactSectionLink("CMS", MenuSection.CMS, section) { sectionOverride.value = MenuSection.CMS }
|
||||
}
|
||||
} else {
|
||||
CompactLink("Login", Destinations.Login.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||
}
|
||||
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate) { sectionOverride.value = null }
|
||||
}
|
||||
|
||||
ScrollableMenuRow(scrollState = subScroll, topPadding = 3.dp) {
|
||||
subItems.forEach { item ->
|
||||
SubLink(item.label, item.route == selectedRoute) {
|
||||
if (item.route == LOGOUT_ROUTE) {
|
||||
onLogout()
|
||||
} else {
|
||||
onNavigate(item.route)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CompactSectionLink(
|
||||
label: String,
|
||||
section: MenuSection,
|
||||
activeSection: MenuSection?,
|
||||
modifier: Modifier = Modifier,
|
||||
onSelect: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
color = if (activeSection == section) Primary600 else Color.Transparent,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = modifier.clickable { onSelect() },
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = if (activeSection == section) Color.White else Color(0xFFD4D4D8),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,12 +156,13 @@ private fun CompactNavigation(
|
||||
private fun WebTabletNavigation(
|
||||
selectedRoute: String?,
|
||||
onNavigate: (String) -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
navigationState: NavigationUiState,
|
||||
) {
|
||||
val section = menuSection(selectedRoute)
|
||||
var cmsExpanded = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||
// Helper that closes the CMS submenu when navigating away
|
||||
val navigateAndClose: (String) -> Unit = { route -> cmsExpanded.value = false; onNavigate(route) }
|
||||
val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
|
||||
val section = sectionOverride.value ?: menuSection(selectedRoute)
|
||||
val subScroll = rememberScrollState()
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Brand()
|
||||
Spacer(Modifier.width(16.dp))
|
||||
@@ -103,75 +171,91 @@ private fun WebTabletNavigation(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
MainLink("Start", selectedRoute == Destinations.Home.route, onClick = { navigateAndClose(Destinations.Home.route) })
|
||||
MainLink("Verein", section == MenuSection.VEREIN, onClick = { navigateAndClose(Destinations.VereinAbout.route) })
|
||||
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { navigateAndClose(Destinations.Mannschaften.route) })
|
||||
MainLink("Training", section == MenuSection.TRAINING, onClick = { navigateAndClose(Destinations.Training.route) })
|
||||
MainLink("Mitgliedschaft", selectedRoute == Destinations.Membership.route, onClick = { navigateAndClose(Destinations.Membership.route) })
|
||||
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = { navigateAndClose(Destinations.Termine.route) })
|
||||
MainLink("Start", selectedRoute == Destinations.Home.route, onClick = {
|
||||
sectionOverride.value = null
|
||||
onNavigate(Destinations.Home.route)
|
||||
})
|
||||
MainLink("Verein", section == MenuSection.VEREIN, onClick = {
|
||||
sectionOverride.value = MenuSection.VEREIN
|
||||
onNavigate(Destinations.VereinAbout.route)
|
||||
})
|
||||
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = {
|
||||
sectionOverride.value = MenuSection.MANNSCHAFTEN
|
||||
onNavigate(Destinations.Mannschaften.route)
|
||||
})
|
||||
MainLink("Training", section == MenuSection.TRAINING, onClick = {
|
||||
sectionOverride.value = MenuSection.TRAINING
|
||||
onNavigate(Destinations.Training.route)
|
||||
})
|
||||
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = {
|
||||
sectionOverride.value = null
|
||||
onNavigate(Destinations.Termine.route)
|
||||
})
|
||||
if (navigationState.showGallery) {
|
||||
MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = { onNavigate(Destinations.Gallery.route) })
|
||||
MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = {
|
||||
sectionOverride.value = MenuSection.VEREIN
|
||||
onNavigate(Destinations.Gallery.route)
|
||||
})
|
||||
}
|
||||
MainLink("Newsletter", section == MenuSection.NEWSLETTER, onClick = { onNavigate(Destinations.NewsletterSubscribe.route) })
|
||||
MainLink("Newsletter", section == MenuSection.NEWSLETTER, onClick = {
|
||||
sectionOverride.value = MenuSection.NEWSLETTER
|
||||
onNavigate(Destinations.NewsletterSubscribe.route)
|
||||
})
|
||||
if (navigationState.loggedIn) {
|
||||
MainLink("Intern", section == MenuSection.INTERN, onClick = { onNavigate(Destinations.MemberArea.route) })
|
||||
}
|
||||
MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = { navigateAndClose(Destinations.Contact.route) })
|
||||
TextButton(onClick = { navigateAndClose(Destinations.Login.route) }) { Text("Login", color = Color.White) }
|
||||
}
|
||||
}
|
||||
val subItems = submenu(section, navigationState)
|
||||
// determine CMS parent index and children
|
||||
val cmsIndex = subItems.indexOfFirst { it.label == "CMS" }
|
||||
val cmsChildren = if (cmsIndex >= 0) subItems.subList(cmsIndex + 1, subItems.size) else emptyList<MenuTarget>()
|
||||
if (cmsChildren.any { it.route == selectedRoute }) {
|
||||
cmsExpanded.value = true
|
||||
}
|
||||
// First row: render all subitems but do NOT render CMS children here
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(top = 3.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
subItems.forEachIndexed { idx, item ->
|
||||
if (idx == cmsIndex) {
|
||||
// CMS parent toggle
|
||||
SubLink(item.label, item.route == selectedRoute) {
|
||||
cmsExpanded.value = !cmsExpanded.value
|
||||
MainLink("Intern", section == MenuSection.INTERN, onClick = {
|
||||
sectionOverride.value = MenuSection.INTERN
|
||||
})
|
||||
if (navigationState.canAccessCms) {
|
||||
MainLink("CMS", section == MenuSection.CMS, onClick = {
|
||||
sectionOverride.value = MenuSection.CMS
|
||||
onNavigate(Destinations.Cms.route)
|
||||
})
|
||||
}
|
||||
} else if (idx > cmsIndex && cmsIndex >= 0) {
|
||||
// skip cms children here; they'll be rendered in the second row when expanded
|
||||
} else {
|
||||
// normal item before CMS: close cms submenu on navigate
|
||||
SubLink(item.label, item.route == selectedRoute) { navigateAndClose(item.route) }
|
||||
}
|
||||
MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = {
|
||||
sectionOverride.value = null
|
||||
onNavigate(Destinations.Contact.route)
|
||||
})
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
if (navigationState.loggedIn) {
|
||||
TextButton(onClick = onLogout) { Text("Logout", color = Color.White) }
|
||||
} else {
|
||||
TextButton(onClick = {
|
||||
sectionOverride.value = null
|
||||
onNavigate(Destinations.Login.route)
|
||||
}) { Text("Login", color = Color.White) }
|
||||
}
|
||||
}
|
||||
|
||||
// Second row: when CMS expanded, render its children beneath
|
||||
if (cmsExpanded.value && cmsChildren.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(top = 6.dp, bottom = 3.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
cmsChildren.forEach { child ->
|
||||
SubLink(child.label, child.route == selectedRoute) { onNavigate(child.route) }
|
||||
val subItems = submenu(section, navigationState)
|
||||
ScrollableMenuRow(scrollState = subScroll, topPadding = 3.dp) {
|
||||
subItems.forEach { item ->
|
||||
SubLink(item.label, item.route == selectedRoute) {
|
||||
if (item.route == LOGOUT_ROUTE) {
|
||||
onLogout()
|
||||
} else {
|
||||
onNavigate(item.route)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BrandRow(onLogin: () -> Unit) {
|
||||
private fun BrandRow(
|
||||
loggedIn: Boolean,
|
||||
onLogin: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Brand()
|
||||
Spacer(Modifier.weight(1f))
|
||||
TextButton(onClick = onLogin) { Text("Login", color = Color.White) }
|
||||
if (loggedIn) {
|
||||
TextButton(onClick = onLogout) { Text("Logout", color = Color.White) }
|
||||
} else {
|
||||
TextButton(onClick = onLogin) { Text("Login", color = Color.White) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,8 +298,8 @@ private fun MainLink(
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = Color.White.copy(alpha = if (selected || primary) 1f else 0.82f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White.copy(alpha = if (selected || primary) 1f else 0.94f),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 9.dp),
|
||||
maxLines = 1,
|
||||
)
|
||||
@@ -229,23 +313,62 @@ private fun CompactLink(
|
||||
selectedRoute: String?,
|
||||
onNavigate: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
beforeNavigate: () -> Unit = {},
|
||||
) {
|
||||
Surface(
|
||||
color = if (route == selectedRoute) Primary600 else Color.Transparent,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
modifier = modifier.clickable { onNavigate(route) },
|
||||
modifier = modifier.clickable {
|
||||
beforeNavigate()
|
||||
onNavigate(route)
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = if (route == selectedRoute) Color.White else Color(0xFFD4D4D8),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScrollableMenuRow(
|
||||
scrollState: ScrollState,
|
||||
topPadding: Dp = 0.dp,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = topPadding),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
if (scrollState.canScrollBackward) "◀" else "",
|
||||
color = Color(0xFFD4D4D8),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.width(14.dp),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.horizontalScroll(scrollState),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = content,
|
||||
)
|
||||
Text(
|
||||
if (scrollState.canScrollForward) "▶" else "",
|
||||
color = Color(0xFFD4D4D8),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.width(14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Surface(
|
||||
@@ -256,7 +379,7 @@ private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) {
|
||||
Text(
|
||||
label,
|
||||
color = if (selected) Color.White else Color(0xFFD4D4D8),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp),
|
||||
maxLines = 1,
|
||||
)
|
||||
@@ -287,11 +410,15 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
|
||||
Destinations.NewsletterConfirm.route,
|
||||
Destinations.NewsletterConfirmed.route,
|
||||
Destinations.NewsletterUnsubscribed.route -> MenuSection.NEWSLETTER
|
||||
|
||||
Destinations.MemberArea.route,
|
||||
Destinations.Members.route,
|
||||
Destinations.Qttr.route,
|
||||
Destinations.MemberNews.route,
|
||||
Destinations.Profile.route,
|
||||
Destinations.MemberApi.route,
|
||||
Destinations.NotificationSettings.route,
|
||||
Destinations.MemberApi.route -> MenuSection.INTERN
|
||||
|
||||
Destinations.CmsStartseite.route,
|
||||
Destinations.CmsInhalte.route,
|
||||
Destinations.CmsVereinsmeisterschaften.route,
|
||||
@@ -302,7 +429,8 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
|
||||
Destinations.CmsEinstellungen.route,
|
||||
Destinations.CmsBenutzer.route,
|
||||
Destinations.CmsPasswordResetDiagnostics.route,
|
||||
Destinations.Cms.route -> MenuSection.INTERN
|
||||
Destinations.Cms.route -> MenuSection.CMS
|
||||
|
||||
else -> null
|
||||
}.let { section ->
|
||||
if (section == null && route?.startsWith("mannschaften/") == true) MenuSection.MANNSCHAFTEN else section
|
||||
@@ -319,42 +447,52 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
|
||||
MenuTarget("Links", Destinations.Links.route),
|
||||
MenuTarget("Impressum", Destinations.Impressum.route),
|
||||
)
|
||||
|
||||
MenuSection.MANNSCHAFTEN -> listOf(
|
||||
MenuTarget("Übersicht", Destinations.Mannschaften.route),
|
||||
) + state.teams.map { MenuTarget(it.mannschaft, Destinations.MannschaftDetail.create(it.slug)) } + listOf(
|
||||
MenuTarget("Spielpläne", Destinations.Spielplan.route),
|
||||
MenuTarget("Spielsysteme", Destinations.Spielsysteme.route),
|
||||
)
|
||||
|
||||
MenuSection.TRAINING -> listOf(
|
||||
MenuTarget("Trainingszeiten", Destinations.Training.route),
|
||||
MenuTarget("Trainer", Destinations.Trainer.route),
|
||||
MenuTarget("Anfänger", Destinations.Anfaenger.route),
|
||||
MenuTarget("TT-Regeln", Destinations.Regeln.route),
|
||||
)
|
||||
|
||||
MenuSection.NEWSLETTER -> listOf(
|
||||
MenuTarget("Abonnieren", Destinations.NewsletterSubscribe.route),
|
||||
MenuTarget("Abmelden", Destinations.NewsletterUnsubscribe.route),
|
||||
MenuTarget("Bestätigt", Destinations.NewsletterConfirmed.route),
|
||||
)
|
||||
|
||||
MenuSection.INTERN -> buildList {
|
||||
add(MenuTarget("Übersicht", Destinations.MemberArea.route))
|
||||
add(MenuTarget("Mitgliederliste", Destinations.Members.route))
|
||||
add(MenuTarget("QTTR", Destinations.Qttr.route))
|
||||
add(MenuTarget("News", Destinations.MemberNews.route))
|
||||
add(MenuTarget("Mein Profil", Destinations.Profile.route))
|
||||
add(MenuTarget("Benachrichtigungen", Destinations.NotificationSettings.route))
|
||||
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route))
|
||||
if (state.isAdmin || state.canAccessContactRequests || state.canAccessNewsletter) add(MenuTarget("CMS", Destinations.Cms.route))
|
||||
// CMS child items (will be rendered when CMS parent is expanded)
|
||||
if (state.isAdmin || state.canAccessContactRequests || state.canAccessNewsletter) add(MenuTarget("Übersicht", Destinations.Cms.route))
|
||||
if (state.isAdmin) add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
|
||||
if (state.isAdmin) add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
|
||||
if (state.isAdmin) add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
|
||||
if (state.isAdmin) add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route))
|
||||
if (state.isAdmin) add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
|
||||
}
|
||||
|
||||
MenuSection.CMS -> buildList {
|
||||
if (state.canAccessFullCms) {
|
||||
add(MenuTarget("Übersicht", Destinations.Cms.route))
|
||||
add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
|
||||
add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
|
||||
add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
|
||||
add(MenuTarget("News", Destinations.MemberNews.route))
|
||||
add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route))
|
||||
add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
|
||||
add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
|
||||
add(MenuTarget("Benutzerverwaltung", Destinations.CmsBenutzer.route))
|
||||
}
|
||||
if (state.canAccessNewsletter) add(MenuTarget("Newsletter", Destinations.CmsNewsletter.route))
|
||||
if (state.canAccessContactRequests) add(MenuTarget("Kontaktanfragen", Destinations.CmsContactRequests.route))
|
||||
if (state.isAdmin) add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
|
||||
if (state.isAdmin) add(MenuTarget("Benutzer", Destinations.CmsBenutzer.route))
|
||||
if (state.isAdmin) add(MenuTarget("Reset-Diagnose", Destinations.CmsPasswordResetDiagnostics.route))
|
||||
}
|
||||
|
||||
null -> emptyList()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.harheimertc.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
|
||||
@Composable
|
||||
internal fun LoadingState(message: String = "Daten werden geladen...") {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
CircularProgressIndicator(color = Primary600)
|
||||
Text(message, color = Accent500, modifier = Modifier.padding(top = 12.dp))
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,12 @@ sealed class Destinations(val route: String) {
|
||||
object Links : Destinations("verein/links")
|
||||
object Impressum : Destinations("impressum")
|
||||
object Mannschaften : Destinations("mannschaften")
|
||||
object MannschaftDetail : Destinations("mannschaften/{slug}") {
|
||||
fun create(slug: String): String = "mannschaften/$slug"
|
||||
object MannschaftDetail : Destinations("mannschaften/{slug}?season={season}") {
|
||||
fun create(slug: String, season: String? = null): String {
|
||||
val encodedSlug = android.net.Uri.encode(slug)
|
||||
val selectedSeason = season?.takeIf { it.isNotBlank() } ?: return "mannschaften/$encodedSlug"
|
||||
return "mannschaften/$encodedSlug?season=${android.net.Uri.encode(selectedSeason)}"
|
||||
}
|
||||
}
|
||||
object MannschaftLegacyDetail : Destinations("mannschaft/{slug}") {
|
||||
fun create(slug: String): String = "mannschaft/$slug"
|
||||
@@ -36,8 +40,10 @@ sealed class Destinations(val route: String) {
|
||||
object Register : Destinations("register")
|
||||
object MemberArea : Destinations("intern")
|
||||
object Members : Destinations("intern/mitglieder")
|
||||
object Qttr : Destinations("intern/qttr")
|
||||
object MemberNews : Destinations("intern/news")
|
||||
object Profile : Destinations("intern/profil")
|
||||
object NotificationSettings : Destinations("intern/benachrichtigungen")
|
||||
object MemberApi : Destinations("intern/api")
|
||||
object CmsStartseite : Destinations("cms/startseite")
|
||||
object CmsInhalte : Destinations("cms/inhalte")
|
||||
|
||||
@@ -2,15 +2,22 @@ package de.harheimertc.ui.navigation
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
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.Column
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -26,7 +33,9 @@ fun NavGraph(
|
||||
val backStackEntry = navController.currentBackStackEntryAsState().value
|
||||
val route = backStackEntry?.destination?.route
|
||||
val currentRoute = if (route == Destinations.MannschaftDetail.route) {
|
||||
backStackEntry.arguments?.getString("slug")?.let(Destinations.MannschaftDetail::create)
|
||||
backStackEntry.arguments?.getString("slug")?.let { slug ->
|
||||
Destinations.MannschaftDetail.create(slug, backStackEntry.arguments?.getString("season"))
|
||||
}
|
||||
} else route
|
||||
val navigationState by navigationViewModel.state.collectAsState()
|
||||
LaunchedEffect(currentRoute) {
|
||||
@@ -35,10 +44,27 @@ fun NavGraph(
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val persistentNavigation = maxWidth >= 600.dp
|
||||
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) {
|
||||
AppNavigationHeader(
|
||||
selectedRoute = currentRoute,
|
||||
onNavigate = navController::navigateTopLevel,
|
||||
onLogout = {
|
||||
navigationViewModel.logout {
|
||||
navController.navigate(Destinations.Home.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
},
|
||||
webTabletNavigation = true,
|
||||
navigationState = navigationState,
|
||||
)
|
||||
@@ -52,6 +78,8 @@ fun NavGraph(
|
||||
de.harheimertc.ui.screens.home.HomeScreen(
|
||||
navController = navController,
|
||||
showNavigationHeader = !persistentNavigation,
|
||||
navigationViewModel = navigationViewModel,
|
||||
viewModel = hiltViewModel(),
|
||||
)
|
||||
}
|
||||
composable(Destinations.VereinAbout.route) {
|
||||
@@ -111,9 +139,13 @@ fun NavGraph(
|
||||
composable("mannschaften/jugend") {
|
||||
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.MannschaftDetail.route) { entry ->
|
||||
composable(
|
||||
route = Destinations.MannschaftDetail.route,
|
||||
arguments = listOf(navArgument("season") { nullable = true; defaultValue = null }),
|
||||
) { entry ->
|
||||
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
|
||||
slug = entry.arguments?.getString("slug").orEmpty(),
|
||||
season = entry.arguments?.getString("season"),
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
@@ -121,6 +153,7 @@ fun NavGraph(
|
||||
composable(Destinations.MannschaftLegacyDetail.route) { entry ->
|
||||
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
|
||||
slug = entry.arguments?.getString("slug").orEmpty(),
|
||||
season = null,
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
@@ -253,6 +286,7 @@ fun NavGraph(
|
||||
de.harheimertc.ui.screens.memberarea.MemberAreaScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
navigationState = navigationState,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Members.route) {
|
||||
@@ -261,6 +295,12 @@ fun NavGraph(
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.Qttr.route) {
|
||||
de.harheimertc.ui.screens.memberarea.QttrScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.MemberNews.route) {
|
||||
de.harheimertc.ui.screens.memberarea.MemberNewsScreen(
|
||||
navController = navController,
|
||||
@@ -273,6 +313,13 @@ fun NavGraph(
|
||||
showBackNavigation = !persistentNavigation,
|
||||
)
|
||||
}
|
||||
composable(Destinations.NotificationSettings.route) {
|
||||
de.harheimertc.ui.screens.notifications.NotificationSettingsScreen(
|
||||
navController = navController,
|
||||
showBackNavigation = !persistentNavigation,
|
||||
navigationState = navigationState,
|
||||
)
|
||||
}
|
||||
composable(Destinations.MemberApi.route) {
|
||||
de.harheimertc.ui.screens.memberarea.MemberApiScreen(
|
||||
navController = navController,
|
||||
|
||||
@@ -3,10 +3,13 @@ package de.harheimertc.ui.navigation
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.ConnectivityMonitor
|
||||
import de.harheimertc.repositories.AuthRepository
|
||||
import de.harheimertc.repositories.GalleryRepository
|
||||
import de.harheimertc.repositories.LoginRepository
|
||||
import de.harheimertc.repositories.Mannschaft
|
||||
import de.harheimertc.repositories.MannschaftenRepository
|
||||
import de.harheimertc.repositories.PushTokenRepository
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -18,10 +21,13 @@ data class NavigationUiState(
|
||||
val hasGalleryImages: Boolean = false,
|
||||
val loggedIn: Boolean = false,
|
||||
val roles: Set<String> = emptySet(),
|
||||
val connectionNote: String? = null,
|
||||
) {
|
||||
val isAdmin: Boolean get() = "admin" in roles
|
||||
val canAccessFullCms: Boolean get() = roles.any { it in setOf("admin", "vorstand") }
|
||||
val canAccessNewsletter: Boolean get() = roles.any { it in setOf("admin", "vorstand", "newsletter") }
|
||||
val canAccessContactRequests: Boolean get() = roles.any { it in setOf("admin", "vorstand", "trainer") }
|
||||
val canAccessCms: Boolean get() = canAccessFullCms || canAccessNewsletter || canAccessContactRequests
|
||||
val showGallery: Boolean get() = hasGalleryImages || canAccessNewsletter
|
||||
}
|
||||
|
||||
@@ -30,12 +36,24 @@ class NavigationViewModel @Inject constructor(
|
||||
private val mannschaftenRepository: MannschaftenRepository,
|
||||
private val galleryRepository: GalleryRepository,
|
||||
private val loginRepository: LoginRepository,
|
||||
private val authRepository: AuthRepository,
|
||||
private val connectivityMonitor: ConnectivityMonitor,
|
||||
private val pushTokenRepository: PushTokenRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(NavigationUiState())
|
||||
val state: StateFlow<NavigationUiState> = _state
|
||||
|
||||
init {
|
||||
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() {
|
||||
@@ -44,22 +62,54 @@ class NavigationViewModel @Inject constructor(
|
||||
val gallery = async { galleryRepository.hasPublicImages().getOrDefault(false) }
|
||||
val auth = async { loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) }
|
||||
val status = auth.await()
|
||||
val hasStoredSession = !authRepository.getToken().isNullOrBlank()
|
||||
val loggedIn = hasStoredSession || status.isLoggedIn
|
||||
_state.value = NavigationUiState(
|
||||
teams = teams.await(),
|
||||
hasGalleryImages = gallery.await(),
|
||||
loggedIn = status.isLoggedIn,
|
||||
roles = (status.roles + status.user?.roles.orEmpty()).toSet(),
|
||||
loggedIn = loggedIn,
|
||||
roles = status.navigationRoles(),
|
||||
connectionNote = null,
|
||||
)
|
||||
if (loggedIn) registerPushToken()
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshSession() {
|
||||
viewModelScope.launch {
|
||||
val status = loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse())
|
||||
val hasStoredSession = !authRepository.getToken().isNullOrBlank()
|
||||
val loggedIn = hasStoredSession || status.isLoggedIn
|
||||
_state.value = _state.value.copy(
|
||||
loggedIn = status.isLoggedIn,
|
||||
roles = (status.roles + status.user?.roles.orEmpty()).toSet(),
|
||||
loggedIn = loggedIn,
|
||||
roles = status.navigationRoles(),
|
||||
connectionNote = _state.value.connectionNote,
|
||||
)
|
||||
if (loggedIn) registerPushToken()
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerPushToken() {
|
||||
viewModelScope.launch {
|
||||
pushTokenRepository.registerCurrentDevice()
|
||||
}
|
||||
}
|
||||
|
||||
fun logout(onComplete: () -> Unit = {}) {
|
||||
viewModelScope.launch {
|
||||
loginRepository.logout()
|
||||
_state.value = _state.value.copy(
|
||||
loggedIn = false,
|
||||
roles = emptySet(),
|
||||
connectionNote = _state.value.connectionNote,
|
||||
)
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun de.harheimertc.data.AuthStatusResponse.navigationRoles(): Set<String> = buildSet {
|
||||
addAll(roles)
|
||||
role?.takeIf { it.isNotBlank() }?.let(::add)
|
||||
addAll(user?.roles.orEmpty())
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Text
|
||||
@@ -36,6 +35,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.NewsSaveRequest
|
||||
import de.harheimertc.ui.components.FormMessages
|
||||
import de.harheimertc.ui.components.LoadingState
|
||||
import de.harheimertc.ui.components.NativeRichTextEditor
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -49,7 +49,7 @@ import java.util.Locale
|
||||
fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
var selection by remember { mutableStateOf(setOf<Int>()) }
|
||||
var selection by remember { mutableStateOf(setOf<String>()) }
|
||||
val loginVm: de.harheimertc.ui.screens.login.LoginViewModel = hiltViewModel()
|
||||
val loginState by loginVm.state.collectAsState()
|
||||
val canWrite = loginState.roles.any { it == "admin" || it == "vorstand" }
|
||||
@@ -62,7 +62,7 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
|
||||
|
||||
// Local dialog state for create/edit + delete confirmation (hoisted)
|
||||
var dialogOpen by remember { mutableStateOf(false) }
|
||||
var deletingIds by remember { mutableStateOf<List<Int>?>(null) }
|
||||
var deletingIds by remember { mutableStateOf<List<String>?>(null) }
|
||||
var editing by remember { mutableStateOf<NewsDto?>(null) }
|
||||
var title by remember { mutableStateOf("") }
|
||||
var content by remember { mutableStateOf("") }
|
||||
@@ -112,7 +112,7 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
|
||||
}
|
||||
|
||||
CmsPage(navController, showBackNavigation, "News", "Interne und öffentliche News") {
|
||||
if (state.loading) item { CircularProgressIndicator() }
|
||||
if (state.loading) item { LoadingState("News werden geladen...") }
|
||||
|
||||
item {
|
||||
Button(onClick = { viewModel.load(); /* ensure latest */ }, modifier = Modifier.fillMaxWidth()) { Text("Neu laden") }
|
||||
@@ -256,9 +256,9 @@ fun CmsNewsScreen(navController: NavController, showBackNavigation: Boolean, vie
|
||||
private fun NewsListItem(
|
||||
news: NewsDto,
|
||||
selected: Boolean = false,
|
||||
onSelect: (Int?, Boolean) -> Unit = { _, _ -> },
|
||||
onSelect: (String?, Boolean) -> Unit = { _, _ -> },
|
||||
onEdit: (NewsDto) -> Unit,
|
||||
onDelete: (Int) -> Unit,
|
||||
onDelete: (String) -> Unit,
|
||||
) {
|
||||
androidx.compose.material3.Surface(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
|
||||
@@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -50,6 +49,7 @@ import de.harheimertc.data.PasswordResetMatchingUserDto
|
||||
import de.harheimertc.data.PasswordResetAttemptDto
|
||||
import de.harheimertc.data.PasswordResetStepDto
|
||||
import de.harheimertc.ui.components.FormMessages
|
||||
import de.harheimertc.ui.components.LoadingState
|
||||
import de.harheimertc.ui.components.NativeRichTextEditor
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.repositories.MeisterschaftResult
|
||||
@@ -67,7 +67,7 @@ import java.util.Locale
|
||||
fun CmsDashboardScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsPage(navController, showBackNavigation, "CMS", "Verwaltung und interne Werkzeuge") {
|
||||
if (state.loading) item { CircularProgressIndicator(color = Primary600) }
|
||||
if (state.loading) item { LoadingState("CMS-Daten werden geladen...") }
|
||||
item { CmsSummaryGrid(navController, state) }
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ fun CmsStartseiteScreen(navController: NavController, showBackNavigation: Boolea
|
||||
|
||||
CmsPage(navController, showBackNavigation, "Startseite konfigurieren", "Legen Sie die Reihenfolge der Elemente auf der Startseite fest.") {
|
||||
when {
|
||||
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
|
||||
state.loading || config == null -> item { LoadingState("Startseitenkonfiguration wird geladen...") }
|
||||
else -> {
|
||||
item {
|
||||
Button(
|
||||
@@ -171,7 +171,7 @@ fun CmsInhalteScreen(navController: NavController, showBackNavigation: Boolean,
|
||||
|
||||
CmsPage(navController, showBackNavigation, "Inhalte", "Vereinsseiten und strukturierte Inhalte") {
|
||||
when {
|
||||
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
|
||||
state.loading || config == null -> item { LoadingState("Inhalte werden geladen...") }
|
||||
else -> {
|
||||
item {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||
@@ -262,7 +262,7 @@ fun CmsVereinsmeisterschaftenScreen(navController: NavController, showBackNaviga
|
||||
|
||||
CmsPage(navController, showBackNavigation, "Vereinsmeisterschaften", "Ergebnisse und Hinweise als CSV-Inhalt bearbeiten") {
|
||||
when {
|
||||
state.loading -> item { CircularProgressIndicator(color = Primary600) }
|
||||
state.loading -> item { LoadingState("Vereinsmeisterschaften werden geladen...") }
|
||||
else -> {
|
||||
item {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -456,7 +456,7 @@ fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Bool
|
||||
|
||||
CmsPage(navController, showBackNavigation, "Sportbetrieb", "Trainingszeiten, Trainingsort und Trainer pflegen") {
|
||||
when {
|
||||
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
|
||||
state.loading || config == null -> item { LoadingState("Sportbetriebsdaten werden geladen...") }
|
||||
else -> {
|
||||
item {
|
||||
Button(
|
||||
@@ -555,7 +555,7 @@ fun CmsMitgliederverwaltungScreen(navController: NavController, showBackNavigati
|
||||
fun CmsContactRequestsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
CmsPage(navController, showBackNavigation, "Kontaktanfragen", "Eingegangene Nachrichten") {
|
||||
if (state.loading) item { CircularProgressIndicator(color = Primary600) }
|
||||
if (state.loading) item { LoadingState("Kontaktanfragen werden geladen...") }
|
||||
if (!state.loading && state.contactRequests.isEmpty()) item { EmptyCard("Keine Kontaktanfragen gefunden.") }
|
||||
items(state.contactRequests.size) { index -> ContactRequestCard(state.contactRequests[index], viewModel) }
|
||||
}
|
||||
@@ -592,7 +592,7 @@ fun CmsNewsletterScreen(
|
||||
var grpTargetGroup by remember { mutableStateOf("") }
|
||||
var grpSendToExternal by remember { mutableStateOf(true) }
|
||||
CmsPage(navController, showBackNavigation, "Newsletter", "Newsletter und Gruppen") {
|
||||
if (state.loading) item { CircularProgressIndicator(color = Primary600) }
|
||||
if (state.loading) item { LoadingState("Newsletter-Daten werden geladen...") }
|
||||
item {
|
||||
if (canWrite) Button(onClick = {
|
||||
editingNewsletter = null
|
||||
@@ -769,7 +769,7 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
|
||||
|
||||
CmsPage(navController, showBackNavigation, "Einstellungen", "Vereins- und Website-Konfiguration") {
|
||||
when {
|
||||
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
|
||||
state.loading || config == null -> item { LoadingState("Einstellungen werden geladen...") }
|
||||
else -> {
|
||||
item {
|
||||
Button(
|
||||
@@ -883,7 +883,7 @@ fun CmsPasswordResetDiagnosticsScreen(navController: NavController, showBackNavi
|
||||
}
|
||||
|
||||
if (state.loading) {
|
||||
item { CircularProgressIndicator(color = Primary600) }
|
||||
item { LoadingState("Diagnosedaten werden geladen...") }
|
||||
}
|
||||
|
||||
if (state.passwordResetSearchTerm.isNotBlank()) {
|
||||
@@ -1073,7 +1073,7 @@ private fun CmsConfigPage(
|
||||
) {
|
||||
CmsPage(navController, showBackNavigation, title, subtitle) {
|
||||
if (config == null) {
|
||||
item { CircularProgressIndicator(color = Primary600) }
|
||||
item { LoadingState("Konfiguration wird geladen...") }
|
||||
} else {
|
||||
item {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.harheimertc.ui.screens.cms
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.ConnectivityMonitor
|
||||
import de.harheimertc.data.CmsUserDto
|
||||
import de.harheimertc.data.ConfigResponse
|
||||
import de.harheimertc.data.ContactRequestDto
|
||||
@@ -43,12 +44,22 @@ data class CmsUiState(
|
||||
@HiltViewModel
|
||||
class CmsViewModel @Inject constructor(
|
||||
private val repository: CmsRepository,
|
||||
private val connectivityMonitor: ConnectivityMonitor,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(CmsUiState())
|
||||
val state: StateFlow<CmsUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
viewModelScope.launch {
|
||||
var wasOnline: Boolean? = null
|
||||
connectivityMonitor.online.collect { online ->
|
||||
if (online && wasOnline == false) {
|
||||
load()
|
||||
}
|
||||
wasOnline = online
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun load() {
|
||||
@@ -207,7 +218,7 @@ class CmsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun bulkSetPublic(ids: List<Int>, makePublic: Boolean) {
|
||||
fun bulkSetPublic(ids: List<String>, makePublic: Boolean) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
ids.forEach { id ->
|
||||
@@ -229,7 +240,7 @@ class CmsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun bulkSetHidden(ids: List<Int>, makeHidden: Boolean) {
|
||||
fun bulkSetHidden(ids: List<String>, makeHidden: Boolean) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
ids.forEach { id ->
|
||||
@@ -251,7 +262,7 @@ class CmsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun bulkDelete(ids: List<Int>) {
|
||||
fun bulkDelete(ids: List<String>) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
ids.forEach { id ->
|
||||
@@ -262,7 +273,7 @@ class CmsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteNews(id: Int) {
|
||||
fun deleteNews(id: String) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
repository.deleteNews(id)
|
||||
|
||||
@@ -14,7 +14,6 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
@@ -36,6 +35,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import de.harheimertc.R
|
||||
import de.harheimertc.ui.components.FormMessages
|
||||
import de.harheimertc.ui.components.LoadingState
|
||||
import de.harheimertc.ui.components.ImageGrid
|
||||
|
||||
@Composable
|
||||
@@ -131,7 +131,7 @@ fun GalleryScreen(viewModel: GalleryViewModel = hiltViewModel()) {
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
if (loading) {
|
||||
CircularProgressIndicator()
|
||||
LoadingState("Galerie wird geladen...")
|
||||
} else if (images.isEmpty()) {
|
||||
Text(text = stringResource(R.string.gallery_empty))
|
||||
} else {
|
||||
|
||||
@@ -44,15 +44,14 @@ import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import de.harheimertc.ui.navigation.NavigationViewModel
|
||||
import androidx.navigation.NavController
|
||||
import coil.compose.AsyncImage
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.data.HomepageSectionDto
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.SeasonDto
|
||||
@@ -77,9 +76,9 @@ import java.util.Locale
|
||||
fun HomeScreen(
|
||||
navController: NavController,
|
||||
showNavigationHeader: Boolean = true,
|
||||
viewModel: HomeViewModel = hiltViewModel(),
|
||||
navigationViewModel: NavigationViewModel,
|
||||
viewModel: HomeViewModel,
|
||||
) {
|
||||
val navigationViewModel: NavigationViewModel = hiltViewModel()
|
||||
val navigationState by navigationViewModel.state.collectAsState()
|
||||
val state by viewModel.state.collectAsState()
|
||||
var selectedNews by remember { mutableStateOf<NewsDto?>(null) }
|
||||
@@ -107,6 +106,13 @@ fun HomeScreen(
|
||||
AppNavigationHeader(
|
||||
selectedRoute = Destinations.Home.route,
|
||||
onNavigate = navController::navigate,
|
||||
onLogout = {
|
||||
navigationViewModel.logout {
|
||||
navController.navigate(Destinations.Home.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationState = navigationState,
|
||||
)
|
||||
}
|
||||
@@ -126,11 +132,54 @@ fun HomeScreen(
|
||||
onReset = viewModel::resetSections,
|
||||
)
|
||||
}
|
||||
if (state.error) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 18.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = state.errorMessage ?: "Daten konnten nicht geladen werden.",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
OutlinedButton(onClick = viewModel::load) {
|
||||
Text("Erneut versuchen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.debugDiagnostics.isNotEmpty()) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 18.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Technische Diagnose (vorübergehend)",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
Text(
|
||||
text = state.debugDiagnostics.joinToString("\n\n---\n\n"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
state.homepageSections.forEachIndexed { index, section ->
|
||||
if (!section.enabled) return@forEachIndexed
|
||||
val sectionKey = homeSectionKey(section)
|
||||
when (section.id) {
|
||||
"banner" -> item(key = "home_section_${sectionKey}_$index") { WebHero() }
|
||||
"banner" -> item(key = "home_section_${sectionKey}_$index") {
|
||||
WebHero(imageUrl = state.heroImageUrl)
|
||||
}
|
||||
"termine" -> item(key = "home_section_${sectionKey}_$index") {
|
||||
HomeTermineSection(
|
||||
termine = state.termine,
|
||||
@@ -454,7 +503,7 @@ private fun HomeSpielplanTeamWidgetSection(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WebHero() {
|
||||
private fun WebHero(imageUrl: String?) {
|
||||
val years = Calendar.getInstance().get(Calendar.YEAR) - 1954
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -463,12 +512,14 @@ private fun WebHero() {
|
||||
.background(Brush.verticalGradient(listOf(Color(0xFFFAFAFA), Color(0xFFF4F4F5)))),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = "${BuildConfig.API_BASE_URL}images/club_about_us.png",
|
||||
contentDescription = null,
|
||||
modifier = Modifier.matchParentSize().alpha(0.10f),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
if (!imageUrl.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.matchParentSize().alpha(0.10f),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 58.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
|
||||
@@ -34,6 +34,7 @@ data class HomeSpielplanTeamOption(
|
||||
|
||||
data class HomeUiState(
|
||||
val loading: Boolean = true,
|
||||
val heroImageUrl: String? = null,
|
||||
val termine: List<TerminDto> = emptyList(),
|
||||
val spiele: List<SpielDto> = emptyList(),
|
||||
val news: List<NewsDto> = emptyList(),
|
||||
@@ -44,6 +45,8 @@ data class HomeUiState(
|
||||
val spielplanWidgetErrors: Map<String, String> = emptyMap(),
|
||||
val widgetsLoading: Boolean = false,
|
||||
val error: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val debugDiagnostics: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
@@ -62,7 +65,12 @@ class HomeViewModel @Inject constructor(
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = false)
|
||||
_state.value = _state.value.copy(
|
||||
loading = true,
|
||||
error = false,
|
||||
errorMessage = null,
|
||||
debugDiagnostics = emptyList(),
|
||||
)
|
||||
repository.fetchHomeData()
|
||||
.onSuccess { data ->
|
||||
serverSections = normalizedHomepageSections(data.homepageSections)
|
||||
@@ -80,6 +88,7 @@ class HomeViewModel @Inject constructor(
|
||||
)
|
||||
_state.value = HomeUiState(
|
||||
loading = false,
|
||||
heroImageUrl = data.heroImageUrl,
|
||||
termine = data.termine
|
||||
.filter { it.asDateTime()?.isBefore(LocalDateTime.now()) != true }
|
||||
.sortedBy { it.asDateTime() }
|
||||
@@ -99,10 +108,16 @@ class HomeViewModel @Inject constructor(
|
||||
spielplanTeamsBySeason = widgetData.teamsBySeason,
|
||||
spielplanWidgetPreviews = widgetData.previewGamesBySectionKey,
|
||||
spielplanWidgetErrors = widgetData.errorsBySectionKey,
|
||||
debugDiagnostics = data.diagnostics,
|
||||
)
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = HomeUiState(loading = false, error = true)
|
||||
.onFailure { err ->
|
||||
_state.value = HomeUiState(
|
||||
loading = false,
|
||||
error = true,
|
||||
errorMessage = err.message ?: "Daten konnten nicht geladen werden.",
|
||||
debugDiagnostics = listOf(err.message ?: "Unbekannter Fehler"),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ fun PasswordResetScreen(
|
||||
val state by viewModel.state.collectAsState()
|
||||
AuthFormPage(
|
||||
title = "Passwort zurücksetzen",
|
||||
subtitle = "Geben Sie Ihre E-Mail-Adresse ein, um Ihr Passwort zurückzusetzen.",
|
||||
subtitle = "Geben Sie Ihre E-Mail-Adresse ein, um einen Reset-Link zu erhalten.",
|
||||
onBack = { navController.navigate(Destinations.Login.route) },
|
||||
showBackNavigation = showBackNavigation,
|
||||
) {
|
||||
@@ -68,7 +68,7 @@ fun PasswordResetScreen(
|
||||
TextButton(onClick = { navController.navigate(Destinations.Login.route) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Zurück zum Login")
|
||||
}
|
||||
AuthNotice("Sie erhalten eine E-Mail mit einem temporären Passwort, sofern ein Konto vorhanden ist.")
|
||||
AuthNotice("Sie erhalten eine E-Mail mit einem Reset-Link, sofern ein Konto vorhanden ist. Ihr bisheriges Passwort bleibt bis zur Änderung gültig.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,10 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -27,6 +29,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -34,15 +37,18 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.data.LeagueTableRowDto
|
||||
import de.harheimertc.repositories.Mannschaft
|
||||
import de.harheimertc.ui.components.LoadingState
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.ui.theme.Accent100
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent700
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
@@ -64,13 +70,22 @@ fun MannschaftenScreen(
|
||||
BackLink(navController, showBackNavigation)
|
||||
Text("Unsere Mannschaften", style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 18.dp))
|
||||
Text("Unsere aktiven Mannschaften in der aktuellen Saison", color = Accent500, modifier = Modifier.padding(top = 8.dp))
|
||||
if (state.seasons.isNotEmpty()) {
|
||||
SeasonSelector(
|
||||
seasons = state.seasons,
|
||||
selectedSeason = state.selectedSeason,
|
||||
onSeasonSelected = viewModel::selectSeason,
|
||||
modifier = Modifier.padding(top = 14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
when {
|
||||
state.seasonsLoading -> item { Loading() }
|
||||
state.loading -> item { Loading() }
|
||||
state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) }
|
||||
state.teams.isEmpty() -> item { Text("Keine Mannschaftsdaten geladen", color = Accent500) }
|
||||
else -> items(state.teams) { team ->
|
||||
TeamCard(team) { navController.navigate(Destinations.MannschaftDetail.create(team.slug)) }
|
||||
TeamCard(team) { navController.navigate(Destinations.MannschaftDetail.create(team.slug, state.selectedSeason)) }
|
||||
}
|
||||
}
|
||||
item {
|
||||
@@ -88,6 +103,38 @@ fun MannschaftenScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeasonSelector(
|
||||
seasons: List<de.harheimertc.data.SeasonDto>,
|
||||
selectedSeason: String,
|
||||
onSeasonSelected: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var open by remember { mutableStateOf(false) }
|
||||
val selectedLabel = seasons.firstOrNull { it.slug == selectedSeason }?.label ?: selectedSeason
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Saison", color = Accent700, style = MaterialTheme.typography.labelSmall)
|
||||
BoxWithConstraints {
|
||||
OutlinedButton(onClick = { open = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(selectedLabel.ifBlank { "-" }, modifier = Modifier.weight(1f), textAlign = TextAlign.Start)
|
||||
Text("v")
|
||||
}
|
||||
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
|
||||
seasons.forEach { season ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(season.label.ifBlank { season.slug }) },
|
||||
onClick = {
|
||||
open = false
|
||||
onSeasonSelected(season.slug)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TeamCard(team: Mannschaft, onOpen: () -> Unit) {
|
||||
Surface(
|
||||
@@ -114,13 +161,14 @@ private fun TeamCard(team: Mannschaft, onOpen: () -> Unit) {
|
||||
@Composable
|
||||
fun MannschaftDetailScreen(
|
||||
slug: String,
|
||||
season: String?,
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: MannschaftDetailViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
var selectedTab by rememberSaveable(slug) { mutableStateOf(DetailTab.Matches) }
|
||||
LaunchedEffect(slug) { viewModel.load(slug) }
|
||||
var selectedTab by rememberSaveable(slug, season) { mutableStateOf(DetailTab.Matches) }
|
||||
LaunchedEffect(slug, season) { viewModel.load(slug, season) }
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 22.dp),
|
||||
@@ -182,7 +230,7 @@ fun MannschaftDetailScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
} ?: item { ErrorPanel(state.matchesError ?: "Mannschaft nicht gefunden.") { viewModel.load(slug) } }
|
||||
} ?: item { ErrorPanel(state.matchesError ?: "Mannschaft nicht gefunden.") { viewModel.load(slug, season) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -377,9 +425,7 @@ private fun BackLink(navController: NavController, visible: Boolean) {
|
||||
|
||||
@Composable
|
||||
private fun Loading() {
|
||||
Column(Modifier.fillMaxWidth().padding(30.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(color = Primary600)
|
||||
}
|
||||
LoadingState("Mannschaftsdaten werden geladen...")
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.data.LeagueTableRowDto
|
||||
import de.harheimertc.data.SeasonDto
|
||||
import de.harheimertc.repositories.Mannschaft
|
||||
import de.harheimertc.repositories.MannschaftenRepository
|
||||
import de.harheimertc.repositories.SpielplanRepository
|
||||
@@ -17,6 +18,9 @@ data class MannschaftenUiState(
|
||||
val loading: Boolean = true,
|
||||
val error: String? = null,
|
||||
val teams: List<Mannschaft> = emptyList(),
|
||||
val seasons: List<SeasonDto> = emptyList(),
|
||||
val selectedSeason: String = "",
|
||||
val seasonsLoading: Boolean = false,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
@@ -27,17 +31,76 @@ class MannschaftenViewModel @Inject constructor(
|
||||
val state: StateFlow<MannschaftenUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
loadSeasonsAndMannschaften()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = MannschaftenUiState(loading = true)
|
||||
repository.fetchMannschaften()
|
||||
.onSuccess { _state.value = MannschaftenUiState(loading = false, teams = it) }
|
||||
.onFailure { _state.value = MannschaftenUiState(loading = false, error = "Mannschaften konnten nicht geladen werden.") }
|
||||
val season = _state.value.selectedSeason.ifBlank { null }
|
||||
_state.value = _state.value.copy(loading = true, error = null)
|
||||
repository.fetchMannschaften(season)
|
||||
.onSuccess { teams -> _state.value = _state.value.copy(loading = false, teams = teams) }
|
||||
.onFailure { _state.value = _state.value.copy(loading = false, error = "Mannschaften konnten nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun selectSeason(season: String) {
|
||||
if (season == _state.value.selectedSeason) return
|
||||
_state.value = _state.value.copy(selectedSeason = season)
|
||||
load()
|
||||
}
|
||||
|
||||
private fun loadSeasonsAndMannschaften() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(seasonsLoading = true, error = null)
|
||||
repository.fetchSeasons()
|
||||
.onSuccess { response ->
|
||||
val currentSeason = getCurrentSeasonSlug()
|
||||
val seasons = response.seasons
|
||||
.map { season -> SeasonDto(slug = season, label = formatSeasonLabel(season)) }
|
||||
.ifEmpty {
|
||||
val fallbackSeason = response.currentSeason.ifBlank { currentSeason }
|
||||
listOf(SeasonDto(slug = fallbackSeason, label = formatSeasonLabel(fallbackSeason)))
|
||||
}
|
||||
val serverCurrentSeason = response.currentSeason.ifBlank { currentSeason }
|
||||
val selectedSeason = when {
|
||||
seasons.any { it.slug == currentSeason } -> currentSeason
|
||||
seasons.any { it.slug == serverCurrentSeason } -> serverCurrentSeason
|
||||
response.defaultSeason.isNotBlank() -> response.defaultSeason
|
||||
seasons.isNotEmpty() -> seasons.first().slug
|
||||
else -> currentSeason
|
||||
}
|
||||
_state.value = _state.value.copy(
|
||||
seasonsLoading = false,
|
||||
seasons = seasons,
|
||||
selectedSeason = selectedSeason,
|
||||
)
|
||||
load()
|
||||
}
|
||||
.onFailure {
|
||||
val currentSeason = getCurrentSeasonSlug()
|
||||
_state.value = _state.value.copy(
|
||||
seasonsLoading = false,
|
||||
seasons = listOf(SeasonDto(slug = currentSeason, label = formatSeasonLabel(currentSeason))),
|
||||
selectedSeason = currentSeason,
|
||||
)
|
||||
load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCurrentSeasonSlug(): String {
|
||||
val now = java.util.Calendar.getInstance()
|
||||
val year = now.get(java.util.Calendar.YEAR)
|
||||
val startYear = if (now.get(java.util.Calendar.MONTH) >= 6) year else year - 1
|
||||
val endYear = startYear + 1
|
||||
return "%02d--%02d".format(startYear % 100, endYear % 100)
|
||||
}
|
||||
|
||||
private fun formatSeasonLabel(seasonSlug: String): String {
|
||||
val match = Regex("^(\\d{2})--(\\d{2})$").matchEntire(seasonSlug.trim()) ?: return seasonSlug
|
||||
return "20${match.groupValues[1]}/${match.groupValues[2]}"
|
||||
}
|
||||
}
|
||||
|
||||
data class MannschaftDetailUiState(
|
||||
@@ -58,35 +121,38 @@ class MannschaftDetailViewModel @Inject constructor(
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(MannschaftDetailUiState())
|
||||
val state: StateFlow<MannschaftDetailUiState> = _state
|
||||
private var loadedSlug: String? = null
|
||||
private var loadedKey: String? = null
|
||||
|
||||
fun load(slug: String) {
|
||||
if (loadedSlug == slug) return
|
||||
loadedSlug = slug
|
||||
fun load(slug: String, season: String? = null) {
|
||||
val selectedSeason = season?.takeIf { it.isNotBlank() }
|
||||
val key = "$slug|${selectedSeason.orEmpty()}"
|
||||
if (loadedKey == key) return
|
||||
loadedKey = key
|
||||
viewModelScope.launch {
|
||||
_state.value = MannschaftDetailUiState(loading = true)
|
||||
val team = mannschaftenRepository.fetchMannschaften().getOrDefault(emptyList()).find { it.slug == slug }
|
||||
_state.value = MannschaftDetailUiState(loading = true, season = selectedSeason)
|
||||
val team = mannschaftenRepository.fetchMannschaften(selectedSeason).getOrDefault(emptyList()).find { it.slug == slug }
|
||||
if (team == null) {
|
||||
_state.value = MannschaftDetailUiState(loading = false, matchesError = "Mannschaft nicht gefunden.")
|
||||
return@launch
|
||||
}
|
||||
spielplanRepository.fetchSpielplan()
|
||||
spielplanRepository.fetchSpielplan(selectedSeason)
|
||||
.onSuccess { plan ->
|
||||
_state.value = MannschaftDetailUiState(
|
||||
loading = false,
|
||||
team = team,
|
||||
matches = plan.data.filter { matchesTeam(it, team.mannschaft) },
|
||||
season = plan.season,
|
||||
season = plan.season ?: selectedSeason,
|
||||
)
|
||||
if (team.informationenLink.isNotBlank()) {
|
||||
loadTable(team, plan.season)
|
||||
loadTable(team, plan.season ?: selectedSeason)
|
||||
}
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = MannschaftDetailUiState(
|
||||
loading = false,
|
||||
team = team,
|
||||
matchesError = "Der aktuelle Spielplan konnte nicht geladen werden.",
|
||||
season = selectedSeason,
|
||||
matchesError = "Der Spielplan konnte nicht geladen werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -25,15 +25,20 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import android.util.Log
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.data.MemberDto
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.QttrRowDto
|
||||
import de.harheimertc.ui.components.LoadingState
|
||||
import de.harheimertc.ui.components.RichText
|
||||
import de.harheimertc.ui.theme.Accent100
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
@@ -158,7 +163,7 @@ fun MembersScreen(
|
||||
}
|
||||
|
||||
when {
|
||||
state.loading -> item { CircularProgressIndicator(color = Primary600) }
|
||||
state.loading -> item { LoadingState("Mitglieder werden geladen...") }
|
||||
state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) }
|
||||
display.isEmpty() -> item { Text("Keine Mitglieder gefunden.", color = Accent700) }
|
||||
else -> if (viewMode == "table") {
|
||||
@@ -166,7 +171,14 @@ fun MembersScreen(
|
||||
val m = display[index]
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(6.dp)) {
|
||||
Row(Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Column(Modifier.weight(1f)) { Text(m.name, color = Accent900) }
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
m.name,
|
||||
color = Accent900,
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
)
|
||||
}
|
||||
Column(Modifier.weight(1f)) { Text(m.email ?: "-", color = Primary600) }
|
||||
Column(Modifier.weight(1f)) { Text(m.phone ?: "-", color = Accent700) }
|
||||
}
|
||||
@@ -188,7 +200,7 @@ fun MemberNewsScreen(
|
||||
val state by viewModel.state.collectAsState()
|
||||
MemberAreaPage(navController, showBackNavigation, "News", "Neuigkeiten und Ankündigungen im Mitgliederbereich") {
|
||||
when {
|
||||
state.loading -> item { CircularProgressIndicator(color = Primary600) }
|
||||
state.loading -> item { LoadingState("News werden geladen...") }
|
||||
state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) }
|
||||
state.news.isEmpty() -> item { Text("Noch keine News vorhanden.", color = Accent700) }
|
||||
else -> items(state.news.size) { index -> NewsCard(state.news[index]) }
|
||||
@@ -200,7 +212,7 @@ fun MemberNewsScreen(
|
||||
fun MemberApiScreen(navController: NavController, showBackNavigation: Boolean) {
|
||||
val groups = listOf(
|
||||
"Authentifizierung" to listOf("POST /api/auth/login", "POST /api/auth/refresh", "GET /api/auth/status", "POST /api/auth/logout"),
|
||||
"Mitgliederbereich" to listOf("GET /api/members", "GET /api/news", "GET /api/profile", "PUT /api/profile"),
|
||||
"Mitgliederbereich" to listOf("GET /api/members", "GET /api/mitgliederbereich/qttr", "GET /api/news", "GET /api/profile", "PUT /api/profile"),
|
||||
"CMS" to listOf("GET /api/cms/users/list", "GET /api/cms/contact-requests", "GET /api/newsletter/list", "GET /api/config"),
|
||||
)
|
||||
MemberAreaPage(navController, showBackNavigation, "API-Dokumentation", "Kurzüberblick der wichtigsten App-Endpunkte") {
|
||||
@@ -229,6 +241,42 @@ fun MemberApiScreen(navController: NavController, showBackNavigation: Boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun QttrScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
viewModel: QttrViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val externalUrl = "https://www.mytischtennis.de/rankings/andro-rangliste?continent=all&country=Deutschland&all-players=on&as=DE.WE.R4.07&di=DE.WE.R4.07.04&area=DE.WE.R4.07.04.43&clubnr-search=Harheimer+TC&clubnr=43030&fednickname=HeTTV&gender=all¤t-ranking=yes&ttr-range=100%3B3000&birth-range=1926%3B2021"
|
||||
|
||||
MemberAreaPage(navController, showBackNavigation, "QTTR-Werte", "Aus technischen Gründen sind nur die QTTR-Werte verfügbar.") {
|
||||
item {
|
||||
Surface(color = Primary100, shape = RoundedCornerShape(12.dp)) {
|
||||
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Für TTR bitte die myTischtennis-Rangliste verwenden.", color = Primary900)
|
||||
TextButton(onClick = { uriHandler.openUri(externalUrl) }) { Text("myTischtennis öffnen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(state.title ?: "Andro-Rangliste", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
Text("${state.rows.size} Einträge · Aktualisiert ${state.importedAt ?: "unbekannt"}", color = Accent500)
|
||||
}
|
||||
}
|
||||
}
|
||||
when {
|
||||
state.loading -> item { LoadingState("QTTR-Werte werden geladen...") }
|
||||
state.error != null -> item { ErrorCard(state.error.orEmpty(), viewModel::load) }
|
||||
state.rows.isEmpty() -> item { Text("Keine QTTR-Werte gefunden.", color = Accent700) }
|
||||
else -> items(state.rows.size) { index -> QttrRowCard(state.rows[index], isOwnRow(state.rows[index].playerName, state.currentUserName)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemberAreaPage(
|
||||
navController: NavController,
|
||||
@@ -259,7 +307,13 @@ private fun MemberAreaPage(
|
||||
private fun MemberCard(member: MemberDto, onEdit: (MemberDto) -> Unit = {}, onDelete: (MemberDto) -> Unit = {}) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
Text(member.name.ifBlank { "${member.firstName} ${member.lastName}".trim() }, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
Text(
|
||||
member.name.ifBlank { "${member.firstName} ${member.lastName}".trim() },
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontFamily = FontFamily.SansSerif,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
color = Accent900,
|
||||
)
|
||||
if (!member.email.isNullOrBlank()) Text(member.email, color = Primary600)
|
||||
if (!member.phone.isNullOrBlank()) Text(member.phone, color = Accent700)
|
||||
if (!member.birthday.isNullOrBlank()) {
|
||||
@@ -308,6 +362,83 @@ private fun Badge(label: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QttrRowCard(row: QttrRowDto, highlighted: Boolean) {
|
||||
Surface(
|
||||
color = if (highlighted) Primary100 else Color.White,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
shadowElevation = 3.dp,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(18.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Surface(color = if (highlighted) Primary100 else Accent100, shape = RoundedCornerShape(10.dp), modifier = Modifier.size(46.dp)) {
|
||||
Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
|
||||
Text(row.rank?.toString() ?: "-", color = Accent900, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(
|
||||
row.playerName.ifBlank { "Unbekannt" },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = qttrNameColor(row.gender, isMinor(row.birthdate)),
|
||||
fontWeight = if (highlighted) FontWeight.Bold else FontWeight.Medium,
|
||||
)
|
||||
Text(row.clubName.ifBlank { "Harheimer TC" }, color = qttrNameColor(row.gender, isMinor(row.birthdate)).copy(alpha = 0.88f))
|
||||
}
|
||||
Text(row.currentQttr?.toString() ?: "-", color = Primary600, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isOwnRow(playerName: String?, currentUserName: String): Boolean {
|
||||
fun normalize(value: String?): String {
|
||||
return java.text.Normalizer.normalize(value.orEmpty().trim().lowercase(), java.text.Normalizer.Form.NFKD)
|
||||
.replace(Regex("[\\u0300-\\u036f]"), "")
|
||||
.replace(Regex("[’'`]"), "")
|
||||
.replace(Regex("\\s+"), " ")
|
||||
}
|
||||
|
||||
val current = normalize(currentUserName)
|
||||
if (current.isBlank()) return false
|
||||
return normalize(playerName) == current
|
||||
}
|
||||
|
||||
private fun qttrNameColor(gender: String?, isMinor: Boolean): Color {
|
||||
val value = gender.orEmpty().trim().lowercase()
|
||||
return when {
|
||||
value.startsWith('m') || value.contains("männ") || value.contains("maenn") -> if (isMinor) Color(0xFF60A5FA) else Color(0xFF2563EB)
|
||||
value.startsWith('w') || value.contains("weib") || value.contains("frau") -> if (isMinor) Color(0xFFF9A8D4) else Color(0xFF9D174D)
|
||||
else -> Accent900
|
||||
}
|
||||
}
|
||||
|
||||
private fun isMinor(birthdate: String?): Boolean {
|
||||
val date = parseBirthdate(birthdate) ?: return false
|
||||
val today = java.time.LocalDate.now()
|
||||
var age = today.year - date.year
|
||||
if (today.monthValue < date.monthValue || (today.monthValue == date.monthValue && today.dayOfMonth < date.dayOfMonth)) {
|
||||
age -= 1
|
||||
}
|
||||
return age < 18
|
||||
}
|
||||
|
||||
private fun parseBirthdate(value: String?): java.time.LocalDate? {
|
||||
val raw = value.orEmpty().trim()
|
||||
if (raw.isBlank()) return null
|
||||
return try {
|
||||
if (Regex("^\\d{4}$").matches(raw)) {
|
||||
java.time.LocalDate.of(raw.toInt(), 1, 1)
|
||||
} else {
|
||||
java.time.LocalDate.parse(raw)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorCard(message: String, onRetry: () -> Unit) {
|
||||
Surface(color = Color(0xFFFEE2E2), shape = RoundedCornerShape(12.dp)) {
|
||||
|
||||
@@ -3,9 +3,13 @@ package de.harheimertc.ui.screens.memberarea
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.ConnectivityMonitor
|
||||
import de.harheimertc.data.AuthStatusResponse
|
||||
import de.harheimertc.data.MemberDto
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.QttrRowDto
|
||||
import de.harheimertc.repositories.MemberAreaRepository
|
||||
import de.harheimertc.repositories.LoginRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -21,12 +25,22 @@ data class MembersUiState(
|
||||
@HiltViewModel
|
||||
class MembersViewModel @Inject constructor(
|
||||
private val repository: MemberAreaRepository,
|
||||
private val connectivityMonitor: ConnectivityMonitor,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(MembersUiState())
|
||||
val state: StateFlow<MembersUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
viewModelScope.launch {
|
||||
var wasOnline: Boolean? = null
|
||||
connectivityMonitor.online.collect { online ->
|
||||
if (online && wasOnline == false) {
|
||||
load()
|
||||
}
|
||||
wasOnline = online
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateQuery(query: String) {
|
||||
@@ -84,12 +98,22 @@ data class MemberNewsUiState(
|
||||
@HiltViewModel
|
||||
class MemberNewsViewModel @Inject constructor(
|
||||
private val repository: MemberAreaRepository,
|
||||
private val connectivityMonitor: ConnectivityMonitor,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(MemberNewsUiState())
|
||||
val state: StateFlow<MemberNewsUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
viewModelScope.launch {
|
||||
var wasOnline: Boolean? = null
|
||||
connectivityMonitor.online.collect { online ->
|
||||
if (online && wasOnline == false) {
|
||||
load()
|
||||
}
|
||||
wasOnline = online
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun load() {
|
||||
@@ -101,3 +125,55 @@ class MemberNewsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class QttrUiState(
|
||||
val rows: List<QttrRowDto> = emptyList(),
|
||||
val title: String? = null,
|
||||
val importedAt: String? = null,
|
||||
val currentUserName: String = "",
|
||||
val loading: Boolean = true,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class QttrViewModel @Inject constructor(
|
||||
private val repository: MemberAreaRepository,
|
||||
private val loginRepository: LoginRepository,
|
||||
private val connectivityMonitor: ConnectivityMonitor,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(QttrUiState())
|
||||
val state: StateFlow<QttrUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
viewModelScope.launch {
|
||||
var wasOnline: Boolean? = null
|
||||
connectivityMonitor.online.collect { online ->
|
||||
if (online && wasOnline == false) {
|
||||
load()
|
||||
}
|
||||
wasOnline = online
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = null)
|
||||
val authStatus = loginRepository.status().getOrDefault(AuthStatusResponse())
|
||||
repository.qttrValues()
|
||||
.onSuccess { response ->
|
||||
_state.value = _state.value.copy(
|
||||
rows = response.rows,
|
||||
title = response.title,
|
||||
importedAt = response.importedAt,
|
||||
currentUserName = authStatus.user?.name.orEmpty(),
|
||||
loading = false,
|
||||
)
|
||||
}
|
||||
.onFailure {
|
||||
_state.value = _state.value.copy(loading = false, error = it.message ?: "QTTR-Werte konnten nicht geladen werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -27,8 +26,11 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.data.BirthdayDto
|
||||
import de.harheimertc.ui.components.LoadingState
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.ui.navigation.NavigationUiState
|
||||
import de.harheimertc.ui.theme.Accent100
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent700
|
||||
@@ -40,6 +42,7 @@ import de.harheimertc.ui.theme.Primary600
|
||||
fun MemberAreaScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
navigationState: NavigationUiState = NavigationUiState(),
|
||||
viewModel: MemberAreaViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
@@ -63,6 +66,12 @@ fun MemberAreaScreen(
|
||||
MemberAreaCardGrid(navController)
|
||||
}
|
||||
|
||||
if (navigationState.isAdmin) {
|
||||
item {
|
||||
ServerInfoCard()
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
BirthdayCard(
|
||||
birthdays = state.birthdays,
|
||||
@@ -74,6 +83,16 @@ fun MemberAreaScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerInfoCard() {
|
||||
Surface(color = Primary100, shape = RoundedCornerShape(14.dp), shadowElevation = 2.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Serververbindung", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
Text(BuildConfig.API_BASE_URL.trimEnd('/'), color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MemberAreaCardGrid(navController: NavController) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
@@ -83,6 +102,12 @@ private fun MemberAreaCardGrid(navController: NavController) {
|
||||
marker = "P",
|
||||
onClick = { navController.navigate(Destinations.Profile.route) },
|
||||
)
|
||||
MemberAreaCard(
|
||||
title = "Benachrichtigungen",
|
||||
description = "Persönliche Hinweise im Android-System verwalten",
|
||||
marker = "B",
|
||||
onClick = { navController.navigate(Destinations.NotificationSettings.route) },
|
||||
)
|
||||
MemberAreaCard(
|
||||
title = "Mitglieder",
|
||||
description = "Kontaktdaten der Vereinsmitglieder",
|
||||
@@ -95,6 +120,12 @@ private fun MemberAreaCardGrid(navController: NavController) {
|
||||
marker = "N",
|
||||
onClick = { navController.navigate(Destinations.MemberNews.route) },
|
||||
)
|
||||
MemberAreaCard(
|
||||
title = "QTTR",
|
||||
description = "Aktuelle QTTR-Werte der Vereinsmitglieder",
|
||||
marker = "Q",
|
||||
onClick = { navController.navigate(Destinations.Qttr.route) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,8 +183,7 @@ private fun BirthdayCard(
|
||||
|
||||
when {
|
||||
loading -> {
|
||||
CircularProgressIndicator(color = Primary600, modifier = Modifier.size(28.dp))
|
||||
Text("Lade...", color = Accent500)
|
||||
LoadingState("Geburtstage werden geladen...")
|
||||
}
|
||||
error != null -> {
|
||||
Text(error, color = MaterialTheme.colorScheme.error)
|
||||
|
||||
@@ -3,6 +3,7 @@ package de.harheimertc.ui.screens.memberarea
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.data.ConnectivityMonitor
|
||||
import de.harheimertc.data.BirthdayDto
|
||||
import de.harheimertc.repositories.MemberAreaRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -19,12 +20,22 @@ data class MemberAreaUiState(
|
||||
@HiltViewModel
|
||||
class MemberAreaViewModel @Inject constructor(
|
||||
private val repository: MemberAreaRepository,
|
||||
private val connectivityMonitor: ConnectivityMonitor,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(MemberAreaUiState())
|
||||
val state: StateFlow<MemberAreaUiState> = _state
|
||||
|
||||
init {
|
||||
loadBirthdays()
|
||||
viewModelScope.launch {
|
||||
var wasOnline: Boolean? = null
|
||||
connectivityMonitor.online.collect { online ->
|
||||
if (online && wasOnline == false) {
|
||||
loadBirthdays()
|
||||
}
|
||||
wasOnline = online
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadBirthdays() {
|
||||
|
||||
@@ -14,7 +14,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -32,6 +31,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.data.NewsletterGroupDto
|
||||
import de.harheimertc.ui.components.LoadingState
|
||||
import de.harheimertc.ui.components.ValidatedTextField
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.ui.theme.Accent100
|
||||
@@ -93,8 +93,7 @@ fun NewsletterConfirmScreen(
|
||||
NewsletterStatusPage(navController, showBackNavigation, "Newsletter bestätigen") {
|
||||
when {
|
||||
state.loading -> {
|
||||
CircularProgressIndicator(color = Primary600)
|
||||
Text("Newsletter-Anmeldung wird bestätigt...", color = Accent700)
|
||||
LoadingState("Newsletter-Anmeldung wird bestätigt...")
|
||||
}
|
||||
state.error != null -> {
|
||||
Text("Fehler", style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
@@ -154,7 +153,7 @@ private fun NewsletterFormScreen(
|
||||
Text("Wählen Sie einen Newsletter und geben Sie Ihre E-Mail-Adresse ein.", color = Accent500, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
if (state.loading) {
|
||||
item { CircularProgressIndicator(color = Primary600) }
|
||||
item { LoadingState("Newsletter-Daten werden geladen...") }
|
||||
} else {
|
||||
item {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package de.harheimertc.ui.screens.notifications
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.notifications.HarheimerNotifications
|
||||
import de.harheimertc.repositories.Mannschaft
|
||||
import de.harheimertc.repositories.NotificationPreferences
|
||||
import de.harheimertc.ui.components.LoadingState
|
||||
import de.harheimertc.ui.navigation.NavigationUiState
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent700
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
|
||||
private val notificationTimes = (6..22).flatMap { hour ->
|
||||
listOf("%02d:00".format(hour), "%02d:30".format(hour))
|
||||
}.dropLast(1)
|
||||
|
||||
@Composable
|
||||
fun NotificationSettingsScreen(
|
||||
navController: NavController,
|
||||
showBackNavigation: Boolean,
|
||||
navigationState: NavigationUiState,
|
||||
viewModel: NotificationSettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
var hasPermission by remember { mutableStateOf(HarheimerNotifications.hasNotificationPermission(context)) }
|
||||
val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
hasPermission = granted || HarheimerNotifications.hasNotificationPermission(context)
|
||||
}
|
||||
val isBoard = "vorstand" in navigationState.roles
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
item {
|
||||
if (showBackNavigation) {
|
||||
TextButton(onClick = { navController.popBackStack() }) {
|
||||
Text("< Intern", color = Primary600, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
Text("Benachrichtigungen", style = MaterialTheme.typography.displayLarge, color = Accent900)
|
||||
Text("Persönliche Android-Benachrichtigungen verwalten.", color = Accent500, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
|
||||
item {
|
||||
NotificationCard("Android-Berechtigung") {
|
||||
val permissionText = if (hasPermission) {
|
||||
"Benachrichtigungen sind im Android-System erlaubt."
|
||||
} else {
|
||||
"Benachrichtigungen sind im Android-System noch nicht erlaubt."
|
||||
}
|
||||
Text(permissionText, color = if (hasPermission) Color(0xFF166534) else Accent700)
|
||||
if (!hasPermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
Button(
|
||||
onClick = { permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Berechtigung anfordern")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.loading) {
|
||||
item { LoadingState("Benachrichtigungseinstellungen werden geladen...") }
|
||||
} else {
|
||||
item {
|
||||
NotificationCard("Benachrichtigungszeit") {
|
||||
Text("Automatische Benachrichtigungen werden zu dieser Uhrzeit ausgelöst.", color = Accent700)
|
||||
TimeSelection(state.settings.notificationTime) { selectedTime ->
|
||||
viewModel.update(state.settings.copy(notificationTime = selectedTime))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
NotificationCard("News") {
|
||||
ToggleRow("Neue News", state.settings.newNews) {
|
||||
viewModel.update(state.settings.copy(newNews = it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
NotificationCard("Termine") {
|
||||
ToggleRow("Neue Termine", state.settings.newEvents) {
|
||||
viewModel.update(state.settings.copy(newEvents = it))
|
||||
}
|
||||
ToggleRow("Termine von heute", state.settings.eventsToday) {
|
||||
viewModel.update(state.settings.copy(eventsToday = it))
|
||||
}
|
||||
ToggleRow("Termine von morgen", state.settings.eventsTomorrow) {
|
||||
viewModel.update(state.settings.copy(eventsTomorrow = it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
NotificationCard("Punktspiele") {
|
||||
ToggleRow("Punktspiele der eigenen Mannschaft", state.settings.ownTeamMatches) {
|
||||
viewModel.update(state.settings.copy(ownTeamMatches = it))
|
||||
}
|
||||
Text("Die eigene Mannschaft wird später aus den Mannschaftsdefinitionen der aktuellen Saison ermittelt.", color = Accent700)
|
||||
ToggleRow("Punktspiele aller Mannschaften", state.settings.allTeamMatches) {
|
||||
viewModel.update(state.settings.copy(allTeamMatches = it))
|
||||
}
|
||||
TeamSelection(
|
||||
teams = state.teams,
|
||||
seasons = state.seasons,
|
||||
selectedSeason = state.settings.selectedTeamSeason,
|
||||
settings = state.settings,
|
||||
onSelectSeason = viewModel::selectSeason,
|
||||
onToggleTeam = viewModel::toggleTeam,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
NotificationCard("Mitglieder") {
|
||||
ToggleRow("Geburtstage", state.settings.birthdays) {
|
||||
viewModel.update(state.settings.copy(birthdays = it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isBoard) {
|
||||
item {
|
||||
NotificationCard("Vorstand") {
|
||||
ToggleRow("Neue Kontaktanfrage", state.settings.newContactRequest) {
|
||||
viewModel.update(state.settings.copy(newContactRequest = it))
|
||||
}
|
||||
ToggleRow("Neue Benutzerregistrierung", state.settings.newUserRegistration) {
|
||||
viewModel.update(state.settings.copy(newUserRegistration = it))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.saveError?.let { message ->
|
||||
item { Text(message, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
|
||||
state.error?.let { message ->
|
||||
item {
|
||||
Text(message, color = MaterialTheme.colorScheme.error)
|
||||
TextButton(onClick = viewModel::load) { Text("Erneut laden") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationCard(title: String, content: @Composable ColumnScope.() -> Unit) {
|
||||
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 2.dp) {
|
||||
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToggleRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(label, color = Accent900, modifier = Modifier.weight(1f))
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimeSelection(selectedTime: String, onSelectTime: (String) -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
notificationTimes.forEach { time ->
|
||||
if (time == selectedTime) {
|
||||
Button(onClick = { onSelectTime(time) }) { Text(time) }
|
||||
} else {
|
||||
OutlinedButton(onClick = { onSelectTime(time) }) { Text(time) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TeamSelection(
|
||||
teams: List<Mannschaft>,
|
||||
seasons: List<String>,
|
||||
selectedSeason: String?,
|
||||
settings: NotificationPreferences,
|
||||
onSelectSeason: (String) -> Unit,
|
||||
onToggleTeam: (String, Boolean) -> Unit,
|
||||
) {
|
||||
Surface(color = Primary100, shape = RoundedCornerShape(10.dp)) {
|
||||
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Ausgewählte Mannschaften", color = Accent900, fontWeight = FontWeight.SemiBold)
|
||||
Text("Zusätzlich einzelne Mannschaften abonnieren.", color = Accent700)
|
||||
if (seasons.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
seasons.forEach { season ->
|
||||
val selected = season == selectedSeason
|
||||
if (selected) {
|
||||
Button(onClick = { onSelectSeason(season) }) { Text(season) }
|
||||
} else {
|
||||
OutlinedButton(onClick = { onSelectSeason(season) }) { Text(season) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (teams.isEmpty()) {
|
||||
Text("Keine Mannschaften verfügbar.", color = Accent700)
|
||||
} else {
|
||||
teams.forEach { team ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Checkbox(
|
||||
checked = team.slug in settings.selectedTeamSlugs,
|
||||
onCheckedChange = { onToggleTeam(team.slug, it) },
|
||||
)
|
||||
Text(team.mannschaft, color = Accent900)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package de.harheimertc.ui.screens.notifications
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.harheimertc.repositories.Mannschaft
|
||||
import de.harheimertc.repositories.MannschaftenRepository
|
||||
import de.harheimertc.repositories.NotificationPreferences
|
||||
import de.harheimertc.repositories.NotificationPreferencesRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
data class NotificationSettingsUiState(
|
||||
val loading: Boolean = true,
|
||||
val settings: NotificationPreferences = NotificationPreferences(),
|
||||
val teams: List<Mannschaft> = emptyList(),
|
||||
val seasons: List<String> = emptyList(),
|
||||
val error: String? = null,
|
||||
val saveError: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class NotificationSettingsViewModel @Inject constructor(
|
||||
private val preferencesRepository: NotificationPreferencesRepository,
|
||||
private val mannschaftenRepository: MannschaftenRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(NotificationSettingsUiState())
|
||||
val state: StateFlow<NotificationSettingsUiState> = _state
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = null, saveError = null)
|
||||
val storedSettings = preferencesRepository.loadRemote().getOrElse { preferencesRepository.loadLocal() }
|
||||
val seasonsResponse = mannschaftenRepository.fetchSeasons().getOrNull()
|
||||
val seasons = seasonsResponse?.seasons.orEmpty()
|
||||
val selectedSeason = storedSettings.selectedTeamSeason
|
||||
?: seasonsResponse?.defaultSeason?.takeIf { it.isNotBlank() }
|
||||
?: seasons.firstOrNull()
|
||||
loadTeams(storedSettings.copy(selectedTeamSeason = selectedSeason), seasons)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectSeason(season: String) {
|
||||
val current = _state.value.settings
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = null, saveError = null)
|
||||
loadTeams(current.copy(selectedTeamSeason = season), _state.value.seasons, syncRemote = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun update(settings: NotificationPreferences) {
|
||||
preferencesRepository.saveLocal(settings)
|
||||
_state.value = _state.value.copy(settings = settings, saveError = null)
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.saveRemote(settings)
|
||||
.onSuccess { saved -> _state.value = _state.value.copy(settings = saved, saveError = null) }
|
||||
.onFailure { error ->
|
||||
_state.value = _state.value.copy(saveError = error.message ?: "Benachrichtigungseinstellungen konnten nicht gespeichert werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleTeam(slug: String, selected: Boolean) {
|
||||
val current = _state.value.settings
|
||||
val nextTeams = if (selected) {
|
||||
current.selectedTeamSlugs + slug
|
||||
} else {
|
||||
current.selectedTeamSlugs - slug
|
||||
}
|
||||
update(current.copy(selectedTeamSlugs = nextTeams))
|
||||
}
|
||||
|
||||
private suspend fun loadTeams(settings: NotificationPreferences, seasons: List<String>, syncRemote: Boolean = false) {
|
||||
mannschaftenRepository.fetchMannschaften(settings.selectedTeamSeason)
|
||||
.onSuccess { teams ->
|
||||
val knownSlugs = teams.map { it.slug }.toSet()
|
||||
val nextSettings = settings.copy(selectedTeamSlugs = settings.selectedTeamSlugs.intersect(knownSlugs))
|
||||
preferencesRepository.saveLocal(nextSettings)
|
||||
val saveError = if (syncRemote) {
|
||||
preferencesRepository.saveRemote(nextSettings).exceptionOrNull()?.message
|
||||
} else null
|
||||
_state.value = NotificationSettingsUiState(
|
||||
loading = false,
|
||||
settings = nextSettings,
|
||||
teams = teams,
|
||||
seasons = seasons,
|
||||
saveError = saveError,
|
||||
)
|
||||
}
|
||||
.onFailure { error ->
|
||||
preferencesRepository.saveLocal(settings)
|
||||
val saveError = if (syncRemote) {
|
||||
preferencesRepository.saveRemote(settings).exceptionOrNull()?.message
|
||||
} else null
|
||||
_state.value = NotificationSettingsUiState(
|
||||
loading = false,
|
||||
settings = settings,
|
||||
seasons = seasons,
|
||||
error = error.message ?: "Mannschaften konnten nicht geladen werden.",
|
||||
saveError = saveError,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import androidx.navigation.NavController
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
import de.harheimertc.ui.components.ValidatedTextField
|
||||
import de.harheimertc.ui.components.LoadingState
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
|
||||
@Composable
|
||||
@@ -67,9 +68,7 @@ fun ProfileScreen(
|
||||
|
||||
if (state.loading) {
|
||||
item {
|
||||
Column(Modifier.fillMaxWidth().padding(28.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(color = Primary600)
|
||||
}
|
||||
LoadingState("Profil wird geladen...")
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
|
||||
@@ -15,7 +15,6 @@ import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -27,6 +26,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.BuildConfig
|
||||
import de.harheimertc.ui.components.LoadingState
|
||||
import de.harheimertc.ui.components.RichText
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
@@ -68,9 +68,7 @@ internal fun PublicCard(title: String? = null, content: @Composable () -> Unit)
|
||||
|
||||
@Composable
|
||||
internal fun PublicLoading() {
|
||||
Column(Modifier.fillMaxWidth().padding(28.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(color = Primary600)
|
||||
}
|
||||
LoadingState()
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -18,7 +18,6 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -43,6 +42,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.data.SeasonDto
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.ui.components.LoadingState
|
||||
import de.harheimertc.ui.theme.Accent100
|
||||
import de.harheimertc.ui.theme.Accent200
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
@@ -228,10 +228,7 @@ private fun MatchRow(game: SpielDto) {
|
||||
|
||||
@Composable
|
||||
private fun LoadingPlan() {
|
||||
Column(Modifier.fillMaxWidth().padding(40.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(color = Primary600)
|
||||
Text("Spielpläne werden geladen...", color = Accent500, modifier = Modifier.padding(top = 12.dp))
|
||||
}
|
||||
LoadingState("Spielpläne werden geladen...")
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -15,7 +15,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -32,6 +31,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.NavController
|
||||
import de.harheimertc.data.TerminDto
|
||||
import de.harheimertc.ui.components.LoadingState
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent700
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
@@ -125,10 +125,7 @@ private fun TerminCard(termin: TerminDto) {
|
||||
|
||||
@Composable
|
||||
private fun LoadingPanel() {
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 38.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(color = Primary600)
|
||||
Text("Termine werden geladen...", color = Accent500, modifier = Modifier.padding(top = 12.dp))
|
||||
}
|
||||
LoadingState("Termine werden geladen...")
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -13,7 +13,6 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Surface
|
||||
@@ -32,6 +31,7 @@ import androidx.navigation.NavController
|
||||
import de.harheimertc.data.TrainingTimeDto
|
||||
import de.harheimertc.data.TrainerDto
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
import de.harheimertc.ui.components.LoadingState
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
import de.harheimertc.ui.theme.Accent700
|
||||
import de.harheimertc.ui.theme.Accent900
|
||||
@@ -193,9 +193,7 @@ private fun TrainerCard(trainer: TrainerDto) {
|
||||
|
||||
@Composable
|
||||
private fun Loading() {
|
||||
Column(Modifier.fillMaxWidth().padding(30.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
CircularProgressIndicator(color = Primary600)
|
||||
}
|
||||
LoadingState("Trainingsdaten werden geladen...")
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -10,32 +10,91 @@ import de.harheimertc.R
|
||||
|
||||
// Bundled variable fonts in res/font:
|
||||
val InterFamily = FontFamily(Font(R.font.inter_variable))
|
||||
val MontserratFamily = FontFamily(Font(R.font.montserrat_variable, FontWeight.SemiBold))
|
||||
val MontserratFamily = FontFamily(
|
||||
Font(R.font.montserrat_variable, FontWeight.SemiBold),
|
||||
Font(R.font.montserrat_variable, FontWeight.Bold),
|
||||
Font(R.font.montserrat_variable, FontWeight.ExtraBold),
|
||||
)
|
||||
// Android headings: use system sans-serif for stronger strokes/readability on tablets.
|
||||
val HeaderFamily = FontFamily.SansSerif
|
||||
|
||||
val AppTypography = Typography(
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = MontserratFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 30.sp
|
||||
fontFamily = HeaderFamily,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSize = 32.sp,
|
||||
lineHeight = 38.sp
|
||||
),
|
||||
headlineLarge = TextStyle(
|
||||
fontFamily = HeaderFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 34.sp
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontFamily = HeaderFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 30.sp
|
||||
),
|
||||
headlineSmall = TextStyle(
|
||||
fontFamily = HeaderFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 26.sp
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = MontserratFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 20.sp
|
||||
fontFamily = HeaderFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = HeaderFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp,
|
||||
lineHeight = 24.sp
|
||||
),
|
||||
titleSmall = TextStyle(
|
||||
fontFamily = HeaderFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 22.sp
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = InterFamily,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 17.sp,
|
||||
lineHeight = 25.sp
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = InterFamily,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 22.sp
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontFamily = InterFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = InterFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 20.sp
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontFamily = InterFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 19.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = InterFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
)
|
||||
|
||||
12
android-app/app/src/release/AndroidManifest.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<application>
|
||||
<provider
|
||||
android:name="io.sentry.android.core.SentryInitProvider"
|
||||
tools:node="remove" />
|
||||
<provider
|
||||
android:name="io.sentry.android.core.SentryPerformanceProvider"
|
||||
tools:node="remove" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -6,6 +6,7 @@ plugins {
|
||||
id("com.google.devtools.ksp") version "2.3.7" apply false
|
||||
id("org.jetbrains.kotlin.kapt") version "2.3.21" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.3.21" apply false
|
||||
id("com.google.gms.google-services") version "4.4.4" apply false
|
||||
}
|
||||
|
||||
buildscript {
|
||||
|
||||
6
android-app/gradle-local.properties.example
Normal file
@@ -0,0 +1,6 @@
|
||||
# Copy this file to android-app/gradle-local.properties (ignored by git)
|
||||
# and fill in your release signing credentials.
|
||||
RELEASE_STORE_FILE=/home/torsten/android\ keystore/harheimertc.jks
|
||||
RELEASE_STORE_PASSWORD=
|
||||
RELEASE_KEY_ALIAS=
|
||||
RELEASE_KEY_PASSWORD=
|
||||
@@ -8,14 +8,15 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
|
||||
PRODUCTION_API_BASE_URL=https://harheimertc.de/
|
||||
|
||||
# Android app versioning for Play Store uploads
|
||||
ANDROID_VERSION_CODE=4
|
||||
ANDROID_VERSION_NAME=1.0.0
|
||||
ANDROID_VERSION_CODE=25
|
||||
ANDROID_VERSION_NAME=0.9.20
|
||||
|
||||
# Enable R8 for release by default so mapping.txt is generated for Play Console.
|
||||
RELEASE_MINIFY_ENABLED=true
|
||||
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
|
||||
RELEASE_MINIFY_ENABLED=false
|
||||
|
||||
# Release signing (set in local, untracked gradle.properties or via CI secrets)
|
||||
# RELEASE_STORE_FILE=/absolute/path/to/keystore.jks
|
||||
RELEASE_STORE_FILE=/home/torsten/android\ keystore/harheimertc.jks
|
||||
# Keep secrets out of git. Use ~/.gradle/gradle.properties or environment variables.
|
||||
# RELEASE_STORE_PASSWORD=***
|
||||
# RELEASE_KEY_ALIAS=***
|
||||
# RELEASE_KEY_PASSWORD=***
|
||||
|
||||
BIN
android-app/playstore-assets/anon/Screenshot_20260530_000103.png
Normal file
|
After Width: | Height: | Size: 814 KiB |
BIN
android-app/playstore-assets/anon/Screenshot_20260530_000133.png
Normal file
|
After Width: | Height: | Size: 331 KiB |
BIN
android-app/playstore-assets/anon/Screenshot_20260530_000230.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
android-app/playstore-assets/anon/Screenshot_20260530_000301.png
Normal file
|
After Width: | Height: | Size: 286 KiB |
BIN
android-app/playstore-assets/anon/Screenshot_20260530_000429.png
Normal file
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 863 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 859 KiB |
|
After Width: | Height: | Size: 737 KiB |
|
After Width: | Height: | Size: 368 KiB |
|
After Width: | Height: | Size: 977 KiB |
|
After Width: | Height: | Size: 845 KiB |
BIN
android-app/playstore-assets/raw/Screenshot_20260530_000103.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
android-app/playstore-assets/raw/Screenshot_20260530_000301.png
Normal file
|
After Width: | Height: | Size: 391 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 958 KiB |
|
After Width: | Height: | Size: 128 KiB |
@@ -32,7 +32,7 @@
|
||||
Header always set Content-Security-Policy "frame-ancestors 'self' https://harheimertc.de https://www.harheimertc.de"
|
||||
|
||||
# Optional: Vollständige Content Security Policy (zusätzlich zu frame-ancestors)
|
||||
# Header always set Content-Security-Policy-Report-Only "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self' https://harheimertc.de https://www.harheimertc.de; font-src 'self' https://fonts.gstatic.com data:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self'; img-src 'self' data: blob:; connect-src 'self'"
|
||||
# Header always set Content-Security-Policy-Report-Only "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self' https://harheimertc.de https://www.harheimertc.de; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data: blob:; connect-src 'self'"
|
||||
|
||||
# Proxy alle Anfragen an Nuxt Server (Port 3100)
|
||||
ProxyPreserveHost On
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
Header always set Content-Security-Policy "frame-ancestors 'self' https://harheimertc.de https://www.harheimertc.de"
|
||||
|
||||
# Optional: Vollständige Content Security Policy (zusätzlich zu frame-ancestors)
|
||||
# Header always set Content-Security-Policy-Report-Only "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self' https://harheimertc.de https://www.harheimertc.de; font-src 'self' https://fonts.gstatic.com data:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self'; img-src 'self' data: blob:; connect-src 'self'"
|
||||
# Header always set Content-Security-Policy-Report-Only "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self' https://harheimertc.de https://www.harheimertc.de; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data: blob:; connect-src 'self'"
|
||||
|
||||
# SPA Fallback für Nuxt.js
|
||||
<Directory "/var/www/harheimertc/dist">
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'Montserrat', system-ui, sans-serif;
|
||||
font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
20
assets/images/hero-originals/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Hero-Originalbilder
|
||||
|
||||
Das Tool `npm run hero:prepare` unterstuetzt zwei Eingabeformate:
|
||||
|
||||
1. Unterordner pro Variante (empfohlen)
|
||||
|
||||
- `assets/images/hero-originals/<variante>/hero.png`
|
||||
|
||||
2. Flache Ablage von PNGs
|
||||
|
||||
- `public/images/hero-originals/hero1.png`
|
||||
- `public/images/hero-originals/hero2.png`
|
||||
|
||||
Ausgabe je Variante in:
|
||||
|
||||
- `public/images/hero/<variante>/hero_960.webp`
|
||||
- `public/images/hero/<variante>/hero_1600.webp`
|
||||
- `public/images/hero/<variante>/hero_fallback.png`
|
||||
|
||||
Die Startseite (`components/Hero.vue`) waehlt danach automatisch zufaellig eine vorhandene Variante aus.
|
||||
@@ -1,21 +1,35 @@
|
||||
<template>
|
||||
<section
|
||||
id="home"
|
||||
class="relative min-h-full flex items-center justify-center overflow-hidden bg-gradient-to-br from-gray-50 to-gray-100"
|
||||
class="hero-shell relative overflow-hidden bg-gradient-to-br from-gray-50 to-gray-100"
|
||||
>
|
||||
<!-- Decorative Elements -->
|
||||
<div class="absolute inset-0 z-0">
|
||||
<div class="absolute top-0 right-0 w-96 h-96 bg-primary-200/30 rounded-full blur-3xl" />
|
||||
<div class="absolute bottom-0 left-0 w-96 h-96 bg-gray-300/30 rounded-full blur-3xl" />
|
||||
<!-- Hintergrundbild -->
|
||||
<div
|
||||
class="absolute inset-0 opacity-10"
|
||||
style="background-image: url('/images/club_about_us.png'); background-size: cover; background-position: center;"
|
||||
/>
|
||||
<picture class="absolute inset-0 opacity-15">
|
||||
<source
|
||||
v-if="heroImage.mobileWebp && heroImage.desktopWebp"
|
||||
type="image/webp"
|
||||
:srcset="`${heroImage.mobileWebp} 960w, ${heroImage.desktopWebp} 1600w`"
|
||||
sizes="(max-width: 1024px) 960px, 1600px"
|
||||
>
|
||||
<img
|
||||
:src="heroImage.fallback"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="w-full h-full object-cover object-[center_36%]"
|
||||
width="1600"
|
||||
height="900"
|
||||
loading="eager"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
>
|
||||
</picture>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="relative z-20 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 sm:py-8">
|
||||
<div class="relative z-20 max-w-7xl mx-auto">
|
||||
<div class="text-center">
|
||||
<h1 class="text-5xl sm:text-6xl lg:text-7xl font-display font-bold text-gray-900 mb-6 leading-tight animate-fade-in">
|
||||
Willkommen beim<br>
|
||||
@@ -31,11 +45,101 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useFetch, useHead, useState } from '#imports'
|
||||
|
||||
function buildInlineFallback() {
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 900" preserveAspectRatio="xMidYMid slice">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#eef2f7" />
|
||||
<stop offset="100%" stop-color="#d8e0ea" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1600" height="900" fill="url(#g)" />
|
||||
</svg>`
|
||||
|
||||
return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`
|
||||
}
|
||||
|
||||
const DEFAULT_HERO_IMAGE = {
|
||||
key: 'fallback',
|
||||
mobileWebp: '',
|
||||
desktopWebp: '',
|
||||
fallback: buildInlineFallback()
|
||||
}
|
||||
|
||||
const { data: heroImagesResponse } = await useFetch('/api/hero-images')
|
||||
|
||||
const heroVariants = computed(() => {
|
||||
const variants = heroImagesResponse.value?.variants
|
||||
return Array.isArray(variants) && variants.length ? variants : [DEFAULT_HERO_IMAGE]
|
||||
})
|
||||
|
||||
function pickRandomHeroImage(variants) {
|
||||
const list = Array.isArray(variants) && variants.length ? variants : [DEFAULT_HERO_IMAGE]
|
||||
const index = Math.floor(Math.random() * list.length)
|
||||
return list[index]
|
||||
}
|
||||
|
||||
const heroImageState = useState('home-hero-image', () => pickRandomHeroImage(heroVariants.value))
|
||||
if (!heroVariants.value.some((variant) => variant.key === heroImageState.value?.key)) {
|
||||
heroImageState.value = pickRandomHeroImage(heroVariants.value)
|
||||
}
|
||||
|
||||
const heroImage = computed(() => heroImageState.value)
|
||||
|
||||
const preloadLinks = computed(() => {
|
||||
const links = []
|
||||
if (heroImage.value.mobileWebp) {
|
||||
links.push({
|
||||
rel: 'preload',
|
||||
as: 'image',
|
||||
href: heroImage.value.mobileWebp,
|
||||
type: 'image/webp',
|
||||
media: '(max-width: 1024px)'
|
||||
})
|
||||
}
|
||||
|
||||
if (heroImage.value.desktopWebp) {
|
||||
links.push({
|
||||
rel: 'preload',
|
||||
as: 'image',
|
||||
href: heroImage.value.desktopWebp,
|
||||
type: 'image/webp',
|
||||
media: '(min-width: 1025px)'
|
||||
})
|
||||
}
|
||||
|
||||
return links
|
||||
})
|
||||
|
||||
useHead(() => ({
|
||||
link: preloadLinks.value
|
||||
}))
|
||||
|
||||
const foundingYear = 1954
|
||||
const yearsSinceFounding = new Date().getFullYear() - foundingYear
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hero-shell {
|
||||
min-height: 430px;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.hero-shell {
|
||||
min-height: 540px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-aspect-ratio: 21/9) {
|
||||
.hero-shell {
|
||||
min-height: 640px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -274,6 +274,13 @@
|
||||
>
|
||||
Mitgliederliste
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/mitgliederbereich/qttr"
|
||||
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
|
||||
active-class="text-white bg-primary-600"
|
||||
>
|
||||
QTTR
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/mitgliederbereich/news"
|
||||
class="px-2.5 py-1 text-xs text-gray-300 hover:text-white hover:bg-primary-700/50 rounded transition-all"
|
||||
@@ -714,6 +721,13 @@
|
||||
>
|
||||
Mitgliederliste
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/mitgliederbereich/qttr"
|
||||
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
||||
@click="isMobileMenuOpen = false"
|
||||
>
|
||||
QTTR
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/mitgliederbereich/news"
|
||||
class="block px-4 py-2 text-sm text-gray-400 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
||||
@@ -814,7 +828,7 @@
|
||||
Einstellungen
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="getAuthStore()?.hasAnyRole('admin', 'vorstand')"
|
||||
v-if="canManageUsers"
|
||||
to="/cms/benutzer"
|
||||
class="block px-4 py-2 text-sm text-yellow-300 hover:text-white hover:bg-primary-700/50 rounded-lg transition-colors"
|
||||
@click="isMobileMenuOpen = false"
|
||||
@@ -849,34 +863,13 @@ const mobileSubmenu = ref(null)
|
||||
const mannschaften = ref([])
|
||||
const hasGalleryImages = ref(false)
|
||||
const showCmsDropdown = ref(false)
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Lazy store access to avoid Pinia initialization issues
|
||||
const getAuthStore = () => {
|
||||
try {
|
||||
return useAuthStore()
|
||||
} catch (e) {
|
||||
// Fallback if Pinia is not yet initialized
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Reactive auth state from store (lazy)
|
||||
const isLoggedIn = computed(() => {
|
||||
const store = getAuthStore()
|
||||
return store?.isLoggedIn ?? false
|
||||
})
|
||||
const isAdmin = computed(() => {
|
||||
const store = getAuthStore()
|
||||
return store?.isAdmin ?? false
|
||||
})
|
||||
const canAccessNewsletter = computed(() => {
|
||||
const store = getAuthStore()
|
||||
return store?.hasAnyRole('admin', 'vorstand', 'newsletter') ?? false
|
||||
})
|
||||
const canAccessContactRequests = computed(() => {
|
||||
const store = getAuthStore()
|
||||
return store?.hasAnyRole('admin', 'vorstand', 'trainer') ?? false
|
||||
})
|
||||
const isLoggedIn = computed(() => authStore.isLoggedIn)
|
||||
const isAdmin = computed(() => authStore.isAdmin)
|
||||
const canAccessNewsletter = computed(() => authStore.hasAnyRole('admin', 'vorstand', 'newsletter'))
|
||||
const canAccessContactRequests = computed(() => authStore.hasAnyRole('admin', 'vorstand', 'trainer'))
|
||||
const canManageUsers = computed(() => authStore.hasAnyRole('admin', 'vorstand'))
|
||||
|
||||
// Automatisches Setzen des Submenus basierend auf der Route
|
||||
const currentSubmenu = computed(() => {
|
||||
@@ -982,10 +975,7 @@ const handleDocumentClick = (e) => {
|
||||
onMounted(() => {
|
||||
loadMannschaften()
|
||||
checkGalleryImages()
|
||||
const store = getAuthStore()
|
||||
if (store) {
|
||||
store.checkAuth()
|
||||
}
|
||||
authStore.checkAuth()
|
||||
|
||||
// Close CMS dropdown when clicking outside
|
||||
document.addEventListener('click', handleDocumentClick)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<section
|
||||
v-if="news.length > 0"
|
||||
class="py-16 sm:py-20 bg-white"
|
||||
class="py-16 sm:py-20 bg-white min-h-[32rem]"
|
||||
>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-16">
|
||||
@@ -14,7 +13,29 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="grid gap-8 grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
<div
|
||||
v-for="placeholder in 3"
|
||||
:key="`news-placeholder-${placeholder}`"
|
||||
class="bg-gray-50 rounded-xl p-6 border border-gray-200"
|
||||
>
|
||||
<div class="h-4 w-32 bg-gray-200 rounded animate-pulse mb-4" />
|
||||
<div class="h-7 w-3/4 bg-gray-200 rounded animate-pulse mb-4" />
|
||||
<div class="space-y-2">
|
||||
<div class="h-4 w-full bg-gray-200 rounded animate-pulse" />
|
||||
<div class="h-4 w-5/6 bg-gray-200 rounded animate-pulse" />
|
||||
<div class="h-4 w-2/3 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="news.length > 0"
|
||||
class="flex justify-center"
|
||||
>
|
||||
<div
|
||||
class="grid gap-8"
|
||||
:class="getGridClass()"
|
||||
@@ -43,61 +64,85 @@
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- News Modal -->
|
||||
<div
|
||||
v-if="selectedNews"
|
||||
class="fixed inset-0 z-50 bg-black bg-opacity-50 flex items-center justify-center p-4"
|
||||
@click.self="closeNewsModal"
|
||||
>
|
||||
<div class="bg-white rounded-xl shadow-2xl max-w-4xl w-full max-h-[90vh] flex flex-col">
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center text-sm text-gray-500 mb-2">
|
||||
<Calendar
|
||||
:size="16"
|
||||
class="mr-2"
|
||||
/>
|
||||
{{ formatDate(selectedNews.created) }}
|
||||
</div>
|
||||
<h2 class="text-2xl font-display font-bold text-gray-900">
|
||||
{{ selectedNews.title }}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
class="ml-4 p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
@click="closeNewsModal"
|
||||
>
|
||||
<X :size="24" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Content (scrollable) -->
|
||||
<div class="p-6 overflow-y-auto flex-1">
|
||||
<div class="prose max-w-none text-gray-700 whitespace-pre-wrap">
|
||||
{{ selectedNews.content }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="max-w-xl mx-auto text-center bg-gray-50 border border-gray-200 rounded-xl p-8"
|
||||
>
|
||||
<p class="text-gray-700 font-semibold mb-2">
|
||||
Aktuell keine News
|
||||
</p>
|
||||
<p class="text-gray-600 text-sm">
|
||||
Neue Vereinsnachrichten erscheinen hier automatisch.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="news-modal">
|
||||
<div
|
||||
v-if="selectedNews"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950/65 px-4 py-6 sm:px-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="modalTitleId"
|
||||
@click.self="closeNewsModal"
|
||||
>
|
||||
<article class="w-full max-w-3xl max-h-[min(44rem,calc(100vh-3rem))] overflow-hidden rounded-lg bg-white shadow-2xl ring-1 ring-black/10 flex flex-col">
|
||||
<header class="flex items-start gap-4 border-b border-gray-200 bg-white px-5 py-4 sm:px-7 sm:py-6">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-3 inline-flex items-center gap-2 rounded-full bg-primary-50 px-3 py-1 text-sm font-medium text-primary-800">
|
||||
<Calendar :size="15" />
|
||||
<time :datetime="selectedNews.created">
|
||||
{{ formatDate(selectedNews.created) }}
|
||||
</time>
|
||||
</div>
|
||||
<h2
|
||||
:id="modalTitleId"
|
||||
class="text-2xl sm:text-3xl font-display font-bold leading-tight text-gray-950 break-words"
|
||||
>
|
||||
{{ selectedNews.title }}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-md border border-gray-200 bg-white p-2 text-gray-500 shadow-sm transition-colors hover:bg-gray-50 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
aria-label="News schließen"
|
||||
@click="closeNewsModal"
|
||||
>
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="overflow-y-auto px-5 py-5 sm:px-7 sm:py-6">
|
||||
<div class="news-modal-content text-base leading-7 text-gray-800 sm:text-lg sm:leading-8">
|
||||
{{ selectedNews.content }}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { Calendar, X } from 'lucide-vue-next'
|
||||
|
||||
const news = ref([])
|
||||
const selectedNews = ref(null)
|
||||
const isLoading = ref(true)
|
||||
const modalTitleId = 'public-news-modal-title'
|
||||
|
||||
const loadNews = async () => {
|
||||
try {
|
||||
const response = await $fetch('/api/news-public')
|
||||
news.value = response.news
|
||||
news.value = Array.isArray(response?.news) ? response.news : []
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der öffentlichen News:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,19 +173,30 @@ const getGridClass = () => {
|
||||
|
||||
const openNewsModal = (item) => {
|
||||
selectedNews.value = item
|
||||
// Verhindere Scrollen im Hintergrund
|
||||
document.body.style.overflow = 'hidden'
|
||||
document.addEventListener('keydown', handleModalKeydown)
|
||||
}
|
||||
|
||||
const closeNewsModal = () => {
|
||||
selectedNews.value = null
|
||||
// Erlaube Scrollen wieder
|
||||
document.body.style.overflow = ''
|
||||
document.removeEventListener('keydown', handleModalKeydown)
|
||||
}
|
||||
|
||||
const handleModalKeydown = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeNewsModal()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadNews()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.body.style.overflow = ''
|
||||
document.removeEventListener('keydown', handleModalKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -150,5 +206,20 @@ onMounted(() => {
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.news-modal-content {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.news-modal-enter-active,
|
||||
.news-modal-leave-active {
|
||||
transition: opacity 160ms ease;
|
||||
}
|
||||
|
||||
.news-modal-enter-from,
|
||||
.news-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -57,7 +57,10 @@ has_tracked_files_under() {
|
||||
install_dependencies() {
|
||||
if [ -f "package-lock.json" ]; then
|
||||
echo " Running: npm ci"
|
||||
npm ci
|
||||
if ! npm ci; then
|
||||
echo " WARNING: npm ci fehlgeschlagen (Lockfile ggf. nicht synchron). Fallback auf npm install..."
|
||||
npm install
|
||||
fi
|
||||
else
|
||||
echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
|
||||
npm install
|
||||
@@ -92,6 +95,10 @@ install_dependencies_if_needed() {
|
||||
echo " package-lock.json unverändert, überspringe npm ci"
|
||||
fi
|
||||
|
||||
if [ -f "package-lock.json" ]; then
|
||||
current_lock_hash="$(sha256sum package-lock.json | awk '{print $1}')"
|
||||
fi
|
||||
|
||||
printf '%s\n' "$current_lock_hash" > "$lock_hash_file"
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,10 @@ has_tracked_files_under() {
|
||||
install_dependencies() {
|
||||
if [ -f "package-lock.json" ]; then
|
||||
echo " Running: npm ci"
|
||||
npm ci
|
||||
if ! npm ci; then
|
||||
echo " WARNING: npm ci fehlgeschlagen (Lockfile ggf. nicht synchron). Fallback auf npm install..."
|
||||
npm install
|
||||
fi
|
||||
else
|
||||
echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
|
||||
npm install
|
||||
@@ -105,6 +108,10 @@ install_dependencies_if_needed() {
|
||||
echo " package-lock.json unverändert, überspringe npm ci"
|
||||
fi
|
||||
|
||||
if [ -f "package-lock.json" ]; then
|
||||
current_lock_hash="$(sha256sum package-lock.json | awk '{print $1}')"
|
||||
fi
|
||||
|
||||
printf '%s\n' "$current_lock_hash" > "$lock_hash_file"
|
||||
}
|
||||
|
||||
|
||||
@@ -76,13 +76,7 @@ export default defineNuxtConfig({
|
||||
{ property: 'twitter:description', content: 'Offizielle Website des Harheimer Tischtennis-Club 1954 e.V.' }
|
||||
],
|
||||
link: [
|
||||
{ rel: 'canonical', href: 'https://www.harheimertc.de/' },
|
||||
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
||||
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
|
||||
{
|
||||
rel: 'stylesheet',
|
||||
href: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Montserrat:wght@700;800;900&display=swap'
|
||||
}
|
||||
{ rel: 'canonical', href: 'https://www.harheimertc.de/' }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "harheimertc-website",
|
||||
"version": "1.6.2",
|
||||
"version": "1.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "harheimertc-website",
|
||||
"version": "1.6.2",
|
||||
"version": "1.7.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@pinia/nuxt": "^0.11.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "harheimertc-website",
|
||||
"version": "1.7.0",
|
||||
"version": "1.8.1",
|
||||
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -16,13 +16,18 @@
|
||||
"start": "nuxt start --port 3100",
|
||||
"postinstall": "nuxt prepare",
|
||||
"test": "vitest run",
|
||||
"test:data-rotation": "vitest run tests/data-file-rotation.spec.ts",
|
||||
"check-security": "node scripts/verify-no-public-writes.js",
|
||||
"smoke-local": "BASE_URL=http://127.0.0.1:3100 node scripts/smoke-tests.js",
|
||||
"sync-public-data": "node scripts/sync-public-data.js",
|
||||
"data-backups:list": "node scripts/data-backup-restore.js list",
|
||||
"data-backups:restore": "node scripts/data-backup-restore.js restore",
|
||||
"hero:prepare": "node scripts/prepare-hero-variants.mjs",
|
||||
"import-spielplan": "node scripts/import-spielplan.js",
|
||||
"publish-spielplan": "node scripts/publish-imported-spielplan.js",
|
||||
"playstore:assets": "./scripts/playstore-assets.sh",
|
||||
"playstore:anonymize": "./scripts/anonymize-playstore-screenshot.sh",
|
||||
"playstore:screenshots": "./scripts/playstore-screenshot-sizes.sh",
|
||||
"test:watch": "vitest watch",
|
||||
"lint": "eslint . --fix"
|
||||
},
|
||||
|
||||
@@ -207,8 +207,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="heroSection">
|
||||
<component :is="getComponentForSection(heroSection.id)" />
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="featuredWidgetSection"
|
||||
class="relative z-30 px-4 sm:px-6 lg:px-8"
|
||||
:class="hasHeroSection ? '-mt-44 sm:-mt-48 lg:-mt-52' : 'mt-8'"
|
||||
>
|
||||
<div class="featured-widget-shell max-w-6xl mx-auto rounded-2xl border border-gray-200 bg-white/25 backdrop-blur-md overflow-hidden min-h-[22rem] sm:min-h-[25rem]">
|
||||
<HomeSpielplanTeamWidget
|
||||
v-if="featuredWidgetSection.id === 'spielplan_team'"
|
||||
:season="featuredWidgetSection.config?.season"
|
||||
:team-name="featuredWidgetSection.config?.teamName"
|
||||
:team-age-group="featuredWidgetSection.config?.teamAgeGroup"
|
||||
/>
|
||||
<component
|
||||
:is="getComponentForSection(featuredWidgetSection.id)"
|
||||
v-else
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template
|
||||
v-for="section in enabledSections"
|
||||
v-for="section in remainingWidgetSections"
|
||||
:key="section.key"
|
||||
>
|
||||
<HomeSpielplanTeamWidget
|
||||
@@ -226,17 +249,39 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, defineAsyncComponent, h, ref } from 'vue'
|
||||
import { SlidersHorizontal, X } from 'lucide-vue-next'
|
||||
import Hero from '~/components/Hero.vue'
|
||||
import HomeTermine from '~/components/HomeTermine.vue'
|
||||
import Spielplan from '~/components/Spielplan.vue'
|
||||
import PublicNews from '~/components/PublicNews.vue'
|
||||
import HomeActions from '~/components/HomeActions.vue'
|
||||
import HomeTrainingTeaser from '~/components/HomeTrainingTeaser.vue'
|
||||
import HomeLinksTeaser from '~/components/HomeLinksTeaser.vue'
|
||||
import HomeVereinsmeisterschaftenTeaser from '~/components/HomeVereinsmeisterschaftenTeaser.vue'
|
||||
import HomeSpielplanTeamWidget from '~/components/HomeSpielplanTeamWidget.vue'
|
||||
|
||||
const SectionLoadingPlaceholder = {
|
||||
name: 'SectionLoadingPlaceholder',
|
||||
render() {
|
||||
return h('section', { class: 'py-16 sm:py-20 bg-white' }, [
|
||||
h('div', { class: 'max-w-7xl mx-auto px-4 sm:px-6 lg:px-8' }, [
|
||||
h('div', { class: 'h-10 max-w-xs mx-auto rounded bg-gray-200 animate-pulse mb-8' }),
|
||||
h('div', { class: 'h-56 rounded-2xl bg-gray-100 animate-pulse' })
|
||||
])
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
function loadHomeSection(loader) {
|
||||
return defineAsyncComponent({
|
||||
loader,
|
||||
loadingComponent: SectionLoadingPlaceholder,
|
||||
delay: 0,
|
||||
suspensible: false
|
||||
})
|
||||
}
|
||||
|
||||
const HomeTermine = loadHomeSection(() => import('~/components/HomeTermine.vue'))
|
||||
const Spielplan = loadHomeSection(() => import('~/components/Spielplan.vue'))
|
||||
const PublicNews = loadHomeSection(() => import('~/components/PublicNews.vue'))
|
||||
const HomeActions = loadHomeSection(() => import('~/components/HomeActions.vue'))
|
||||
const HomeTrainingTeaser = loadHomeSection(() => import('~/components/HomeTrainingTeaser.vue'))
|
||||
const HomeLinksTeaser = loadHomeSection(() => import('~/components/HomeLinksTeaser.vue'))
|
||||
const HomeVereinsmeisterschaftenTeaser = loadHomeSection(() => import('~/components/HomeVereinsmeisterschaftenTeaser.vue'))
|
||||
const HomeSpielplanTeamWidget = loadHomeSection(() => import('~/components/HomeSpielplanTeamWidget.vue'))
|
||||
|
||||
const { data: config } = await useFetch('/api/config')
|
||||
const { data: authStatus } = await useFetch('/api/auth/status')
|
||||
@@ -344,6 +389,11 @@ function applyPersonalization(baseSections, settingsSections) {
|
||||
|
||||
const resolvedSections = computed(() => applyPersonalization([...sections.value], personalizedSections.value))
|
||||
const enabledSections = computed(() => resolvedSections.value.filter(section => section.enabled !== false))
|
||||
const heroSection = computed(() => enabledSections.value.find(section => section.id === 'banner') || null)
|
||||
const hasHeroSection = computed(() => !!heroSection.value)
|
||||
const widgetSections = computed(() => enabledSections.value.filter(section => section.id !== 'banner'))
|
||||
const featuredWidgetSection = computed(() => widgetSections.value[0] || null)
|
||||
const remainingWidgetSections = computed(() => widgetSections.value.slice(1))
|
||||
|
||||
const componentMap = {
|
||||
banner: Hero,
|
||||
@@ -560,3 +610,26 @@ async function saveEditor() {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.featured-widget-shell :deep(section),
|
||||
.featured-widget-shell :deep(.bg-white),
|
||||
.featured-widget-shell :deep(.bg-gray-50) {
|
||||
--tw-bg-opacity: 0 !important;
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.featured-widget-shell :deep([class*='bg-white']),
|
||||
.featured-widget-shell :deep([class*='bg-gray-50']) {
|
||||
--tw-bg-opacity: 0 !important;
|
||||
background: transparent !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.featured-widget-shell :deep(.py-16),
|
||||
.featured-widget-shell :deep(.sm\:py-20) {
|
||||
padding-top: 1.5rem !important;
|
||||
padding-bottom: 2rem !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
176
pages/mitgliederbereich/qttr.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<div class="min-h-full py-16 bg-gray-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 space-y-8">
|
||||
<div>
|
||||
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
|
||||
QTTR-Werte
|
||||
</h1>
|
||||
<div class="w-24 h-1 bg-primary-600 mb-6" />
|
||||
<p class="text-lg text-gray-700 max-w-3xl">
|
||||
Aus technischen Gründen sind nur die QTTR-Werte verfügbar. Für TTR bitte auf
|
||||
<a
|
||||
:href="externalUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-800 underline"
|
||||
>myTischtennis</a>
|
||||
wechseln.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-lg border border-gray-100 p-6">
|
||||
<div class="flex flex-wrap items-center gap-4 justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-gray-900">
|
||||
Harheimer TC Rangliste
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600">
|
||||
{{ data?.title || 'Andro-Rangliste' }} · {{ data?.rowCount || 0 }} Einträge
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
Aktualisiert: {{ formatDate(data?.importedAt) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="pending" class="py-12 text-center text-gray-500">
|
||||
Lade QTTR-Werte...
|
||||
</div>
|
||||
<div v-else-if="error" class="rounded-lg border border-red-200 bg-red-50 p-4 text-red-800">
|
||||
{{ error.statusMessage || error.message || 'QTTR-Werte konnten nicht geladen werden.' }}
|
||||
</div>
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Rang
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Spieler
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
Verein
|
||||
</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||
QTTR
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 bg-white">
|
||||
<tr
|
||||
v-for="row in data?.rows || []"
|
||||
:key="`${row.rank}-${row.playerNumber || row.playerName}`"
|
||||
:class="isOwnRow(row.playerName) ? 'bg-primary-100' : ''"
|
||||
>
|
||||
<td class="px-4 py-3 text-sm text-gray-600">
|
||||
{{ row.rank ?? '–' }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div :class="['font-medium', getPlayerNameClass(row)]">
|
||||
{{ row.playerName || 'Unbekannt' }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700">
|
||||
{{ row.clubName || 'Harheimer TC' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right text-lg font-semibold text-gray-900 tabular-nums">
|
||||
{{ row.currentQttr ?? '–' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const externalUrl = 'https://www.mytischtennis.de/rankings/andro-rangliste?continent=all&country=Deutschland&all-players=on&as=DE.WE.R4.07&di=DE.WE.R4.07.04&area=DE.WE.R4.07.04.43&clubnr-search=Harheimer+TC&clubnr=43030&fednickname=HeTTV&gender=all¤t-ranking=yes&ttr-range=100%3B3000&birth-range=1926%3B2021'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
layout: 'default'
|
||||
})
|
||||
|
||||
await authStore.checkAuth()
|
||||
|
||||
const { data, pending, error } = await useFetch('/api/mitgliederbereich/qttr')
|
||||
|
||||
const currentUserName = computed(() => authStore.user?.name?.trim() || '')
|
||||
|
||||
function normalizeName(value) {
|
||||
return String(value || '').trim().toLowerCase().replace(/\s+/g, ' ')
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[’'`]/g, '')
|
||||
}
|
||||
|
||||
function isMaleGender(value) {
|
||||
const gender = normalizeName(value)
|
||||
return gender.startsWith('m') || gender.includes('mann') || gender.includes('maenn')
|
||||
}
|
||||
|
||||
function isFemaleGender(value) {
|
||||
const gender = normalizeName(value)
|
||||
return gender.startsWith('w') || gender.includes('weib') || gender.includes('frau')
|
||||
}
|
||||
|
||||
function isOwnRow(playerName) {
|
||||
const current = normalizeName(currentUserName.value)
|
||||
if (!current) return false
|
||||
return normalizeName(playerName) === current
|
||||
}
|
||||
|
||||
function getPlayerNameClass(row) {
|
||||
const minor = isMinor(row.birthdate)
|
||||
if (minor && isMaleGender(row.gender)) return 'text-blue-400'
|
||||
if (minor && isFemaleGender(row.gender)) return 'text-pink-400'
|
||||
if (isMaleGender(row.gender)) return 'text-blue-700'
|
||||
if (isFemaleGender(row.gender)) return 'text-pink-800'
|
||||
return 'text-gray-900'
|
||||
}
|
||||
|
||||
function isMinor(birthdate) {
|
||||
const date = parseBirthdate(birthdate)
|
||||
if (!date) return false
|
||||
const today = new Date()
|
||||
let age = today.getFullYear() - date.getFullYear()
|
||||
const monthDiff = today.getMonth() - date.getMonth()
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < date.getDate())) {
|
||||
age -= 1
|
||||
}
|
||||
return age < 18
|
||||
}
|
||||
|
||||
function parseBirthdate(value) {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return null
|
||||
|
||||
if (/^\d{4}$/.test(raw)) {
|
||||
const parsed = new Date(Number(raw), 0, 1)
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed
|
||||
}
|
||||
|
||||
const parsed = new Date(raw)
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return 'unbekannt'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return 'unbekannt'
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
useHead({
|
||||
title: 'QTTR-Werte - Harheimer TC'
|
||||
})
|
||||
</script>
|
||||
@@ -6,7 +6,7 @@
|
||||
Passwort zurücksetzen
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-600">
|
||||
Geben Sie Ihre E-Mail-Adresse ein, um Ihr Passwort zurückzusetzen
|
||||
Geben Sie Ihre E-Mail-Adresse ein, um einen Reset-Link zu erhalten
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||