Compare commits
48 Commits
test/fix-v
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b69130c2b2 | ||
|
|
77aabef4a9 | ||
|
|
4b699de853 | ||
|
|
e3cb7282bc | ||
|
|
e537839e28 | ||
|
|
44d441811c | ||
|
|
da1efa5a74 | ||
| c7a306e8fa | |||
|
|
14cd5f04d5 | ||
|
|
7e533fae49 | ||
|
|
8393f154e5 | ||
|
|
c956869e8a | ||
|
|
4eabb3b766 | ||
|
|
146dedd9b4 | ||
|
|
6076194497 | ||
|
|
9cde1ab78b | ||
|
|
f5facaa811 | ||
|
|
78015298ec | ||
|
|
b4e1c50ea3 | ||
|
|
5da11d2e4d | ||
|
|
e8a50e55ca | ||
|
|
530e544542 | ||
|
|
300dce9835 | ||
|
|
a98def915e | ||
|
|
7aa7970f2e | ||
|
|
e517720b03 | ||
|
|
402913d877 | ||
|
|
2014abe660 | ||
|
|
80834d8652 | ||
| 45de2a576c | |||
|
|
7bc98c03e4 | ||
|
|
bf1caefde4 | ||
|
|
6983186caf | ||
| e1ad5f7205 | |||
|
|
7c93966878 | ||
| 14341b7a63 | |||
|
|
31d20f1bff | ||
|
|
6507afea5f | ||
|
|
387ce6e08e | ||
|
|
f822fc8a8e | ||
|
|
67c746f18b | ||
| 803481ca8e | |||
|
|
1e65cb47da | ||
|
|
ec96e21517 | ||
|
|
46f80df165 | ||
| 46a8d5a77a | |||
| 96c1d0260b | |||
| 6e00a1b829 |
@@ -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: |
|
||||
@@ -126,7 +146,7 @@ jobs:
|
||||
-o BatchMode=yes \
|
||||
-p "${{ vars.PROD_PORT }}" \
|
||||
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
|
||||
"bash -lc 'cd /var/www/harheimertc && git fetch origin main && git checkout -B main origin/main && git reset --hard origin/main && ./deploy-production.sh'"
|
||||
"bash -lc 'cd /var/www/harheimertc && git reset --hard HEAD && git fetch origin main && git checkout -B main origin/main && git reset --hard origin/main && ./deploy-production.sh'"
|
||||
|
||||
deploy-test:
|
||||
needs: analyze
|
||||
@@ -157,4 +177,4 @@ jobs:
|
||||
-o BatchMode=yes \
|
||||
-p "${{ vars.PROD_PORT }}" \
|
||||
"${{ vars.PROD_USER }}@${{ vars.PROD_HOST }}" \
|
||||
"bash -lc 'cd /var/www/harheimertc.test && git fetch origin dev && git checkout -B dev origin/dev && git reset --hard origin/dev && ./deploy-test.sh'"
|
||||
"bash -lc 'cd /var/www/harheimertc.test && git reset --hard HEAD && git fetch origin dev && git checkout -B dev origin/dev && git reset --hard origin/dev && ./deploy-test.sh'"
|
||||
|
||||
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
|
||||
|
||||
7
.gitleaks.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
[[allowlists]]
|
||||
description = "generated/imported non-secret data"
|
||||
paths = [
|
||||
'''server/data/spielplan-import/harheimer_tc_spielplan\.(html|json)$''',
|
||||
'''android-app/app/build/.*''',
|
||||
'''android-app/\.idea/planningMode\.xml$''',
|
||||
]
|
||||
3
.gitleaksignore
Normal file
@@ -0,0 +1,3 @@
|
||||
google-services.json:gcp-api-key:18
|
||||
google-services.json:gcp-api-key:37
|
||||
google-services.json:gcp-api-key:56
|
||||
@@ -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
|
||||
@@ -46,6 +88,13 @@ android {
|
||||
versionName = androidVersionName
|
||||
}
|
||||
|
||||
lint {
|
||||
disable += setOf(
|
||||
"AutoboxingStateCreation",
|
||||
"MutableCollectionMutableState",
|
||||
)
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
if (hasReleaseSigning) {
|
||||
@@ -135,6 +184,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 +212,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 +264,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 +276,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")
|
||||
|
||||
1
android-app/app/google-services.json
Symbolic link
@@ -0,0 +1 @@
|
||||
../../google-services.json
|
||||
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"
|
||||
@@ -10,12 +11,20 @@
|
||||
android:theme="@style/Theme.HarheimerTC"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||
<activity android:name="de.harheimertc.MainActivity"
|
||||
android:exported="true">
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<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,20 @@
|
||||
package de.harheimertc
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
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.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
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,22 +24,67 @@ import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val notificationRoute = mutableStateOf<String?>(null)
|
||||
|
||||
private val notificationPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
Log.i("NOTIFICATIONS", "POST_NOTIFICATIONS granted=$granted")
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestNotificationPermissionIfNeeded()
|
||||
notificationRoute.value = extractNotificationRoute(intent)
|
||||
setContent {
|
||||
App()
|
||||
App(
|
||||
notificationRoute = notificationRoute.value,
|
||||
onNotificationRouteConsumed = { notificationRoute.value = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
notificationRoute.value = extractNotificationRoute(intent)
|
||||
}
|
||||
|
||||
private fun extractNotificationRoute(intent: Intent?): String? =
|
||||
intent?.getStringExtra(EXTRA_NOTIFICATION_ROUTE)?.takeIf { it.isNotBlank() }
|
||||
|
||||
private fun requestNotificationPermissionIfNeeded() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !HarheimerNotifications.hasNotificationPermission(this)) {
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_NOTIFICATION_TYPE = "de.harheimertc.extra.NOTIFICATION_TYPE"
|
||||
const val EXTRA_NOTIFICATION_ROUTE = "de.harheimertc.extra.NOTIFICATION_ROUTE"
|
||||
const val EXTRA_NEWS_ID = "de.harheimertc.extra.NEWS_ID"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun App() {
|
||||
fun App(
|
||||
notificationRoute: String? = null,
|
||||
onNotificationRouteConsumed: () -> Unit = {},
|
||||
) {
|
||||
HarheimerTheme {
|
||||
val navController = rememberNavController()
|
||||
val ctx = LocalContext.current
|
||||
val activity = ctx as? ComponentActivity
|
||||
Log.i("HILT_FACTORY", "defaultViewModelProviderFactory=${activity?.defaultViewModelProviderFactory?.javaClass?.name}")
|
||||
val navigationViewModel: NavigationViewModel = hiltViewModel()
|
||||
LaunchedEffect(notificationRoute) {
|
||||
val route = notificationRoute?.takeIf { it.isNotBlank() } ?: return@LaunchedEffect
|
||||
navController.navigate(route) {
|
||||
launchSingleTop = true
|
||||
popUpTo(Destinations.Home.route)
|
||||
}
|
||||
onNotificationRouteConsumed()
|
||||
}
|
||||
NavGraph(navController = navController, navigationViewModelParam = navigationViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import okhttp3.RequestBody
|
||||
data class ContactRequest(val name: String, val email: String, val message: String)
|
||||
data class ContactResponse(val ok: Boolean, val id: String? = null, val message: String? = null)
|
||||
data class TermineResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
|
||||
data class TermineManageResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
|
||||
data class TerminDto(
|
||||
val datum: String = "",
|
||||
val uhrzeit: String? = null,
|
||||
@@ -37,6 +38,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 +88,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 +103,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,
|
||||
@@ -225,7 +232,7 @@ data class ProfileVisibilityDto(
|
||||
val showEmail: Boolean = true,
|
||||
val showPhone: Boolean = true,
|
||||
val showAddress: Boolean = false,
|
||||
val showBirthday: Boolean = true,
|
||||
val showBirthday: Boolean = false,
|
||||
)
|
||||
data class ProfileUserDto(
|
||||
val id: String? = null,
|
||||
@@ -251,6 +258,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 +291,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 = "",
|
||||
@@ -276,6 +329,7 @@ data class MemberDto(
|
||||
val editable: Boolean = false,
|
||||
val isMannschaftsspieler: Boolean = false,
|
||||
val hasHallKey: Boolean = false,
|
||||
val showBirthday: Boolean = false,
|
||||
val loginRoles: List<String> = emptyList(),
|
||||
)
|
||||
data class MembersResponse(
|
||||
@@ -387,6 +441,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 = "",
|
||||
@@ -529,6 +592,21 @@ interface ApiService {
|
||||
@GET("/api/termine")
|
||||
suspend fun termine(): Response<TermineResponse>
|
||||
|
||||
@GET("/api/termine-manage")
|
||||
suspend fun termineManage(): Response<TermineManageResponse>
|
||||
|
||||
@POST("/api/termine-manage")
|
||||
suspend fun saveTermin(@Body request: TerminDto): Response<AuthMessageResponse>
|
||||
|
||||
@DELETE("/api/termine-manage")
|
||||
suspend fun deleteTermin(
|
||||
@Query("datum") datum: String,
|
||||
@Query("uhrzeit") uhrzeit: String = "",
|
||||
@Query("titel") titel: String,
|
||||
@Query("beschreibung") beschreibung: String = "",
|
||||
@Query("kategorie") kategorie: String = "Sonstiges",
|
||||
): Response<AuthMessageResponse>
|
||||
|
||||
@GET("/api/spielplan")
|
||||
suspend fun spielplan(@Query("season") season: String? = null): Response<SpielplanResponse>
|
||||
|
||||
@@ -548,14 +626,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 +701,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>
|
||||
|
||||
@@ -634,6 +730,7 @@ interface ApiService {
|
||||
val notes: String? = null,
|
||||
val isMannschaftsspieler: Boolean = false,
|
||||
val hasHallKey: Boolean = false,
|
||||
val showBirthday: Boolean = false,
|
||||
)
|
||||
|
||||
data class BulkImportRequest(val members: List<Map<String, String>>)
|
||||
|
||||
@@ -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(
|
||||
@param: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, message.data)
|
||||
Log.d("HarheimerMessaging", "Push message received type=${message.data["type"]}, shown=$shown")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package de.harheimertc.notifications
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
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.MainActivity
|
||||
import de.harheimertc.R
|
||||
import de.harheimertc.ui.navigation.Destinations
|
||||
|
||||
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,
|
||||
data: Map<String, String> = emptyMap(),
|
||||
): 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)
|
||||
.setContentIntent(createContentIntent(context, notificationId, data))
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
return try {
|
||||
NotificationManagerCompat.from(context).notify(notificationId, notification)
|
||||
true
|
||||
} catch (_: SecurityException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun createContentIntent(context: Context, notificationId: Int, payload: Map<String, String>): PendingIntent {
|
||||
val route = destinationRoute(payload)
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
putExtra(MainActivity.EXTRA_NOTIFICATION_TYPE, payload["type"])
|
||||
putExtra(MainActivity.EXTRA_NOTIFICATION_ROUTE, route)
|
||||
payload["newsId"]?.let { putExtra(MainActivity.EXTRA_NEWS_ID, it) }
|
||||
}
|
||||
return PendingIntent.getActivity(
|
||||
context,
|
||||
notificationId,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun destinationRoute(data: Map<String, String>): String = when (data["type"]) {
|
||||
"news", "news_expiring" -> Destinations.MemberNews.route
|
||||
"event", "events_today", "events_tomorrow" -> Destinations.Termine.route
|
||||
"team_matches" -> Destinations.Spielplan.route
|
||||
"birthdays" -> Destinations.MemberArea.route
|
||||
"contact_request" -> Destinations.CmsContactRequests.route
|
||||
"user_registration" -> Destinations.CmsBenutzer.route
|
||||
else -> Destinations.Home.route
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -10,6 +10,8 @@ import de.harheimertc.data.PasswordResetDiagnosticsResponse
|
||||
import de.harheimertc.data.SaveCsvRequest
|
||||
import de.harheimertc.data.SaveCsvResponse
|
||||
import de.harheimertc.data.SecureOfflineCache
|
||||
import de.harheimertc.data.SpielplanResponse
|
||||
import de.harheimertc.data.TerminDto
|
||||
import javax.inject.Inject
|
||||
|
||||
class CmsRepository @Inject constructor(
|
||||
@@ -83,6 +85,86 @@ class CmsRepository @Inject constructor(
|
||||
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun managedTermine(): Result<List<TerminDto>> = runCatching {
|
||||
val response = api.termineManage()
|
||||
if (!response.isSuccessful) error("Termine konnten nicht geladen werden.")
|
||||
response.body()?.termine.orEmpty()
|
||||
}
|
||||
|
||||
suspend fun saveTermin(request: TerminDto): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val response = api.saveTermin(request)
|
||||
if (!response.isSuccessful) error("Termin konnte nicht gespeichert werden.")
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun deleteTermin(request: TerminDto): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
|
||||
val response = api.deleteTermin(
|
||||
datum = request.datum,
|
||||
uhrzeit = request.uhrzeit.orEmpty(),
|
||||
titel = request.titel,
|
||||
beschreibung = request.beschreibung.orEmpty(),
|
||||
kategorie = request.kategorie ?: "Sonstiges",
|
||||
)
|
||||
if (!response.isSuccessful) error("Termin konnte nicht gelöscht werden.")
|
||||
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun mannschaften(season: String? = null): Result<List<CmsMannschaftRow>> = runCatching {
|
||||
val response = api.mannschaften(season)
|
||||
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
|
||||
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||
if (values.size < 10 || values[0].isBlank()) return@mapNotNull null
|
||||
CmsMannschaftRow(
|
||||
mannschaft = values[0],
|
||||
liga = values[1],
|
||||
staffelleiter = values[2],
|
||||
telefon = values[3],
|
||||
heimspieltag = values[4],
|
||||
spielsystem = values[5],
|
||||
mannschaftsfuehrer = values[6],
|
||||
spieler = values[7],
|
||||
informationenLink = values[8],
|
||||
letzteAktualisierung = values[9],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun mannschaftenSeasons(): Result<de.harheimertc.data.MannschaftenSeasonsResponse> = runCatching {
|
||||
val response = api.mannschaftenSeasons()
|
||||
if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.")
|
||||
response.body() ?: de.harheimertc.data.MannschaftenSeasonsResponse()
|
||||
}
|
||||
|
||||
suspend fun saveMannschaften(season: String?, rows: List<CmsMannschaftRow>): Result<SaveCsvResponse> = runCatching {
|
||||
val response = api.saveCsv(
|
||||
SaveCsvRequest(
|
||||
filename = season?.takeIf { it.isNotBlank() }?.let { "mannschaften_$it.csv" } ?: "mannschaften.csv",
|
||||
content = rows.toMannschaftenCsv(),
|
||||
),
|
||||
)
|
||||
if (!response.isSuccessful) error("Mannschaften konnten nicht gespeichert werden.")
|
||||
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun spielplan(season: String? = null): Result<SpielplanResponse> = runCatching {
|
||||
val response = api.spielplan(season)
|
||||
if (!response.isSuccessful) error("Spielplan konnte nicht geladen werden.")
|
||||
val body = response.body() ?: error("Leere Antwort")
|
||||
if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.")
|
||||
body
|
||||
}
|
||||
|
||||
suspend fun saveSpielplan(headers: List<String>, rows: List<List<String>>): Result<SaveCsvResponse> = runCatching {
|
||||
val response = api.saveCsv(
|
||||
SaveCsvRequest(
|
||||
filename = "spielplan.csv",
|
||||
content = listOf(headers).plus(rows).joinToString("\n") { row -> row.toCsvRow(";") },
|
||||
),
|
||||
)
|
||||
if (!response.isSuccessful) error("Spielplan konnte nicht gespeichert werden.")
|
||||
response.body() ?: SaveCsvResponse(success = false, message = "Leere Antwort")
|
||||
}
|
||||
|
||||
suspend fun users(): Result<CmsUsersResponse> =
|
||||
fetchEncryptedFallback(
|
||||
load = {
|
||||
@@ -267,7 +349,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()
|
||||
@@ -288,6 +370,58 @@ class CmsRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
data class CmsMannschaftRow(
|
||||
val mannschaft: String = "",
|
||||
val liga: String = "",
|
||||
val staffelleiter: String = "",
|
||||
val telefon: String = "",
|
||||
val heimspieltag: String = "",
|
||||
val spielsystem: String = "",
|
||||
val mannschaftsfuehrer: String = "",
|
||||
val spieler: String = "",
|
||||
val informationenLink: String = "",
|
||||
val letzteAktualisierung: String = "",
|
||||
)
|
||||
|
||||
private fun List<CmsMannschaftRow>.toMannschaftenCsv(): String {
|
||||
val header = listOf(
|
||||
"Mannschaft",
|
||||
"Liga",
|
||||
"Staffelleiter",
|
||||
"Telefon",
|
||||
"Heimspieltag",
|
||||
"Spielsystem",
|
||||
"Mannschaftsführer",
|
||||
"Spieler",
|
||||
"Weitere Informationen Link",
|
||||
"Letzte Aktualisierung",
|
||||
).toCsvRow()
|
||||
val rows = map { row ->
|
||||
listOf(
|
||||
row.mannschaft,
|
||||
row.liga,
|
||||
row.staffelleiter,
|
||||
row.telefon,
|
||||
row.heimspieltag,
|
||||
row.spielsystem,
|
||||
row.mannschaftsfuehrer,
|
||||
row.spieler,
|
||||
row.informationenLink,
|
||||
row.letzteAktualisierung,
|
||||
).toCsvRow()
|
||||
}
|
||||
return listOf(header).plus(rows).joinToString("\n")
|
||||
}
|
||||
|
||||
private fun List<String>.toCsvRow(delimiter: String = ","): String =
|
||||
joinToString(delimiter) { value -> value.csvEscape(delimiter) }
|
||||
|
||||
private fun String.csvEscape(delimiter: String): String {
|
||||
val needsQuotes = contains(delimiter) || contains('"') || contains('\n') || contains('\r')
|
||||
val escaped = replace("\"", "\"\"")
|
||||
return if (needsQuotes) "\"$escaped\"" else escaped
|
||||
}
|
||||
|
||||
private fun parseCsv(csv: String): List<List<String>> =
|
||||
csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList()
|
||||
|
||||
|
||||
@@ -24,27 +24,27 @@ class GalleryRepository @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) {
|
||||
suspend fun hasPublicImages(): Result<Boolean> = runCatching {
|
||||
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 {
|
||||
return runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val resp = api.galerieList(page = page, perPage = perPage)
|
||||
if (resp.isSuccessful) {
|
||||
val body = resp.body()
|
||||
Result.success(
|
||||
GalleryPage(
|
||||
images = body?.images.orEmpty().map { it.toGalleryImage() },
|
||||
pagination = body?.pagination ?: GalleryPaginationDto(),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
Result.failure(Exception("HTTP ${resp.code()}"))
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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,10 +26,20 @@ data class Mannschaft(
|
||||
@Singleton
|
||||
class MannschaftenRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchMannschaften(season: String? = null): Result<List<Mannschaft>> = runCatching {
|
||||
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()
|
||||
.filter(String::isNotBlank)
|
||||
|
||||
@@ -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,10 +8,12 @@ import javax.inject.Inject
|
||||
|
||||
class NewsletterRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun groups(): Result<NewsletterGroupsResponse> = runCatching {
|
||||
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 {
|
||||
val response = api.subscribeNewsletter(NewsletterSubscriptionRequest(groupId, email.trim(), name?.trim().orEmpty()))
|
||||
@@ -26,8 +28,10 @@ class NewsletterRepository @Inject constructor(private val api: ApiService) {
|
||||
}
|
||||
|
||||
suspend fun confirm(token: String): Result<AuthMessageResponse> = runCatching {
|
||||
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,6 +28,7 @@ class PasskeyRepository @Inject constructor(
|
||||
private val authRepository: AuthRepository,
|
||||
) {
|
||||
suspend fun login(context: Context, email: String?): Result<LoginResponse> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val optionsResponse = api.passkeyAuthenticationOptions(
|
||||
PasskeyAuthenticationOptionsRequest(email = email?.trim()?.takeIf(String::isNotBlank)),
|
||||
)
|
||||
@@ -59,15 +60,19 @@ class PasskeyRepository @Inject constructor(
|
||||
?: 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 {
|
||||
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 {
|
||||
retryOnNetworkFailure {
|
||||
val optionsResponse = api.passkeyRegistrationOptions(PasskeyRegistrationOptionsRequest())
|
||||
if (!optionsResponse.isSuccessful) error("Passkey-Erstellung konnte nicht gestartet werden.")
|
||||
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
|
||||
@@ -90,6 +95,7 @@ class PasskeyRepository @Inject constructor(
|
||||
)
|
||||
if (!response.isSuccessful) error("Passkey konnte nicht hinzugefügt werden.")
|
||||
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 {
|
||||
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 {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.updateProfile(request)
|
||||
if (!response.isSuccessful) error("Profil konnte nicht gespeichert werden.")
|
||||
response.body() ?: error("Leere Antwort")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,12 +31,15 @@ data class MeisterschaftResult(
|
||||
@Singleton
|
||||
class PublicPagesRepository @Inject constructor(private val api: ApiService) {
|
||||
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
|
||||
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 {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.spielsysteme()
|
||||
if (!response.isSuccessful) error("Spielsysteme konnten nicht geladen werden.")
|
||||
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||
@@ -52,8 +55,10 @@ class PublicPagesRepository @Inject constructor(private val api: ApiService) {
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchVereinsmeisterschaften(): Result<List<MeisterschaftResult>> = runCatching {
|
||||
retryOnNetworkFailure {
|
||||
val response = api.vereinsmeisterschaften()
|
||||
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.")
|
||||
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
|
||||
@@ -70,6 +75,7 @@ class PublicPagesRepository @Inject constructor(private val api: ApiService) {
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseCsv(csv: String): List<List<String>> =
|
||||
|
||||
@@ -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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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,76 +171,92 @@ 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) }
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
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
|
||||
MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = {
|
||||
sectionOverride.value = null
|
||||
onNavigate(Destinations.Contact.route)
|
||||
})
|
||||
}
|
||||
// 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
|
||||
}
|
||||
} else if (idx > cmsIndex && cmsIndex >= 0) {
|
||||
// skip cms children here; they'll be rendered in the second row when expanded
|
||||
Spacer(Modifier.width(12.dp))
|
||||
if (navigationState.loggedIn) {
|
||||
TextButton(onClick = onLogout) { Text("Logout", color = Color.White) }
|
||||
} else {
|
||||
// normal item before CMS: close cms submenu on navigate
|
||||
SubLink(item.label, item.route == selectedRoute) { navigateAndClose(item.route) }
|
||||
}
|
||||
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))
|
||||
if (loggedIn) {
|
||||
TextButton(onClick = onLogout) { Text("Logout", color = Color.White) }
|
||||
} else {
|
||||
TextButton(onClick = onLogin) { Text("Login", color = Color.White) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -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,14 +410,19 @@ 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,
|
||||
Destinations.CmsNews.route,
|
||||
Destinations.CmsSportbetrieb.route,
|
||||
Destinations.CmsMitgliederverwaltung.route,
|
||||
Destinations.CmsNewsletter.route,
|
||||
@@ -302,7 +430,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 +448,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.CmsNews.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,12 +40,15 @@ 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")
|
||||
object CmsVereinsmeisterschaften : Destinations("cms/vereinsmeisterschaften")
|
||||
object CmsNews : Destinations("cms/news")
|
||||
object CmsSportbetrieb : Destinations("cms/sportbetrieb")
|
||||
object CmsMitgliederverwaltung : Destinations("cms/mitgliederverwaltung")
|
||||
object CmsNewsletter : Destinations("cms/newsletter")
|
||||
|
||||
@@ -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,
|
||||
@@ -288,6 +335,9 @@ fun NavGraph(
|
||||
composable(Destinations.CmsVereinsmeisterschaften.route) {
|
||||
de.harheimertc.ui.screens.cms.CmsVereinsmeisterschaftenScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.CmsNews.route) {
|
||||
de.harheimertc.ui.screens.cms.CmsNewsScreen(navController, !persistentNavigation)
|
||||
}
|
||||
composable(Destinations.CmsSportbetrieb.route) {
|
||||
de.harheimertc.ui.screens.cms.CmsSportbetriebScreen(navController, !persistentNavigation)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package de.harheimertc.ui.screens.cms
|
||||
|
||||
import android.content.Intent
|
||||
import android.app.DatePickerDialog
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -16,7 +18,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
|
||||
@@ -37,6 +38,8 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.navigation.NavController
|
||||
@@ -49,9 +52,12 @@ import de.harheimertc.data.NewsletterGroupDto
|
||||
import de.harheimertc.data.PasswordResetMatchingUserDto
|
||||
import de.harheimertc.data.PasswordResetAttemptDto
|
||||
import de.harheimertc.data.PasswordResetStepDto
|
||||
import de.harheimertc.data.TerminDto
|
||||
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.CmsMannschaftRow
|
||||
import de.harheimertc.repositories.MeisterschaftResult
|
||||
import de.harheimertc.ui.theme.Accent100
|
||||
import de.harheimertc.ui.theme.Accent500
|
||||
@@ -61,13 +67,14 @@ import de.harheimertc.ui.theme.Primary100
|
||||
import de.harheimertc.ui.theme.Primary600
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
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 +92,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 +178,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 +269,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()) {
|
||||
@@ -433,116 +440,362 @@ fun CmsVereinsmeisterschaftenScreen(navController: NavController, showBackNaviga
|
||||
@Composable
|
||||
fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val config = state.config
|
||||
var ortName by remember { mutableStateOf("") }
|
||||
var ortStrasse by remember { mutableStateOf("") }
|
||||
var ortPlz by remember { mutableStateOf("") }
|
||||
var ortOrt by remember { mutableStateOf("") }
|
||||
val trainingTimes = remember { mutableStateListOf<de.harheimertc.data.TrainingTimeDto>() }
|
||||
val trainers = remember { mutableStateListOf<de.harheimertc.data.TrainerDto>() }
|
||||
val context = LocalContext.current
|
||||
var activeTab by remember { mutableStateOf("termine") }
|
||||
val mannschaften = remember { mutableStateListOf<CmsMannschaftRow>() }
|
||||
var spielplanCsv by remember { mutableStateOf("") }
|
||||
var spielplanEditorOpen by remember { mutableStateOf(false) }
|
||||
var terminDialogOpen by remember { mutableStateOf(false) }
|
||||
var editingTermin by remember { mutableStateOf<TerminDto?>(null) }
|
||||
var terminDatum by remember { mutableStateOf("") }
|
||||
var terminUhrzeit by remember { mutableStateOf("") }
|
||||
var terminTitel by remember { mutableStateOf("") }
|
||||
var terminBeschreibung by remember { mutableStateOf("") }
|
||||
var terminKategorie by remember { mutableStateOf("Sonstiges") }
|
||||
var terminKategorieOpen by remember { mutableStateOf(false) }
|
||||
val tabs = listOf(
|
||||
"termine" to "Termine",
|
||||
"mannschaften" to "Mannschaften",
|
||||
"spielplaene" to "Spielpläne",
|
||||
)
|
||||
val terminKategorien = listOf("Training", "Punktspiel", "Turnier", "Veranstaltung", "Sonstiges")
|
||||
|
||||
LaunchedEffect(config) {
|
||||
config?.let {
|
||||
ortName = it.training.ort.name
|
||||
ortStrasse = it.training.ort.strasse
|
||||
ortPlz = it.training.ort.plz
|
||||
ortOrt = it.training.ort.ort
|
||||
trainingTimes.clear()
|
||||
trainingTimes.addAll(it.training.zeiten)
|
||||
trainers.clear()
|
||||
trainers.addAll(it.trainer)
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadSportbetrieb()
|
||||
}
|
||||
|
||||
LaunchedEffect(state.sportMannschaften) {
|
||||
mannschaften.clear()
|
||||
mannschaften.addAll(state.sportMannschaften)
|
||||
}
|
||||
|
||||
LaunchedEffect(state.sportSpielplanHeaders, state.sportSpielplanRows) {
|
||||
spielplanCsv = sportSpielplanCsvText(state.sportSpielplanHeaders, state.sportSpielplanRows)
|
||||
}
|
||||
|
||||
fun openTerminDialog(termin: TerminDto?) {
|
||||
editingTermin = termin
|
||||
terminDatum = termin?.datum.orEmpty()
|
||||
terminUhrzeit = termin?.uhrzeit.orEmpty()
|
||||
terminTitel = termin?.titel.orEmpty()
|
||||
terminBeschreibung = termin?.beschreibung.orEmpty()
|
||||
terminKategorie = termin?.kategorie ?: "Sonstiges"
|
||||
terminDialogOpen = true
|
||||
}
|
||||
|
||||
fun openDatePicker() {
|
||||
val calendar = Calendar.getInstance()
|
||||
runCatching {
|
||||
val parts = terminDatum.split("-")
|
||||
if (parts.size == 3) {
|
||||
calendar.set(parts[0].toInt(), parts[1].toInt() - 1, parts[2].toInt())
|
||||
}
|
||||
}
|
||||
DatePickerDialog(
|
||||
context,
|
||||
{ _, year, month, day ->
|
||||
terminDatum = "%04d-%02d-%02d".format(Locale.ROOT, year, month + 1, day)
|
||||
},
|
||||
calendar.get(Calendar.YEAR),
|
||||
calendar.get(Calendar.MONTH),
|
||||
calendar.get(Calendar.DAY_OF_MONTH),
|
||||
).show()
|
||||
}
|
||||
|
||||
CmsPage(navController, showBackNavigation, "Sportbetrieb", "Termine, Mannschaften und Spielpläne") {
|
||||
if (state.sportLoading) item { LoadingState("Sportbetriebsdaten werden geladen...") }
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
tabs.forEach { (id, label) ->
|
||||
if (activeTab == id) {
|
||||
Button(onClick = { activeTab = id }, modifier = Modifier.weight(1f)) { Text(label) }
|
||||
} else {
|
||||
OutlinedButton(onClick = { activeTab = id }, modifier = Modifier.weight(1f)) { Text(label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CmsPage(navController, showBackNavigation, "Sportbetrieb", "Trainingszeiten, Trainingsort und Trainer pflegen") {
|
||||
when {
|
||||
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
|
||||
else -> {
|
||||
if (!state.sportLoading) {
|
||||
when (activeTab) {
|
||||
"termine" -> {
|
||||
item {
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.saveConfig(
|
||||
config.copy(
|
||||
training = config.training.copy(
|
||||
ort = config.training.ort.copy(
|
||||
name = ortName,
|
||||
strasse = ortStrasse,
|
||||
plz = ortPlz,
|
||||
ort = ortOrt,
|
||||
),
|
||||
zeiten = trainingTimes.toList(),
|
||||
),
|
||||
trainer = trainers.toList(),
|
||||
),
|
||||
)
|
||||
},
|
||||
enabled = !state.saving,
|
||||
onClick = { openTerminDialog(null) },
|
||||
enabled = !state.sportSaving,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(if (state.saving) "Speichert..." else "Speichern")
|
||||
Text("Termin hinzufügen")
|
||||
}
|
||||
}
|
||||
if (state.sportTermine.isEmpty()) {
|
||||
item { EmptyCard("Keine Termine gefunden.") }
|
||||
}
|
||||
items(state.sportTermine.size) { index ->
|
||||
val termin = state.sportTermine[index]
|
||||
DataCard(termin.titel.ifBlank { "Termin" }) {
|
||||
InfoRow("Datum", listOf(termin.datum, termin.uhrzeit.orEmpty()).filter(String::isNotBlank).joinToString(" "))
|
||||
InfoRow("Kategorie", termin.kategorie ?: "Sonstiges")
|
||||
if (!termin.beschreibung.isNullOrBlank()) {
|
||||
Text(termin.beschreibung, color = Accent700)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedButton(onClick = { openTerminDialog(termin) }, modifier = Modifier.weight(1f)) {
|
||||
Text("Bearbeiten")
|
||||
}
|
||||
TextButton(
|
||||
onClick = { viewModel.deleteSportTermin(termin) },
|
||||
enabled = !state.sportSaving,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Löschen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"mannschaften" -> {
|
||||
item {
|
||||
DataCard("Trainingsort") {
|
||||
OutlinedTextField(value = ortName, onValueChange = { ortName = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = ortStrasse, onValueChange = { ortStrasse = it }, label = { Text("Straße") }, modifier = Modifier.fillMaxWidth())
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(value = ortPlz, onValueChange = { ortPlz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
|
||||
OutlinedTextField(value = ortOrt, onValueChange = { ortOrt = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
|
||||
if (state.sportMannschaftenSeasons.isNotEmpty()) {
|
||||
DataCard("Saison") {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
state.sportMannschaftenSeasons.forEach { season ->
|
||||
if (season == state.sportMannschaftenSeason) {
|
||||
Button(onClick = { }, modifier = Modifier.weight(1f)) { Text(season) }
|
||||
} else {
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.loadSportMannschaftenSeason(season) },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text(season)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
DataCard("Trainingszeiten") {
|
||||
trainingTimes.forEachIndexed { index, zeit ->
|
||||
TrainingTimeEditorCard(
|
||||
zeit = zeit,
|
||||
onChange = { updated -> trainingTimes[index] = updated },
|
||||
onRemove = { trainingTimes.removeAt(index) },
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
Button(
|
||||
onClick = {
|
||||
trainingTimes.add(
|
||||
de.harheimertc.data.TrainingTimeDto(
|
||||
id = "training-${System.currentTimeMillis()}",
|
||||
tag = "Montag",
|
||||
),
|
||||
)
|
||||
mannschaften.add(CmsMannschaftRow(letzteAktualisierung = OffsetDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE)))
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Trainingszeit hinzufügen")
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
DataCard("Trainer") {
|
||||
trainers.forEachIndexed { index, trainer ->
|
||||
TrainerEditorCard(
|
||||
trainer = trainer,
|
||||
onChange = { updated -> trainers[index] = updated },
|
||||
onRemove = { trainers.removeAt(index) },
|
||||
)
|
||||
Text("Hinzufügen")
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
trainers.add(
|
||||
de.harheimertc.data.TrainerDto(
|
||||
id = "trainer-${System.currentTimeMillis()}",
|
||||
),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = { viewModel.saveSportMannschaften(state.sportMannschaftenSeason, mannschaften.toList()) },
|
||||
enabled = !state.sportSaving,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Trainer hinzufügen")
|
||||
Text(if (state.sportSaving) "Speichert..." else "Speichern")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mannschaften.isEmpty()) {
|
||||
item { EmptyCard("Keine Mannschaften gefunden.") }
|
||||
}
|
||||
items(mannschaften.size) { index ->
|
||||
MannschaftEditorCard(
|
||||
row = mannschaften[index],
|
||||
onChange = { updated -> mannschaften[index] = updated },
|
||||
onRemove = { mannschaften.removeAt(index) },
|
||||
)
|
||||
}
|
||||
}
|
||||
"spielplaene" -> {
|
||||
item {
|
||||
DataCard("Vereins-Spielplan (CSV)") {
|
||||
val seasonLabel = state.sportSpielplanSeason.ifBlank { "aktuelle Saison" }
|
||||
val fileName = state.sportSpielplanSeason.takeIf { it.isNotBlank() }?.let { "spielplan-$it.json" } ?: "spielplan.csv"
|
||||
InfoRow("Datei", fileName)
|
||||
InfoRow("Saison", seasonLabel)
|
||||
InfoRow("Einträge", state.sportSpielplanRows.size.toString())
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedButton(
|
||||
onClick = { viewModel.loadSportbetrieb() },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("Neu laden")
|
||||
}
|
||||
Button(
|
||||
onClick = { spielplanEditorOpen = true },
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
Text("CSV bearbeiten")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item { FormMessages(state.error, state.message) }
|
||||
}
|
||||
}
|
||||
|
||||
if (terminDialogOpen) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { terminDialogOpen = false },
|
||||
title = { Text(if (editingTermin == null) "Termin hinzufügen" else "Termin bearbeiten") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = { openDatePicker() }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(terminDatum.ifBlank { "Datum auswählen" })
|
||||
}
|
||||
OutlinedTextField(value = terminUhrzeit, onValueChange = { terminUhrzeit = it }, label = { Text("Uhrzeit") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = terminTitel, onValueChange = { terminTitel = it }, label = { Text("Titel") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = terminBeschreibung, onValueChange = { terminBeschreibung = it }, label = { Text("Beschreibung") }, modifier = Modifier.fillMaxWidth(), minLines = 3)
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedButton(onClick = { terminKategorieOpen = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(terminKategorie.ifBlank { "Kategorie auswählen" })
|
||||
}
|
||||
DropdownMenu(expanded = terminKategorieOpen, onDismissRequest = { terminKategorieOpen = false }) {
|
||||
terminKategorien.forEach { kategorie ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(kategorie) },
|
||||
onClick = {
|
||||
terminKategorie = kategorie
|
||||
terminKategorieOpen = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
viewModel.saveSportTermin(
|
||||
editingTermin,
|
||||
TerminDto(
|
||||
datum = terminDatum,
|
||||
uhrzeit = terminUhrzeit.takeIf { it.isNotBlank() },
|
||||
titel = terminTitel,
|
||||
beschreibung = terminBeschreibung.takeIf { it.isNotBlank() },
|
||||
kategorie = terminKategorie.ifBlank { "Sonstiges" },
|
||||
),
|
||||
)
|
||||
terminDialogOpen = false
|
||||
},
|
||||
enabled = !state.sportSaving && terminDatum.isNotBlank() && terminTitel.isNotBlank(),
|
||||
) {
|
||||
Text(if (state.sportSaving) "Speichert..." else "Speichern")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { terminDialogOpen = false }) { Text("Abbrechen") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (spielplanEditorOpen) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { spielplanEditorOpen = false },
|
||||
title = { Text("Spielplan CSV bearbeiten") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = spielplanCsv,
|
||||
onValueChange = { spielplanCsv = it },
|
||||
label = { Text("CSV mit Semikolon") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
minLines = 12,
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
val (headers, rows) = parseSportCsvText(spielplanCsv)
|
||||
viewModel.saveSportSpielplan(headers, rows)
|
||||
spielplanEditorOpen = false
|
||||
},
|
||||
enabled = !state.sportSaving && spielplanCsv.isNotBlank(),
|
||||
) {
|
||||
Text(if (state.sportSaving) "Speichert..." else "Speichern")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { spielplanEditorOpen = false }) { Text("Abbrechen") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MannschaftEditorCard(
|
||||
row: CmsMannschaftRow,
|
||||
onChange: (CmsMannschaftRow) -> Unit,
|
||||
onRemove: () -> Unit,
|
||||
) {
|
||||
DataCard(row.mannschaft.ifBlank { "Mannschaft" }) {
|
||||
OutlinedTextField(value = row.mannschaft, onValueChange = { onChange(row.copy(mannschaft = it)) }, label = { Text("Mannschaft") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = row.liga, onValueChange = { onChange(row.copy(liga = it)) }, label = { Text("Liga") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = row.staffelleiter, onValueChange = { onChange(row.copy(staffelleiter = it)) }, label = { Text("Staffelleiter") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = row.telefon, onValueChange = { onChange(row.copy(telefon = it)) }, label = { Text("Telefon") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = row.heimspieltag, onValueChange = { onChange(row.copy(heimspieltag = it)) }, label = { Text("Heimspieltag") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = row.spielsystem, onValueChange = { onChange(row.copy(spielsystem = it)) }, label = { Text("Spielsystem") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = row.mannschaftsfuehrer, onValueChange = { onChange(row.copy(mannschaftsfuehrer = it)) }, label = { Text("Mannschaftsführer") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = row.spieler, onValueChange = { onChange(row.copy(spieler = it)) }, label = { Text("Spieler") }, modifier = Modifier.fillMaxWidth(), minLines = 2)
|
||||
OutlinedTextField(value = row.informationenLink, onValueChange = { onChange(row.copy(informationenLink = it)) }, label = { Text("Weitere Informationen Link") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = row.letzteAktualisierung, onValueChange = { onChange(row.copy(letzteAktualisierung = it)) }, label = { Text("Letzte Aktualisierung") }, modifier = Modifier.fillMaxWidth())
|
||||
TextButton(onClick = onRemove, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Entfernen")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sportSpielplanCsvText(headers: List<String>, rows: List<List<String>>): String {
|
||||
if (headers.isEmpty()) return ""
|
||||
return listOf(headers).plus(rows).joinToString("\n") { row -> row.joinToString(";") { it.csvCell(";") } }
|
||||
}
|
||||
|
||||
private fun parseSportCsvText(text: String): Pair<List<String>, List<List<String>>> {
|
||||
val lines = text.lineSequence().filter { it.isNotBlank() }.toList()
|
||||
if (lines.isEmpty()) return emptyList<String>() to emptyList()
|
||||
val headers = parseDelimitedLine(lines.first(), ';')
|
||||
val rows = lines.drop(1).map { parseDelimitedLine(it, ';') }
|
||||
return headers to rows
|
||||
}
|
||||
|
||||
private fun parseDelimitedLine(line: String, delimiter: Char): List<String> {
|
||||
val values = mutableListOf<String>()
|
||||
val value = StringBuilder()
|
||||
var quoted = false
|
||||
var index = 0
|
||||
while (index < line.length) {
|
||||
when (val char = line[index]) {
|
||||
'"' -> {
|
||||
if (quoted && index + 1 < line.length && line[index + 1] == '"') {
|
||||
value.append('"')
|
||||
index++
|
||||
} else {
|
||||
quoted = !quoted
|
||||
}
|
||||
}
|
||||
delimiter -> if (quoted) value.append(char) else {
|
||||
values += value.toString()
|
||||
value.clear()
|
||||
}
|
||||
else -> value.append(char)
|
||||
}
|
||||
index++
|
||||
}
|
||||
values += value.toString()
|
||||
return values
|
||||
}
|
||||
|
||||
private fun String.csvCell(delimiter: String): String {
|
||||
val needsQuotes = contains(delimiter) || contains('"') || contains('\n') || contains('\r')
|
||||
val escaped = replace("\"", "\"\"")
|
||||
return if (needsQuotes) "\"$escaped\"" else escaped
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -555,7 +808,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 +845,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
|
||||
@@ -623,7 +876,7 @@ fun CmsNewsletterScreen(
|
||||
onEdit = { nl ->
|
||||
editingNewsletter = nl
|
||||
nlTitle = nl.title
|
||||
nlContent = nl.title ?: ""
|
||||
nlContent = nl.title
|
||||
nlType = "subscription"
|
||||
nlTargetGroup = ""
|
||||
nlSendToExternal = true
|
||||
@@ -753,6 +1006,12 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
|
||||
var websiteVorname by remember { mutableStateOf("") }
|
||||
var websiteNachname by remember { mutableStateOf("") }
|
||||
var websiteEmail by remember { mutableStateOf("") }
|
||||
var ortName by remember { mutableStateOf("") }
|
||||
var ortStrasse by remember { mutableStateOf("") }
|
||||
var ortPlz by remember { mutableStateOf("") }
|
||||
var ortOrt by remember { mutableStateOf("") }
|
||||
val trainingTimes = remember { mutableStateListOf<de.harheimertc.data.TrainingTimeDto>() }
|
||||
val trainers = remember { mutableStateListOf<de.harheimertc.data.TrainerDto>() }
|
||||
|
||||
LaunchedEffect(config) {
|
||||
config?.let {
|
||||
@@ -764,12 +1023,20 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
|
||||
websiteVorname = it.website.verantwortlicher.vorname
|
||||
websiteNachname = it.website.verantwortlicher.nachname
|
||||
websiteEmail = it.website.verantwortlicher.email
|
||||
ortName = it.training.ort.name
|
||||
ortStrasse = it.training.ort.strasse
|
||||
ortPlz = it.training.ort.plz
|
||||
ortOrt = it.training.ort.ort
|
||||
trainingTimes.clear()
|
||||
trainingTimes.addAll(it.training.zeiten)
|
||||
trainers.clear()
|
||||
trainers.addAll(it.trainer)
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -790,6 +1057,16 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
|
||||
email = websiteEmail,
|
||||
),
|
||||
),
|
||||
training = config.training.copy(
|
||||
ort = config.training.ort.copy(
|
||||
name = ortName,
|
||||
strasse = ortStrasse,
|
||||
plz = ortPlz,
|
||||
ort = ortOrt,
|
||||
),
|
||||
zeiten = trainingTimes.toList(),
|
||||
),
|
||||
trainer = trainers.toList(),
|
||||
),
|
||||
)
|
||||
},
|
||||
@@ -822,6 +1099,63 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
|
||||
OutlinedTextField(value = websiteEmail, onValueChange = { websiteEmail = it }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
item {
|
||||
DataCard("Trainingsort") {
|
||||
OutlinedTextField(value = ortName, onValueChange = { ortName = it }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = ortStrasse, onValueChange = { ortStrasse = it }, label = { Text("Straße") }, modifier = Modifier.fillMaxWidth())
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedTextField(value = ortPlz, onValueChange = { ortPlz = it }, label = { Text("PLZ") }, modifier = Modifier.weight(1f))
|
||||
OutlinedTextField(value = ortOrt, onValueChange = { ortOrt = it }, label = { Text("Ort") }, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
DataCard("Trainingszeiten") {
|
||||
trainingTimes.forEachIndexed { index, zeit ->
|
||||
TrainingTimeEditorCard(
|
||||
zeit = zeit,
|
||||
onChange = { updated -> trainingTimes[index] = updated },
|
||||
onRemove = { trainingTimes.removeAt(index) },
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
trainingTimes.add(
|
||||
de.harheimertc.data.TrainingTimeDto(
|
||||
id = "training-${System.currentTimeMillis()}",
|
||||
tag = "Montag",
|
||||
),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Trainingszeit hinzufügen")
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
DataCard("Trainer") {
|
||||
trainers.forEachIndexed { index, trainer ->
|
||||
TrainerEditorCard(
|
||||
trainer = trainer,
|
||||
onChange = { updated -> trainers[index] = updated },
|
||||
onRemove = { trainers.removeAt(index) },
|
||||
)
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
trainers.add(
|
||||
de.harheimertc.data.TrainerDto(
|
||||
id = "trainer-${System.currentTimeMillis()}",
|
||||
),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Trainer hinzufügen")
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
DataCard("Systemstatus") {
|
||||
InfoRow("Mitgliedschaftstarife", config.mitgliedschaft.size.toString())
|
||||
@@ -883,7 +1217,7 @@ fun CmsPasswordResetDiagnosticsScreen(navController: NavController, showBackNavi
|
||||
}
|
||||
|
||||
if (state.loading) {
|
||||
item { CircularProgressIndicator(color = Primary600) }
|
||||
item { LoadingState("Diagnosedaten werden geladen...") }
|
||||
}
|
||||
|
||||
if (state.passwordResetSearchTerm.isNotBlank()) {
|
||||
@@ -950,6 +1284,7 @@ private fun CmsSummaryGrid(navController: NavController, state: CmsUiState) {
|
||||
val cards = listOf(
|
||||
Triple("Startseite", "Öffentliche Startseite", Destinations.CmsStartseite.route),
|
||||
Triple("Inhalte", "Vereinsseiten", Destinations.CmsInhalte.route),
|
||||
Triple("News", "Interne und öffentliche News", Destinations.CmsNews.route),
|
||||
Triple("Sportbetrieb", "Training und Sportdaten", Destinations.CmsSportbetrieb.route),
|
||||
Triple("Mitgliederverwaltung", "${state.users.size} Benutzer", Destinations.CmsMitgliederverwaltung.route),
|
||||
Triple("Kontaktanfragen", "${state.contactRequests.size} Anfragen", Destinations.CmsContactRequests.route),
|
||||
@@ -1073,7 +1408,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
|
||||
@@ -12,6 +13,10 @@ import de.harheimertc.data.PasswordResetMatchingUserDto
|
||||
import de.harheimertc.data.PasswordResetAttemptDto
|
||||
import de.harheimertc.data.NewsDto
|
||||
import de.harheimertc.data.NewsSaveRequest
|
||||
import de.harheimertc.data.SeasonDto
|
||||
import de.harheimertc.data.SpielDto
|
||||
import de.harheimertc.data.TerminDto
|
||||
import de.harheimertc.repositories.CmsMannschaftRow
|
||||
import de.harheimertc.repositories.CmsRepository
|
||||
import de.harheimertc.repositories.MeisterschaftResult
|
||||
import kotlinx.coroutines.async
|
||||
@@ -38,17 +43,37 @@ data class CmsUiState(
|
||||
val passwordResetFailedOnly: Boolean = true,
|
||||
val news: List<NewsDto> = emptyList(),
|
||||
val meisterschaften: List<MeisterschaftResult> = emptyList(),
|
||||
val sportLoading: Boolean = false,
|
||||
val sportSaving: Boolean = false,
|
||||
val sportTermine: List<TerminDto> = emptyList(),
|
||||
val sportMannschaften: List<CmsMannschaftRow> = emptyList(),
|
||||
val sportMannschaftenSeasons: List<String> = emptyList(),
|
||||
val sportMannschaftenSeason: String = "",
|
||||
val sportSpielplanHeaders: List<String> = emptyList(),
|
||||
val sportSpielplanRows: List<List<String>> = emptyList(),
|
||||
val sportSpielplanSeason: String = "",
|
||||
val sportSpielplanSeasons: List<SeasonDto> = emptyList(),
|
||||
)
|
||||
|
||||
@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() {
|
||||
@@ -168,6 +193,156 @@ class CmsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun loadSportbetrieb() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(sportLoading = true, error = null, message = null)
|
||||
|
||||
val termineRes = async { repository.managedTermine() }
|
||||
val seasonsRes = async { repository.mannschaftenSeasons() }
|
||||
val spielplanRes = async { repository.spielplan() }
|
||||
|
||||
val termineResult = termineRes.await()
|
||||
val seasonsResult = seasonsRes.await()
|
||||
val seasonInfo = seasonsResult.getOrNull()
|
||||
val selectedSeason = _state.value.sportMannschaftenSeason.takeIf { it.isNotBlank() }
|
||||
?: seasonInfo?.defaultSeason?.takeIf { it.isNotBlank() }
|
||||
?: seasonInfo?.currentSeason?.takeIf { it.isNotBlank() }
|
||||
?: seasonInfo?.seasons?.firstOrNull().orEmpty()
|
||||
val mannschaftenResult = repository.mannschaften(selectedSeason.takeIf { it.isNotBlank() })
|
||||
val spielplanResult = spielplanRes.await()
|
||||
val errors = listOfNotNull(
|
||||
ErrorMapper.mapError(termineResult.exceptionOrNull()),
|
||||
ErrorMapper.mapError(seasonsResult.exceptionOrNull()),
|
||||
ErrorMapper.mapError(mannschaftenResult.exceptionOrNull()),
|
||||
ErrorMapper.mapError(spielplanResult.exceptionOrNull()),
|
||||
)
|
||||
val spielplan = spielplanResult.getOrNull()
|
||||
val headers = spielplan?.headers.orEmpty()
|
||||
|
||||
_state.value = _state.value.copy(
|
||||
sportLoading = false,
|
||||
sportTermine = termineResult.getOrNull().orEmpty(),
|
||||
sportMannschaften = mannschaftenResult.getOrNull().orEmpty(),
|
||||
sportMannschaftenSeasons = seasonInfo?.seasons.orEmpty(),
|
||||
sportMannschaftenSeason = selectedSeason,
|
||||
sportSpielplanHeaders = headers,
|
||||
sportSpielplanRows = spielplan?.data.orEmpty().map { row -> headers.map { header -> row.valueForHeader(header) } },
|
||||
sportSpielplanSeason = spielplan?.season.orEmpty(),
|
||||
sportSpielplanSeasons = spielplan?.seasons.orEmpty(),
|
||||
error = errors.takeIf { it.isNotEmpty() }?.joinToString("; "),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadSportMannschaftenSeason(season: String) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(sportLoading = true, error = null, message = null, sportMannschaftenSeason = season)
|
||||
repository.mannschaften(season)
|
||||
.onSuccess { rows ->
|
||||
_state.value = _state.value.copy(sportLoading = false, sportMannschaften = rows)
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(
|
||||
sportLoading = false,
|
||||
error = ErrorMapper.mapError(err) ?: "Mannschaften konnten nicht geladen werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveSportTermin(original: TerminDto?, termin: TerminDto) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
||||
if (original != null) {
|
||||
repository.deleteTermin(original)
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(
|
||||
sportSaving = false,
|
||||
error = ErrorMapper.mapError(err) ?: "Alter Termin konnte nicht ersetzt werden.",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
val saveResult = repository.saveTermin(termin)
|
||||
saveResult
|
||||
.onSuccess { response ->
|
||||
val termine = repository.managedTermine().getOrDefault(_state.value.sportTermine)
|
||||
_state.value = _state.value.copy(
|
||||
sportSaving = false,
|
||||
sportTermine = termine,
|
||||
message = response.message ?: "Termin gespeichert.",
|
||||
)
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(
|
||||
sportSaving = false,
|
||||
error = ErrorMapper.mapError(err) ?: "Termin konnte nicht gespeichert werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteSportTermin(termin: TerminDto) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
||||
repository.deleteTermin(termin)
|
||||
.onSuccess { response ->
|
||||
_state.value = _state.value.copy(
|
||||
sportSaving = false,
|
||||
sportTermine = _state.value.sportTermine.filterNot { it == termin },
|
||||
message = response.message ?: "Termin gelöscht.",
|
||||
)
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(
|
||||
sportSaving = false,
|
||||
error = ErrorMapper.mapError(err) ?: "Termin konnte nicht gelöscht werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveSportMannschaften(season: String, rows: List<CmsMannschaftRow>) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
||||
repository.saveMannschaften(season.takeIf { it.isNotBlank() }, rows)
|
||||
.onSuccess { response ->
|
||||
_state.value = _state.value.copy(
|
||||
sportSaving = false,
|
||||
sportMannschaften = rows,
|
||||
message = response.message ?: "Mannschaften gespeichert.",
|
||||
)
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(
|
||||
sportSaving = false,
|
||||
error = ErrorMapper.mapError(err) ?: "Mannschaften konnten nicht gespeichert werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveSportSpielplan(headers: List<String>, rows: List<List<String>>) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(sportSaving = true, error = null, message = null)
|
||||
repository.saveSpielplan(headers, rows)
|
||||
.onSuccess { response ->
|
||||
_state.value = _state.value.copy(
|
||||
sportSaving = false,
|
||||
sportSpielplanHeaders = headers,
|
||||
sportSpielplanRows = rows,
|
||||
message = response.message ?: "Spielplan gespeichert.",
|
||||
)
|
||||
}
|
||||
.onFailure { err ->
|
||||
_state.value = _state.value.copy(
|
||||
sportSaving = false,
|
||||
error = ErrorMapper.mapError(err) ?: "Spielplan konnte nicht gespeichert werden.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveConfig(config: ConfigResponse) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = null, message = null)
|
||||
@@ -207,7 +382,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 +404,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 +426,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 +437,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)
|
||||
@@ -451,3 +626,18 @@ class CmsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SpielDto.valueForHeader(header: String): String = when (header) {
|
||||
"Termin" -> termin
|
||||
"HeimMannschaft" -> heimMannschaft
|
||||
"GastMannschaft" -> gastMannschaft
|
||||
"HeimMannschaftAltersklasse" -> heimAltersklasse
|
||||
"GastMannschaftAltersklasse" -> gastAltersklasse
|
||||
"Altersklasse" -> altersklasse
|
||||
"Liga" -> liga
|
||||
"Staffel" -> staffel
|
||||
"Runde" -> runde.orEmpty()
|
||||
"SpieleHeim" -> spieleHeim
|
||||
"SpieleGast" -> spieleGast
|
||||
else -> ""
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
if (!imageUrl.isNullOrBlank()) {
|
||||
AsyncImage(
|
||||
model = "${BuildConfig.API_BASE_URL}images/club_about_us.png",
|
||||
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,8 +88,9 @@ class HomeViewModel @Inject constructor(
|
||||
)
|
||||
_state.value = HomeUiState(
|
||||
loading = false,
|
||||
heroImageUrl = data.heroImageUrl,
|
||||
termine = data.termine
|
||||
.filter { it.asDateTime()?.isBefore(LocalDateTime.now()) != true }
|
||||
.filter { it.asDateTime()?.toLocalDate()?.isBefore(LocalDate.now()) != true }
|
||||
.sortedBy { it.asDateTime() }
|
||||
.take(3),
|
||||
spiele = data.spiele
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ data class RegisterFormState(
|
||||
val birthDate: String = "",
|
||||
val password: String = "",
|
||||
val passwordRepeat: String = "",
|
||||
val showBirthday: Boolean = true,
|
||||
val showBirthday: Boolean = false,
|
||||
)
|
||||
|
||||
data class RegisterUiState(
|
||||
|
||||
@@ -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,286 @@
|
||||
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))
|
||||
}
|
||||
OwnTeamInfo(state.ownTeams, state.currentUserName)
|
||||
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 OwnTeamInfo(ownTeams: List<Mannschaft>, currentUserName: String) {
|
||||
val text = when {
|
||||
ownTeams.isNotEmpty() -> "Erkannte eigene Mannschaft: " + ownTeams.joinToString { it.mannschaft }
|
||||
currentUserName.isBlank() -> "Eigene Mannschaft kann erst nach geladenem Profil ermittelt werden."
|
||||
else -> "Keine eigene Mannschaft für " + currentUserName + " erkannt."
|
||||
}
|
||||
Text(text, color = Accent700)
|
||||
}
|
||||
|
||||
@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,143 @@
|
||||
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.LoginRepository
|
||||
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 ownTeams: List<Mannschaft> = emptyList(),
|
||||
val currentUserName: String = "",
|
||||
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,
|
||||
private val loginRepository: LoginRepository,
|
||||
) : 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 authStatus = loginRepository.status().getOrNull()
|
||||
val currentUserName = authStatus?.user?.name.orEmpty()
|
||||
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, currentUserName)
|
||||
}
|
||||
}
|
||||
|
||||
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, _state.value.currentUserName, 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>, currentUserName: 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,
|
||||
ownTeams = ownTeamsForUser(currentUserName, teams),
|
||||
currentUserName = currentUserName,
|
||||
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,
|
||||
currentUserName = currentUserName,
|
||||
seasons = seasons,
|
||||
error = error.message ?: "Mannschaften konnten nicht geladen werden.",
|
||||
saveError = saveError,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ownTeamsForUser(userName: String, teams: List<Mannschaft>): List<Mannschaft> {
|
||||
if (normalizePersonName(userName).isBlank()) return emptyList()
|
||||
return teams.filter { team ->
|
||||
team.spieler.any { player -> personNameMatches(player, userName) } ||
|
||||
personNameMatches(team.mannschaftsfuehrer, userName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun personNameMatches(candidate: String, userName: String): Boolean {
|
||||
val normalizedCandidate = normalizePersonName(candidate)
|
||||
val normalizedUserName = normalizePersonName(userName)
|
||||
if (normalizedCandidate.isBlank() || normalizedUserName.isBlank()) return false
|
||||
if (normalizedCandidate == normalizedUserName) return true
|
||||
|
||||
val candidateParts = normalizedCandidate.split(" " ).filter { it.isNotBlank() }.toSet()
|
||||
val userParts = normalizedUserName.split(" " ).filter { it.isNotBlank() }
|
||||
return userParts.size >= 2 && userParts.all { it in candidateParts }
|
||||
}
|
||||
|
||||
private fun normalizePersonName(value: String): String = value
|
||||
.lowercase()
|
||||
.replace(Regex("[^a-z0-9äöüß]+"), " ")
|
||||
.trim()
|
||||
@@ -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 {
|
||||
|
||||
@@ -24,7 +24,7 @@ data class ProfileFormState(
|
||||
val showEmail: Boolean = true,
|
||||
val showPhone: Boolean = true,
|
||||
val showAddress: Boolean = false,
|
||||
val showBirthday: Boolean = true,
|
||||
val showBirthday: Boolean = false,
|
||||
val currentPassword: String = "",
|
||||
val newPassword: String = "",
|
||||
val confirmPassword: String = "",
|
||||
|
||||
@@ -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>
|
||||
@@ -2,11 +2,13 @@ package de.harheimertc.ui.screens.cms
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -27,6 +29,12 @@ class CmsViewModelTest {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun viewModel(repo: de.harheimertc.repositories.CmsRepository): CmsViewModel {
|
||||
val connectivity = mockk<de.harheimertc.data.ConnectivityMonitor>()
|
||||
every { connectivity.online } returns MutableStateFlow(true)
|
||||
return CmsViewModel(repo, connectivity)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun load_populatesState() = runTest {
|
||||
val repo = mockk<de.harheimertc.repositories.CmsRepository>()
|
||||
@@ -37,11 +45,11 @@ class CmsViewModelTest {
|
||||
coEvery { repo.contactRequests() } returns Result.success(emptyList())
|
||||
coEvery { repo.newsletters() } returns Result.success(de.harheimertc.data.NewsletterListResponse())
|
||||
coEvery { repo.newsletterGroups() } returns Result.success(de.harheimertc.data.NewsletterGroupsResponse())
|
||||
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf(de.harheimertc.data.NewsDto(id = 5, title = "T", content = "C"))))
|
||||
coEvery { repo.news() } returns Result.success(de.harheimertc.data.NewsResponse(success = true, news = listOf(de.harheimertc.data.NewsDto(id = "5", title = "T", content = "C"))))
|
||||
coEvery { repo.passwordResetDiagnostics(any(), any()) } returns Result.success(de.harheimertc.data.PasswordResetDiagnosticsResponse())
|
||||
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
|
||||
|
||||
val vm = CmsViewModel(repo)
|
||||
val vm = viewModel(repo)
|
||||
// advance init launched coroutine
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
@@ -66,7 +74,7 @@ class CmsViewModelTest {
|
||||
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
|
||||
coEvery { repo.saveConfig(any()) } returns Result.success(cfg)
|
||||
coEvery { repo.saveNews(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "ok"))
|
||||
val vm = CmsViewModel(repo)
|
||||
val vm = viewModel(repo)
|
||||
|
||||
// wait for init/load to finish before saving to avoid race
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
@@ -95,7 +103,7 @@ class CmsViewModelTest {
|
||||
coEvery { repo.vereinsmeisterschaften() } returns Result.success(emptyList())
|
||||
coEvery { repo.saveNews(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "saved"))
|
||||
|
||||
val vm = CmsViewModel(repo)
|
||||
val vm = viewModel(repo)
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
vm.saveNews(de.harheimertc.data.NewsSaveRequest(id = null, title = "t", content = "c"))
|
||||
@@ -122,7 +130,7 @@ class CmsViewModelTest {
|
||||
coEvery { repo.updateUserRoles(any(), any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "roles updated"))
|
||||
coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "1", email = "u@e", name = "U", roles = listOf("admin", "vorstand")))))
|
||||
|
||||
val vm = CmsViewModel(repo)
|
||||
val vm = viewModel(repo)
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
vm.updateUserRoles("1", listOf("admin", "vorstand"))
|
||||
@@ -150,7 +158,7 @@ class CmsViewModelTest {
|
||||
coEvery { repo.updateUserActive(any(), any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "user updated"))
|
||||
coEvery { repo.users() } returns Result.success(de.harheimertc.data.CmsUsersResponse(listOf(de.harheimertc.data.CmsUserDto(id = "2", email = "v@e", name = "V", active = false))))
|
||||
|
||||
val vm = CmsViewModel(repo)
|
||||
val vm = viewModel(repo)
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
vm.setUserActive("2", false)
|
||||
@@ -177,7 +185,7 @@ class CmsViewModelTest {
|
||||
|
||||
coEvery { repo.resendInvite(any()) } returns Result.success(de.harheimertc.data.AuthMessageResponse(success = true, message = "invite sent"))
|
||||
|
||||
val vm = CmsViewModel(repo)
|
||||
val vm = viewModel(repo)
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
vm.resendInvite("10")
|
||||
|
||||
@@ -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=26
|
||||
ANDROID_VERSION_NAME=0.9.21
|
||||
|
||||
# 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
|
||||
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>
|
||||
|
||||
<!-- News Modal -->
|
||||
<Teleport to="body">
|
||||
<Transition name="news-modal">
|
||||
<div
|
||||
v-if="selectedNews"
|
||||
class="fixed inset-0 z-50 bg-black bg-opacity-50 flex items-center justify-center p-4"
|
||||
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"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
<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 class="text-2xl font-display font-bold text-gray-900">
|
||||
<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
|
||||
class="ml-4 p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
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="24" />
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Modal Content (scrollable) -->
|
||||
<div class="p-6 overflow-y-auto flex-1">
|
||||
<div class="prose max-w-none text-gray-700 whitespace-pre-wrap">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -550,6 +550,25 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
id="showBirthday"
|
||||
v-model="formData.showBirthday"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
:disabled="isSaving || !canDisableBirthdayVisibility"
|
||||
>
|
||||
<label
|
||||
for="showBirthday"
|
||||
class="ml-2 block text-sm font-medium text-gray-700"
|
||||
>
|
||||
Geburtstag in Mitgliederliste und Benachrichtigungen anzeigen
|
||||
</label>
|
||||
</div>
|
||||
<p class="-mt-3 text-xs text-gray-500">
|
||||
Admins und Vorstand können die Sichtbarkeit nur ausschalten. Einschalten kann nur das Mitglied selbst im Profil.
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="flex items-center p-3 rounded-md bg-red-50 text-red-700 text-sm"
|
||||
@@ -846,7 +865,8 @@ const formData = ref({
|
||||
address: '',
|
||||
notes: '',
|
||||
isMannschaftsspieler: false,
|
||||
hasHallKey: false
|
||||
hasHallKey: false,
|
||||
showBirthday: false
|
||||
})
|
||||
|
||||
const canEdit = computed(() => {
|
||||
@@ -861,6 +881,10 @@ const isBirthdateRequired = computed(() => {
|
||||
return !editingMember.value || Boolean(editingMember.value?.geburtsdatum)
|
||||
})
|
||||
|
||||
const canDisableBirthdayVisibility = computed(() => {
|
||||
return editingMember.value?.showBirthday === true
|
||||
})
|
||||
|
||||
const filteredMembers = computed(() => {
|
||||
if (!filterHasHallKey.value) return members.value
|
||||
return members.value.filter(member => member.hasHallKey)
|
||||
@@ -880,7 +904,7 @@ const loadMembers = async () => {
|
||||
|
||||
const openAddModal = () => {
|
||||
editingMember.value = null
|
||||
formData.value = { firstName: '', lastName: '', geburtsdatum: '', email: '', phone: '', address: '', notes: '', isMannschaftsspieler: false, hasHallKey: false }
|
||||
formData.value = { firstName: '', lastName: '', geburtsdatum: '', email: '', phone: '', address: '', notes: '', isMannschaftsspieler: false, hasHallKey: false, showBirthday: false }
|
||||
showModal.value = true
|
||||
errorMessage.value = ''
|
||||
}
|
||||
@@ -896,7 +920,8 @@ const openEditModal = (member) => {
|
||||
address: member.address || '',
|
||||
notes: member.notes || '',
|
||||
isMannschaftsspieler: member.isMannschaftsspieler === true,
|
||||
hasHallKey: member.hasHallKey === true
|
||||
hasHallKey: member.hasHallKey === true,
|
||||
showBirthday: member.showBirthday === true
|
||||
}
|
||||
showModal.value = true
|
||||
errorMessage.value = ''
|
||||
@@ -914,7 +939,14 @@ const saveMember = async () => {
|
||||
try {
|
||||
await $fetch('/api/members', {
|
||||
method: 'POST',
|
||||
body: { id: editingMember.value?.id, ...formData.value }
|
||||
body: {
|
||||
id: editingMember.value?.id,
|
||||
...formData.value,
|
||||
visibility: {
|
||||
...(editingMember.value?.visibility || {}),
|
||||
showBirthday: formData.value.showBirthday === true
|
||||
}
|
||||
}
|
||||
})
|
||||
closeModal()
|
||||
await loadMembers()
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
<p class="text-sm font-medium text-green-800">
|
||||
{{ currentFile.name }}
|
||||
</p><p class="text-xs text-green-600">
|
||||
{{ currentFile.size }} bytes
|
||||
{{ currentFileLabel }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -368,7 +368,7 @@ const processFile = async (file) => {
|
||||
const parseCSVLine = (line) => { const tabCount = (line.match(/\t/g) || []).length; const semicolonCount = (line.match(/;/g) || []).length; const delimiter = tabCount > semicolonCount ? '\t' : ';'; return line.split(delimiter).map(value => value.trim()) }
|
||||
csvHeaders.value = parseCSVLine(lines[0]); csvData.value = lines.slice(1).map(line => parseCSVLine(line))
|
||||
selectedColumns.value = new Array(csvHeaders.value.length).fill(true); columnsSelected.value = false
|
||||
currentFile.value = { name: file.name, size: file.size, lastModified: file.lastModified }
|
||||
currentFile.value = { name: file.name, size: file.size, entries: csvData.value.length, lastModified: file.lastModified }
|
||||
processingMessage.value = 'Verarbeitung abgeschlossen!'
|
||||
setTimeout(() => { isProcessing.value = false; showUploadModal.value = false }, 1000)
|
||||
} catch (error) { console.error('Fehler:', error); alert('Fehler: ' + error.message); isProcessing.value = false }
|
||||
@@ -377,6 +377,11 @@ const processFile = async (file) => {
|
||||
const processSelectedFile = () => { if (selectedFile.value) processFile(selectedFile.value) }
|
||||
const removeFile = () => { currentFile.value = null; csvData.value = []; csvHeaders.value = []; selectedColumns.value = []; columnsSelected.value = false; filteredCsvData.value = []; filteredCsvHeaders.value = []; if (fileInput.value) fileInput.value.value = '' }
|
||||
const selectedColumnsCount = computed(() => selectedColumns.value.filter(s => s).length)
|
||||
const currentFileLabel = computed(() => {
|
||||
if (!currentFile.value) return ''
|
||||
if (typeof currentFile.value.entries === 'number') return `${currentFile.value.entries} Einträge`
|
||||
return `${currentFile.value.size} bytes`
|
||||
})
|
||||
const getColumnPreview = (index) => { if (csvData.value.length === 0) return 'Keine Daten'; const sv = csvData.value.slice(0, 3).map(row => row[index]).filter(val => val && val.trim() !== ''); return sv.length > 0 ? `Beispiel: ${sv.join(', ')}` : 'Leere Spalte' }
|
||||
const selectAllColumns = () => { selectedColumns.value = selectedColumns.value.map(() => true) }
|
||||
const deselectAllColumns = () => { selectedColumns.value = selectedColumns.value.map(() => false) }
|
||||
@@ -415,7 +420,7 @@ onMounted(() => {
|
||||
csvHeaders.value = result.headers
|
||||
csvData.value = result.data.map(row => csvHeaders.value.map(header => row[header] || ''))
|
||||
selectedColumns.value = new Array(csvHeaders.value.length).fill(true)
|
||||
currentFile.value = { name: result.season ? `spielplan-${result.season}.json` : 'spielplan.csv', size: csvData.value.length, lastModified: null }
|
||||
currentFile.value = { name: result.season ? `spielplan-${result.season}.json` : 'spielplan.csv', entries: csvData.value.length, lastModified: null }
|
||||
} catch { /* ignore */ }
|
||||
})()
|
||||
})
|
||||
|
||||