22 Commits

Author SHA1 Message Date
Torsten Schulz (local)
5da11d2e4d Fix in news, first android notification service
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 7m50s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-06-10 13:47:33 +02:00
Torsten Schulz (local)
e8a50e55ca Fix Mannschaften 2026-06-10 08:03:44 +02:00
Torsten Schulz (local)
530e544542 Implemented the possibility ofa hidden user for playstore tests
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m40s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-06-09 11:32:00 +02:00
Torsten Schulz (local)
300dce9835 Paßwort vergessen modernisiert
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 6m5s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-06-09 10:31:32 +02:00
Torsten Schulz (local)
a98def915e bugfixing
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 8m1s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-06-09 09:59:32 +02:00
Torsten Schulz (local)
7aa7970f2e Android client updated
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m45s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-06-05 08:34:49 +02:00
Torsten Schulz (local)
e517720b03 Implement network retry mechanism across repositories and add connectivity monitoring
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m39s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
- Introduced `retryOnNetworkFailure` function to handle network-related exceptions and retry requests.
- Updated `GalleryRepository`, `HomeRepository`, `LoginRepository`, `MannschaftenRepository`, `NewsletterRepository`, `PasskeyRepository`, `ProfileRepository`, `PublicPagesRepository`, `SpielplanRepository`, `TermineRepository`, and `TrainingRepository` to use the new retry mechanism.
- Added `ConnectivityMonitor` to track internet connectivity status and notify UI components.
- Enhanced `NavigationViewModel`, `CmsViewModel`, `MembersViewModel`, and `MemberAreaViewModel` to reload data when connectivity is restored.
- Bumped app version to 0.9.16.
2026-06-04 22:15:44 +02:00
Torsten Schulz (local)
402913d877 feat: update navigation logic to manage section overrides in WebTabletNavigation
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 7m46s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-06-04 22:05:50 +02:00
Torsten Schulz (local)
2014abe660 Add unit tests for data file rotation utility functions
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m24s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
- Implement tests for writing data files with rotation, ensuring backups are created only on changes.
- Verify that old backups are rotated correctly and the maximum number of backups is maintained.
- Test restoration of backups while preserving the current state as a backup.
- Utilize Vitest for testing framework and manage temporary file storage during tests.
2026-06-01 11:21:21 +02:00
Torsten Schulz (local)
80834d8652 Add UI XML files for current and initial app layout
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m26s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m59s
- Created `ui-current.xml` to represent the current state of the app's UI hierarchy.
- Created `ui.xml` to represent the initial state of the app's UI hierarchy.
2026-06-01 10:46:39 +02:00
Torsten Schulz (local)
7bc98c03e4 feat: update Hero component styles and enhance index page layout with dynamic sections
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m45s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m0s
2026-05-31 15:04:24 +02:00
Torsten Schulz (local)
bf1caefde4 feat: update security headers and improve content security policy; enhance hero image component and loading states in public news
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m31s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m1s
2026-05-31 14:19:15 +02:00
Torsten Schulz (local)
6983186caf feat: add hero image processing and API for serving variants
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 5m44s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
- Introduced a new script `prepare-hero-variants.mjs` to generate responsive hero image variants in WebP format.
- Added a fallback image `hero_fallback.png` for each variant.
- Created an API endpoint `hero-images.get.js` to retrieve available hero image variants and their fallback images.
- Implemented directory and file checks to ensure the existence of required images before serving.
2026-05-31 14:07:14 +02:00
Torsten Schulz (local)
7c93966878 feat: add robots.txt and sitemap.xml routes for SEO optimization
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m44s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m15s
- Implemented a new route for robots.txt to control crawler access.
- Added a sitemap.xml route to provide search engines with a list of site URLs.
- Included functions for URL normalization and XML escaping to ensure proper formatting.
2026-05-31 13:36:49 +02:00
Torsten Schulz (local)
31d20f1bff feat: enhance CompactNavigation with CMS submenu and toggle functionality
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m39s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m57s
2026-05-30 23:51:41 +02:00
Torsten Schulz (local)
6507afea5f feat: add QTTR values feature to member area
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 5m49s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m7s
- Implemented QTTR values screen in the member area with data fetching and display.
- Added new API endpoint for QTTR values retrieval.
- Created a new view model for managing QTTR data state.
- Updated navigation to include QTTR section.
- Enhanced error handling and loading states for QTTR data.
- Adjusted server-side logic to import QTTR values from external source.
- Updated Android app version and adjusted build configurations.
- Added necessary UI components and styling for QTTR display.
2026-05-30 23:43:06 +02:00
Torsten Schulz (local)
387ce6e08e feat: update ProGuard rules and enhance typography for member area screens
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m14s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m3s
2026-05-30 01:24:39 +02:00
Torsten Schulz (local)
f822fc8a8e feat: update typography styles and enhance text appearance in navigation components
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m8s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 1m57s
2026-05-30 00:46:00 +02:00
Torsten Schulz (local)
67c746f18b Add script to generate Play Store screenshot sizes
Some checks failed
Code Analysis and Production Deploy / deploy-production (push) Has been cancelled
Code Analysis and Production Deploy / deploy-test (push) Has been cancelled
Code Analysis and Production Deploy / analyze (push) Has been cancelled
- Introduced a Node.js script (`playstore-screenshot-sizes.mjs`) to resize images for Play Store screenshots based on predefined profiles (phone, tablet-7, tablet-10).
- The script reads images from a specified input directory, processes them, and saves the resized images in an output directory with appropriate naming conventions.
- Added a Bash wrapper script (`playstore-screenshot-sizes.sh`) to execute the Node.js script easily from the command line.
2026-05-30 00:30:50 +02:00
Torsten Schulz (local)
1e65cb47da fix: fallback to npm install on npm ci failure due to lockfile drift
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 7m31s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m15s
2026-05-29 17:14:11 +02:00
Torsten Schulz (local)
ec96e21517 feat: enhance code analysis workflow with debugging information and workspace cleanup
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 4m1s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-29 17:07:36 +02:00
Torsten Schulz (local)
46f80df165 chore: update version to 1.7.0 in package-lock.json
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m11s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped
2026-05-29 17:02:33 +02:00
196 changed files with 5423 additions and 660 deletions

View File

@@ -6,10 +6,27 @@ on:
jobs:
analyze:
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
clean: true
fetch-depth: 0
- name: Ensure clean workspace
run: |
git reset --hard HEAD
git clean -fdx
- name: Debug dependency files
run: |
echo "commit: $(git rev-parse HEAD)"
echo "branch ref: ${GITHUB_REF:-unknown}"
echo "package.json checksum:" && sha256sum package.json
echo "package-lock.json checksum:" && sha256sum package-lock.json
echo "eslint entries in package.json:" && rg '"eslint"' package.json || true
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -72,7 +89,11 @@ jobs:
rm -f gitleaks.tar.gz
- name: Install dependencies
run: npm ci
run: |
if ! npm ci; then
echo "WARNING: npm ci fehlgeschlagen (Lockfile-Drift?). Fallback auf npm install."
npm install
fi
- name: Lint
run: npm run lint
@@ -98,9 +119,8 @@ jobs:
./osv-scanner --lockfile ./package-lock.json
deploy-production:
needs: analyze
runs-on: ubuntu-latest
if: success() && github.event_name == 'push' && github.ref == 'refs/heads/main'
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Prepare SSH
run: |

1
.gitignore vendored
View File

@@ -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

View File

@@ -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`

View File

@@ -1,3 +1,5 @@
import java.util.Properties
plugins {
id("com.android.application")
id("com.google.devtools.ksp")
@@ -5,12 +7,17 @@ plugins {
id("com.google.dagger.hilt.android")
}
if (file("google-services.json").exists()) {
apply(plugin = "com.google.gms.google-services")
}
val localApiBaseUrl = providers.gradleProperty("LOCAL_API_BASE_URL")
.orElse("https://harheimertc.tsschulz.de/")
.get()
val productionApiBaseUrl = providers.gradleProperty("PRODUCTION_API_BASE_URL")
.orElse("https://harheimertc.de/")
.get()
val expectedProductionApiBaseUrl = "https://harheimertc.de/"
val sentryDsn = providers.gradleProperty("SENTRY_DSN")
.orElse("")
.get()
@@ -25,15 +32,50 @@ val releaseMinifyEnabled = providers.gradleProperty("RELEASE_MINIFY_ENABLED")
.orElse("true")
.get()
.toBoolean()
val releaseStoreFile = providers.gradleProperty("RELEASE_STORE_FILE").orNull
val releaseStorePassword = providers.gradleProperty("RELEASE_STORE_PASSWORD").orNull
val releaseKeyAlias = providers.gradleProperty("RELEASE_KEY_ALIAS").orNull
val releaseKeyPassword = providers.gradleProperty("RELEASE_KEY_PASSWORD").orNull
val localSigningProperties = Properties().apply {
val localSigningFile = rootProject.file("gradle-local.properties")
if (localSigningFile.exists()) {
localSigningFile.inputStream().use { load(it) }
}
}
fun signingProperty(name: String): String? =
providers.gradleProperty(name).orNull
?: providers.environmentVariable(name).orNull
?: localSigningProperties.getProperty(name)
val releaseStoreFile = signingProperty("RELEASE_STORE_FILE")
val releaseStorePassword = signingProperty("RELEASE_STORE_PASSWORD")
val releaseKeyAlias = signingProperty("RELEASE_KEY_ALIAS")
val releaseKeyPassword = signingProperty("RELEASE_KEY_PASSWORD")
val hasReleaseSigning = !releaseStoreFile.isNullOrBlank() &&
!releaseStorePassword.isNullOrBlank() &&
!releaseKeyAlias.isNullOrBlank() &&
!releaseKeyPassword.isNullOrBlank()
val ensureReleaseSigning = tasks.register("ensureReleaseSigning") {
doFirst {
if (!hasReleaseSigning) {
throw GradleException(
"Production release signing is not configured. " +
"Set RELEASE_STORE_FILE, RELEASE_STORE_PASSWORD, RELEASE_KEY_ALIAS and RELEASE_KEY_PASSWORD " +
"(e.g. via ~/.gradle/gradle.properties, environment variables, or android-app/gradle-local.properties)."
)
}
}
}
val ensureProductionApiBaseUrl = tasks.register("ensureProductionApiBaseUrl") {
doFirst {
if (productionApiBaseUrl != expectedProductionApiBaseUrl) {
throw GradleException(
"Production Play Store builds must use $expectedProductionApiBaseUrl, but PRODUCTION_API_BASE_URL is $productionApiBaseUrl."
)
}
}
}
android {
namespace = "de.harheimertc"
compileSdk = 35
@@ -135,6 +177,8 @@ val packageNativeDebugSymbolsForProductionRelease = tasks.register<Zip>("package
val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
group = "distribution"
description = "Builds production release artifacts and collects AAB, mapping, and native symbols for Play Console upload."
dependsOn(ensureReleaseSigning)
dependsOn(ensureProductionApiBaseUrl)
dependsOn(":app:bundleProductionRelease")
dependsOn(packageNativeDebugSymbolsForProductionRelease)
@@ -161,6 +205,13 @@ val collectPlayStoreArtifacts = tasks.register("collectPlayStoreArtifacts") {
}
}
tasks.matching {
it.name in setOf("bundleProductionRelease", "assembleProductionRelease")
}.configureEach {
dependsOn(ensureReleaseSigning)
dependsOn(ensureProductionApiBaseUrl)
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
@@ -206,6 +257,9 @@ dependencies {
// Crash reporting
implementation("io.sentry:sentry-android:8.42.0")
// Push notifications
implementation("com.google.firebase:firebase-messaging:25.0.2")
// Room
implementation("androidx.room:room-runtime:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
@@ -215,6 +269,7 @@ dependencies {
implementation("androidx.work:work-runtime-ktx:2.8.1")
implementation("androidx.datastore:datastore-preferences:1.0.0")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.7.3")
// Testing (skeleton)
testImplementation("junit:junit:4.13.2")

View File

@@ -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 { *; }

View File

@@ -2,6 +2,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".HarheimerApplication"
@@ -16,6 +17,13 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".notifications.HarheimerMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.files"

View File

@@ -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

View File

@@ -1,13 +1,17 @@
package de.harheimertc
import android.Manifest
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.compose.setContent
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.navigation.compose.rememberNavController
import de.harheimertc.ui.navigation.NavGraph
import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.notifications.HarheimerNotifications
import de.harheimertc.ui.theme.HarheimerTheme
import androidx.hilt.navigation.compose.hiltViewModel
import de.harheimertc.ui.navigation.NavigationViewModel
@@ -17,12 +21,25 @@ import androidx.compose.ui.platform.LocalContext
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted ->
Log.i("NOTIFICATIONS", "POST_NOTIFICATIONS granted=$granted")
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestNotificationPermissionIfNeeded()
setContent {
App()
}
}
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !HarheimerNotifications.hasNotificationPermission(this)) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
@Composable

View File

@@ -37,6 +37,12 @@ data class SpielplanResponse(
val seasons: List<SeasonDto> = emptyList(),
)
data class SeasonDto(val slug: String = "", val label: String = "")
data class MannschaftenSeasonsResponse(
val success: Boolean = false,
val seasons: List<String> = emptyList(),
val currentSeason: String = "",
val defaultSeason: String = "",
)
data class SpielDto(
@param:Json(name = "Termin") val termin: String = "",
@param:Json(name = "HeimMannschaft") val heimMannschaft: String = "",
@@ -81,7 +87,7 @@ data class LeagueTableRowDto(
)
data class NewsPublicResponse(val news: List<NewsDto> = emptyList())
data class NewsDto(
val id: Int? = null,
val id: String? = null,
val title: String = "",
val content: String = "",
val created: String? = null,
@@ -96,7 +102,7 @@ data class NewsResponse(
val news: List<NewsDto> = emptyList(),
)
data class NewsSaveRequest(
val id: Int? = null,
val id: String? = null,
val title: String,
val content: String,
val isPublic: Boolean = false,
@@ -251,6 +257,30 @@ data class ProfileUpdateRequest(
val currentPassword: String? = null,
val newPassword: String? = null,
)
data class NotificationSettingsDto(
val newNews: Boolean = false,
val newEvents: Boolean = false,
val eventsToday: Boolean = false,
val eventsTomorrow: Boolean = false,
val ownTeamMatches: Boolean = false,
val allTeamMatches: Boolean = false,
val birthdays: Boolean = false,
val newContactRequest: Boolean = false,
val newUserRegistration: Boolean = false,
val selectedTeamSlugs: List<String> = emptyList(),
val selectedTeamSeason: String? = null,
val notificationTime: String = "09:00",
)
data class NotificationSettingsResponse(
val success: Boolean = false,
val message: String? = null,
val settings: NotificationSettingsDto = NotificationSettingsDto(),
)
data class PushTokenRequest(
val token: String,
val platform: String = "android",
val appVersion: String? = null,
)
data class BirthdayDto(
val name: String = "",
val dayMonth: String = "",
@@ -260,6 +290,28 @@ data class BirthdaysResponse(
val success: Boolean = false,
val birthdays: List<BirthdayDto> = emptyList(),
)
data class QttrSourceDto(
val url: String = "",
)
data class QttrRowDto(
val rank: Int? = null,
val playerNumber: Int? = null,
val gender: String? = null,
val playerName: String = "",
val clubName: String = "",
val currentQttr: Int? = null,
val previousQttr: Int? = null,
val birthdate: String? = null,
)
data class QttrValuesResponse(
val format: String = "",
val importedAt: String = "",
val source: QttrSourceDto = QttrSourceDto(),
val title: String? = null,
val headerCount: Int = 0,
val rowCount: Int = 0,
val rows: List<QttrRowDto> = emptyList(),
)
data class MemberDto(
val id: String? = null,
val name: String = "",
@@ -387,6 +439,15 @@ data class HomepageSectionConfigDto(
data class HomepageDto(
val sections: List<HomepageSectionDto> = emptyList(),
)
data class HeroImageVariantDto(
val key: String = "",
val mobileWebp: String = "",
val desktopWebp: String = "",
val fallback: String = "",
)
data class HeroImagesResponse(
val variants: List<HeroImageVariantDto> = emptyList(),
)
data class SeitenDto(
val ueberUns: String = "",
val geschichte: String = "",
@@ -548,14 +609,20 @@ interface ApiService {
suspend fun saveNews(@Body request: NewsSaveRequest): Response<AuthMessageResponse>
@DELETE("/api/news")
suspend fun deleteNews(@Query("id") id: Int): Response<AuthMessageResponse>
suspend fun deleteNews(@Query("id") id: String): Response<AuthMessageResponse>
@GET("/api/mannschaften")
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
@GET("/api/mannschaften/seasons")
suspend fun mannschaftenSeasons(): Response<MannschaftenSeasonsResponse>
@GET("/api/config")
suspend fun config(): Response<ConfigResponse>
@GET("/api/hero-images")
suspend fun heroImages(): Response<HeroImagesResponse>
@PUT("/api/config")
suspend fun updateConfig(@Body request: ConfigResponse): Response<ConfigResponse>
@@ -617,9 +684,21 @@ interface ApiService {
@retrofit2.http.PUT("/api/profile")
suspend fun updateProfile(@Body request: ProfileUpdateRequest): Response<ProfileResponse>
@GET("/api/profile/notifications")
suspend fun notificationSettings(): Response<NotificationSettingsResponse>
@retrofit2.http.PUT("/api/profile/notifications")
suspend fun updateNotificationSettings(@Body request: NotificationSettingsDto): Response<NotificationSettingsResponse>
@POST("/api/profile/push-token")
suspend fun registerPushToken(@Body request: PushTokenRequest): Response<AuthMessageResponse>
@GET("/api/birthdays")
suspend fun birthdays(): Response<BirthdaysResponse>
@GET("/api/mitgliederbereich/qttr")
suspend fun qttrValues(): Response<QttrValuesResponse>
@GET("/api/members")
suspend fun members(): Response<MembersResponse>

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
package de.harheimertc.notifications
import android.util.Log
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import dagger.hilt.android.AndroidEntryPoint
import de.harheimertc.repositories.PushTokenRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class HarheimerMessagingService : FirebaseMessagingService() {
@Inject
lateinit var pushTokenRepository: PushTokenRepository
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onNewToken(token: String) {
super.onNewToken(token)
serviceScope.launch {
pushTokenRepository.registerToken(token)
}
}
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
val title = message.notification?.title
?: message.data["title"]
?: "Harheimer TC"
val body = message.notification?.body
?: message.data["body"]
?: message.data["message"]
?: return
val notificationId = message.data["notificationId"]?.toIntOrNull()
?: message.messageId?.hashCode()
?: System.currentTimeMillis().toInt()
val shown = HarheimerNotifications.showBasicNotification(this, notificationId, title, body)
Log.d("HarheimerMessaging", "Push message received type=${message.data["type"]}, shown=$shown")
}
}

View File

@@ -0,0 +1,51 @@
package de.harheimertc.notifications
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import de.harheimertc.R
object HarheimerNotifications {
const val DEFAULT_CHANNEL_ID = "harheimer_tc_updates"
fun createChannels(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val channel = NotificationChannel(
DEFAULT_CHANNEL_ID,
"Harheimer TC",
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = "Benachrichtigungen des Harheimer TC"
}
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
fun hasNotificationPermission(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
fun showBasicNotification(
context: Context,
notificationId: Int,
title: String,
message: String,
): Boolean {
if (!hasNotificationPermission(context)) return false
val notification = NotificationCompat.Builder(context, DEFAULT_CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(title)
.setContentText(message)
.setStyle(NotificationCompat.BigTextStyle().bigText(message))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(context).notify(notificationId, notification)
return true
}
}

View File

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

View File

@@ -267,7 +267,7 @@ class CmsRepository @Inject constructor(
response.body() ?: de.harheimertc.data.AuthMessageResponse(success = false, message = "Leere Antwort")
}
suspend fun deleteNews(id: Int): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
suspend fun deleteNews(id: String): Result<de.harheimertc.data.AuthMessageResponse> = runCatching {
val response = api.deleteNews(id)
if (!response.isSuccessful) error("News konnten nicht gelöscht werden.")
cache.clearCmsNewsCache()

View File

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

View File

@@ -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>? {

View File

@@ -1,12 +1,16 @@
package de.harheimertc.repositories
import de.harheimertc.BuildConfig
import de.harheimertc.data.ApiService
import de.harheimertc.data.HomepageSectionDto
import de.harheimertc.data.HeroImageVariantDto
import de.harheimertc.data.NewsDto
import de.harheimertc.data.SeasonDto
import de.harheimertc.data.SpielDto
import de.harheimertc.data.SpielplanResponse
import de.harheimertc.data.TerminDto
import io.sentry.Sentry
import kotlin.random.Random
import javax.inject.Inject
import javax.inject.Singleton
@@ -17,20 +21,162 @@ data class HomeData(
val selectedSpielplanSeason: String?,
val news: List<NewsDto>,
val homepageSections: List<HomepageSectionDto>,
val heroImageUrl: String? = null,
val diagnostics: List<String> = emptyList(),
)
@Singleton
class HomeRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchHomeData(): Result<HomeData> = runCatching {
val termine = api.termine().body()?.termine.orEmpty()
val spielplanResponse = api.spielplan().body()
val spiele = spielplanResponse?.data.orEmpty()
val news = api.publicNews().body()?.news.orEmpty()
val homepageSections = runCatching {
val configResponse = api.config()
if (!configResponse.isSuccessful) return@runCatching emptyList()
configResponse.body()?.homepage?.sections.orEmpty()
val diagnostics = mutableListOf<String>()
val termine = runCatching {
retryOnNetworkFailure {
val response = api.termine()
if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty()
diagnostics += buildDiagnostic(
endpoint = "GET /api/termine",
requestPayload = "none",
httpCode = response.code(),
responseBody = errorBody,
throwable = null,
)
error("Termine konnten nicht geladen werden (HTTP ${response.code()}).")
}
response.body()?.termine.orEmpty()
}
}.onFailure { error ->
captureLoadIssue("fetchHomeData.termine", error)
if (diagnostics.none { it.contains("GET /api/termine") }) {
diagnostics += buildDiagnostic(
endpoint = "GET /api/termine",
requestPayload = "none",
httpCode = null,
responseBody = null,
throwable = error,
)
}
}.getOrDefault(emptyList())
val spielplanResponse = runCatching {
retryOnNetworkFailure {
val response = api.spielplan()
if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty()
diagnostics += buildDiagnostic(
endpoint = "GET /api/spielplan",
requestPayload = "none",
httpCode = response.code(),
responseBody = errorBody,
throwable = null,
)
error("Spielplan konnte nicht geladen werden (HTTP ${response.code()}).")
}
response.body()
}
}.onFailure { error ->
captureLoadIssue("fetchHomeData.spielplan", error)
if (diagnostics.none { it.contains("GET /api/spielplan") }) {
diagnostics += buildDiagnostic(
endpoint = "GET /api/spielplan",
requestPayload = "none",
httpCode = null,
responseBody = null,
throwable = error,
)
}
}.getOrNull()
val spiele = spielplanResponse?.data.orEmpty()
val news = runCatching {
retryOnNetworkFailure {
val response = api.publicNews()
if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty()
diagnostics += buildDiagnostic(
endpoint = "GET /api/news-public",
requestPayload = "none",
httpCode = response.code(),
responseBody = errorBody,
throwable = null,
)
error("News konnten nicht geladen werden (HTTP ${response.code()}).")
}
response.body()?.news.orEmpty()
}
}.onFailure { error ->
captureLoadIssue("fetchHomeData.news", error)
if (diagnostics.none { it.contains("GET /api/news-public") }) {
diagnostics += buildDiagnostic(
endpoint = "GET /api/news-public",
requestPayload = "none",
httpCode = null,
responseBody = null,
throwable = error,
)
}
}.getOrDefault(emptyList())
val homepageSections = runCatching {
retryOnNetworkFailure {
val response = api.config()
if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty()
diagnostics += buildDiagnostic(
endpoint = "GET /api/config",
requestPayload = "none",
httpCode = response.code(),
responseBody = errorBody,
throwable = null,
)
error("Konfiguration konnte nicht geladen werden (HTTP ${response.code()}).")
}
response.body()?.homepage?.sections.orEmpty()
}
}.onFailure { error ->
captureLoadIssue("fetchHomeData.config", error)
if (diagnostics.none { it.contains("GET /api/config") }) {
diagnostics += buildDiagnostic(
endpoint = "GET /api/config",
requestPayload = "none",
httpCode = null,
responseBody = null,
throwable = error,
)
}
}.getOrDefault(emptyList())
val heroImageUrl = runCatching {
retryOnNetworkFailure {
val response = api.heroImages()
if (!response.isSuccessful) {
val errorBody = response.errorBody()?.string().orEmpty()
diagnostics += buildDiagnostic(
endpoint = "GET /api/hero-images",
requestPayload = "none",
httpCode = response.code(),
responseBody = errorBody,
throwable = null,
)
error("Hero-Bilder konnten nicht geladen werden (HTTP ${response.code()}).")
}
val variants = response.body()?.variants.orEmpty()
pickRandomHeroImage(variants)
}
}.onFailure { error ->
captureLoadIssue("fetchHomeData.heroImages", error)
if (diagnostics.none { it.contains("GET /api/hero-images") }) {
diagnostics += buildDiagnostic(
endpoint = "GET /api/hero-images",
requestPayload = "none",
httpCode = null,
responseBody = null,
throwable = error,
)
}
}.getOrNull()
HomeData(
termine = termine,
spiele = spiele,
@@ -38,12 +184,77 @@ class HomeRepository @Inject constructor(private val api: ApiService) {
selectedSpielplanSeason = spielplanResponse?.season,
news = news,
homepageSections = homepageSections,
heroImageUrl = heroImageUrl,
diagnostics = diagnostics,
)
}.onFailure { error ->
Sentry.withScope { scope ->
scope.setTag("repository", "HomeRepository")
scope.setTag("operation", "fetchHomeData")
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
Sentry.captureException(error)
}
}
suspend fun fetchSpielplanForSeason(season: String): Result<SpielplanResponse> = runCatching {
val response = api.spielplan(season)
if (!response.isSuccessful) error("HTTP ${response.code()}")
response.body() ?: error("Leere Antwort")
retryOnNetworkFailure {
val response = api.spielplan(season)
if (!response.isSuccessful) error("HTTP ${response.code()}")
response.body() ?: error("Leere Antwort")
}
}.onFailure { error ->
Sentry.withScope { scope ->
scope.setTag("repository", "HomeRepository")
scope.setTag("operation", "fetchSpielplanForSeason")
scope.setExtra("season", season)
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
Sentry.captureException(error)
}
}
private fun captureLoadIssue(operation: String, error: Throwable) {
Sentry.withScope { scope ->
scope.setTag("repository", "HomeRepository")
scope.setTag("operation", operation)
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
Sentry.captureException(error)
}
}
private fun buildDiagnostic(
endpoint: String,
requestPayload: String,
httpCode: Int?,
responseBody: String?,
throwable: Throwable?,
): String {
val responsePreview = responseBody?.trim()?.take(500).orEmpty().ifBlank { "none" }
val throwableInfo = throwable?.let { "${it::class.simpleName}: ${it.message}" }.orEmpty().ifBlank { "none" }
return buildString {
append("Endpoint: ").append(endpoint).append('\n')
append("URL: ").append(BuildConfig.API_BASE_URL).append(endpoint.substringAfter(' ')).append('\n')
append("Request: ").append(requestPayload).append('\n')
append("HTTP: ").append(httpCode?.toString() ?: "none").append('\n')
append("Response: ").append(responsePreview).append('\n')
append("Throwable: ").append(throwableInfo)
}
}
private fun pickRandomHeroImage(variants: List<HeroImageVariantDto>): String? {
if (variants.isEmpty()) return null
val valid = variants.filter { it.fallback.isNotBlank() }
if (valid.isEmpty()) return null
val selected = valid[Random.nextInt(valid.size)]
return toAbsoluteUrl(selected.fallback)
}
private fun toAbsoluteUrl(pathOrUrl: String): String {
if (pathOrUrl.startsWith("http://") || pathOrUrl.startsWith("https://")) {
return pathOrUrl
}
val base = BuildConfig.API_BASE_URL.trimEnd('/')
val path = if (pathOrUrl.startsWith('/')) pathOrUrl else "/$pathOrUrl"
return "$base$path"
}
}

View File

@@ -1,5 +1,6 @@
package de.harheimertc.repositories
import de.harheimertc.BuildConfig
import de.harheimertc.data.ApiService
import de.harheimertc.data.LoginRequest
import de.harheimertc.data.LoginResponse
@@ -9,6 +10,7 @@ import de.harheimertc.data.LogoutRequest
import de.harheimertc.data.RegistrationRequest
import de.harheimertc.data.ResetPasswordRequest
import de.harheimertc.data.SessionRefresher
import io.sentry.Sentry
import javax.inject.Inject
import javax.inject.Singleton
@@ -19,13 +21,41 @@ class LoginRepository @Inject constructor(
private val sessionRefresher: SessionRefresher,
) {
suspend fun login(email: String, password: String): Result<LoginResponse> = runCatching {
val response = api.login(LoginRequest(email.trim(), password))
if (!response.isSuccessful) error("Anmeldung fehlgeschlagen. Bitte prüfen Sie Ihre Zugangsdaten.")
val endpoint = "api/auth/login"
val requestPreview = "{email=\"${maskEmail(email)}\", client=\"android\", deviceName=\"Harheimer TC Android-App\"}"
val response = retryOnNetworkFailure { api.login(LoginRequest(email.trim(), password)) }
if (!response.isSuccessful) {
val body = response.errorBody()?.string().orEmpty()
val serverMessage = extractServerMessage(body)
val fallback = when (response.code()) {
401 -> "Ungueltige Anmeldedaten"
403 -> "Konto nicht freigeschaltet"
429 -> "Zu viele Anmeldeversuche. Bitte spaeter erneut versuchen."
else -> "Anmeldung fehlgeschlagen"
}
val diagnostic = buildString {
append("\n\nDiagnose:\n")
append("URL: ").append(BuildConfig.API_BASE_URL).append(endpoint).append('\n')
append("Request: ").append(requestPreview).append('\n')
append("HTTP: ").append(response.code()).append('\n')
append("Response: ").append(body.take(500).ifBlank { "none" }).append('\n')
append("Server message: ").append(serverMessage ?: "none")
}
error("$fallback (HTTP ${response.code()})${serverMessage?.let { ": $it" } ?: ""}$diagnostic")
}
val body = response.body() ?: error("Leere Antwort")
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
?: error("Der Server hat kein Zugriffstoken geliefert.")
authRepository.setSession(token, body.refreshToken, body.sessionId)
body
}.onFailure { error ->
Sentry.withScope { scope ->
scope.setTag("repository", "LoginRepository")
scope.setTag("operation", "login")
scope.setExtra("emailDomain", email.substringAfter('@', "unknown"))
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
Sentry.captureException(error)
}
}
suspend fun logout(): Result<Unit> = runCatching {
@@ -41,27 +71,55 @@ class LoginRepository @Inject constructor(
return@runCatching AuthStatusResponse()
}
var response = api.authStatus()
var response = retryOnNetworkFailure { api.authStatus() }
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
var status = response.body() ?: AuthStatusResponse()
if (!status.isLoggedIn && authRepository.getRefreshToken() != null && sessionRefresher.refreshAccessToken()) {
response = api.authStatus()
response = retryOnNetworkFailure { api.authStatus() }
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
status = response.body() ?: AuthStatusResponse()
}
if (!status.isLoggedIn && authRepository.getRefreshToken() == null) authRepository.clearSession()
status
}.onFailure { error ->
Sentry.withScope { scope ->
scope.setTag("repository", "LoginRepository")
scope.setTag("operation", "status")
scope.setExtra("hasAccessToken", (!authRepository.getToken().isNullOrBlank()).toString())
scope.setExtra("hasRefreshToken", (authRepository.getRefreshToken() != null).toString())
scope.setExtra("apiBaseUrl", BuildConfig.API_BASE_URL)
Sentry.captureException(error)
}
}
suspend fun resetPassword(email: String): Result<AuthMessageResponse> = runCatching {
val response = api.resetPassword(ResetPasswordRequest(email.trim()))
if (!response.isSuccessful) error("Anfrage konnte nicht gesendet werden.")
response.body() ?: error("Leere Antwort")
retryOnNetworkFailure {
val response = api.resetPassword(ResetPasswordRequest(email.trim()))
if (!response.isSuccessful) error("Anfrage konnte nicht gesendet werden.")
response.body() ?: error("Leere Antwort")
}
}
suspend fun register(request: RegistrationRequest): Result<AuthMessageResponse> = runCatching {
val response = api.register(request)
if (!response.isSuccessful) error("Registrierung fehlgeschlagen.")
response.body() ?: error("Leere Antwort")
retryOnNetworkFailure {
val response = api.register(request)
if (!response.isSuccessful) error("Registrierung fehlgeschlagen.")
response.body() ?: error("Leere Antwort")
}
}
private fun extractServerMessage(raw: String): String? {
if (raw.isBlank()) return null
val msgRegex = Regex("\"message\"\\s*:\\s*\"([^\"]+)\"")
return msgRegex.find(raw)?.groupValues?.getOrNull(1)
}
private fun maskEmail(rawEmail: String): String {
val email = rawEmail.trim()
if (!email.contains('@')) return "hidden"
val local = email.substringBefore('@')
val domain = email.substringAfter('@')
val localMasked = if (local.length <= 2) "**" else local.take(2) + "***"
return "$localMasked@$domain"
}
}

View File

@@ -1,6 +1,8 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import de.harheimertc.data.MannschaftenSeasonsResponse
import de.harheimertc.data.SeasonDto
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
@@ -24,9 +26,19 @@ data class Mannschaft(
@Singleton
class MannschaftenRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchMannschaften(season: String? = null): Result<List<Mannschaft>> = runCatching {
val response = api.mannschaften(season)
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
parseCsv(response.body()?.string().orEmpty())
retryOnNetworkFailure {
val response = api.mannschaften(season)
if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.")
parseCsv(response.body()?.string().orEmpty())
}
}
suspend fun fetchSeasons(): Result<MannschaftenSeasonsResponse> = runCatching {
retryOnNetworkFailure {
val response = api.mannschaftenSeasons()
if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.")
response.body() ?: error("Saisons konnten nicht geladen werden.")
}
}
private fun parseCsv(csv: String): List<Mannschaft> = csv.lineSequence()

View File

@@ -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.")
}

View File

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

View File

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

View File

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

View File

@@ -28,68 +28,74 @@ class PasskeyRepository @Inject constructor(
private val authRepository: AuthRepository,
) {
suspend fun login(context: Context, email: String?): Result<LoginResponse> = runCatching {
val optionsResponse = api.passkeyAuthenticationOptions(
PasskeyAuthenticationOptionsRequest(email = email?.trim()?.takeIf(String::isNotBlank)),
)
if (!optionsResponse.isSuccessful) error("Passkey-Anmeldung konnte nicht gestartet werden.")
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
?: error("Der Server hat keine Passkey-Optionen geliefert.")
retryOnNetworkFailure {
val optionsResponse = api.passkeyAuthenticationOptions(
PasskeyAuthenticationOptionsRequest(email = email?.trim()?.takeIf(String::isNotBlank)),
)
if (!optionsResponse.isSuccessful) error("Passkey-Anmeldung konnte nicht gestartet werden.")
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
?: error("Der Server hat keine Passkey-Optionen geliefert.")
val credentialManager = CredentialManager.create(context)
val credentialResponse = credentialManager.getCredential(
context = context,
request = GetCredentialRequest(
credentialOptions = listOf(GetPublicKeyCredentialOption(optionsJson)),
),
)
val credential = credentialResponse.credential as? PublicKeyCredential
?: error("Der ausgewählte Zugang ist kein Passkey.")
val credentialManager = CredentialManager.create(context)
val credentialResponse = credentialManager.getCredential(
context = context,
request = GetCredentialRequest(
credentialOptions = listOf(GetPublicKeyCredentialOption(optionsJson)),
),
)
val credential = credentialResponse.credential as? PublicKeyCredential
?: error("Der ausgewählte Zugang ist kein Passkey.")
val response = api.passkeyLogin(
JSONObject()
.put("credential", JSONObject(credential.authenticationResponseJson))
.put("client", "android")
.put("deviceName", "Harheimer TC Android-App")
.toString()
.toRequestBody(MediaTypes.json),
)
if (!response.isSuccessful) error("Passkey-Anmeldung fehlgeschlagen.")
val body = response.body() ?: error("Leere Antwort")
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
?: error("Der Server hat kein Zugriffstoken geliefert.")
authRepository.setSession(token, body.refreshToken, body.sessionId)
body
val response = api.passkeyLogin(
JSONObject()
.put("credential", JSONObject(credential.authenticationResponseJson))
.put("client", "android")
.put("deviceName", "Harheimer TC Android-App")
.toString()
.toRequestBody(MediaTypes.json),
)
if (!response.isSuccessful) error("Passkey-Anmeldung fehlgeschlagen.")
val body = response.body() ?: error("Leere Antwort")
val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank)
?: error("Der Server hat kein Zugriffstoken geliefert.")
authRepository.setSession(token, body.refreshToken, body.sessionId)
body
}
}.recoverCredentialCancellation("Passkey-Anmeldung abgebrochen.")
suspend fun list(): Result<PasskeysResponse> = runCatching {
val response = api.passkeys()
if (!response.isSuccessful) error("Passkeys konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort")
retryOnNetworkFailure {
val response = api.passkeys()
if (!response.isSuccessful) error("Passkeys konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort")
}
}
suspend fun add(context: Context, name: String = "Android-App"): Result<AuthMessageResponse> = runCatching {
val optionsResponse = api.passkeyRegistrationOptions(PasskeyRegistrationOptionsRequest())
if (!optionsResponse.isSuccessful) error("Passkey-Erstellung konnte nicht gestartet werden.")
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
?: error("Der Server hat keine Passkey-Optionen geliefert.")
retryOnNetworkFailure {
val optionsResponse = api.passkeyRegistrationOptions(PasskeyRegistrationOptionsRequest())
if (!optionsResponse.isSuccessful) error("Passkey-Erstellung konnte nicht gestartet werden.")
val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options")
?: error("Der Server hat keine Passkey-Optionen geliefert.")
val credentialManager = CredentialManager.create(context)
val credentialResponse = credentialManager.createCredential(
context = context,
request = CreatePublicKeyCredentialRequest(optionsJson),
) as? CreatePublicKeyCredentialResponse
?: error("Der erstellte Zugang ist kein Passkey.")
val credentialManager = CredentialManager.create(context)
val credentialResponse = credentialManager.createCredential(
context = context,
request = CreatePublicKeyCredentialRequest(optionsJson),
) as? CreatePublicKeyCredentialResponse
?: error("Der erstellte Zugang ist kein Passkey.")
val response = api.registerPasskey(
JSONObject()
.put("credential", JSONObject(credentialResponse.registrationResponseJson))
.put("name", name)
.put("client", "android")
.toString()
.toRequestBody(MediaTypes.json),
)
if (!response.isSuccessful) error("Passkey konnte nicht hinzugefügt werden.")
response.body() ?: error("Leere Antwort")
val response = api.registerPasskey(
JSONObject()
.put("credential", JSONObject(credentialResponse.registrationResponseJson))
.put("name", name)
.put("client", "android")
.toString()
.toRequestBody(MediaTypes.json),
)
if (!response.isSuccessful) error("Passkey konnte nicht hinzugefügt werden.")
response.body() ?: error("Leere Antwort")
}
}.recoverCredentialCancellation("Passkey-Erstellung abgebrochen.")
suspend fun remove(credentialId: String): Result<AuthMessageResponse> = runCatching {

View File

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

View File

@@ -31,43 +31,49 @@ data class MeisterschaftResult(
@Singleton
class PublicPagesRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchConfig(): Result<ConfigResponse> = runCatching {
val response = api.config()
if (!response.isSuccessful) error("Inhalte konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort")
retryOnNetworkFailure {
val response = api.config()
if (!response.isSuccessful) error("Inhalte konnten nicht geladen werden.")
response.body() ?: error("Leere Antwort")
}
}
suspend fun fetchSpielsysteme(): Result<List<Spielsystem>> = runCatching {
val response = api.spielsysteme()
if (!response.isSuccessful) error("Spielsysteme konnten nicht geladen werden.")
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
if (values.size < 8) return@mapNotNull null
Spielsystem(
name = values[0],
description = values[1],
teamSize = values[2],
category = values[3],
sequence = values[5],
gameCount = values[6],
features = values[7],
)
retryOnNetworkFailure {
val response = api.spielsysteme()
if (!response.isSuccessful) error("Spielsysteme konnten nicht geladen werden.")
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
if (values.size < 8) return@mapNotNull null
Spielsystem(
name = values[0],
description = values[1],
teamSize = values[2],
category = values[3],
sequence = values[5],
gameCount = values[6],
features = values[7],
)
}
}
}
suspend fun fetchVereinsmeisterschaften(): Result<List<MeisterschaftResult>> = runCatching {
val response = api.vereinsmeisterschaften()
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.")
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
if (values.size < 6) return@mapNotNull null
MeisterschaftResult(
year = values[0],
category = values[1],
rank = values[2],
playerOne = values[3],
playerTwo = values[4],
note = values[5],
imageOne = values.getOrElse(6) { "" },
imageTwo = values.getOrElse(7) { "" },
)
retryOnNetworkFailure {
val response = api.vereinsmeisterschaften()
if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.")
parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values ->
if (values.size < 6) return@mapNotNull null
MeisterschaftResult(
year = values[0],
category = values[1],
rank = values[2],
playerOne = values[3],
playerTwo = values[4],
note = values[5],
imageOne = values.getOrElse(6) { "" },
imageTwo = values.getOrElse(7) { "" },
)
}
}
}
}

View File

@@ -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)
}
}

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,14 @@
package de.harheimertc.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -25,6 +27,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import de.harheimertc.BuildConfig
import de.harheimertc.R
@@ -40,14 +43,17 @@ private enum class MenuSection {
TRAINING,
NEWSLETTER,
INTERN,
CMS,
}
private data class MenuTarget(val label: String, val route: String)
private const val LOGOUT_ROUTE = "__logout__"
@Composable
fun AppNavigationHeader(
selectedRoute: String?,
onNavigate: (String) -> Unit,
onLogout: () -> Unit = {},
webTabletNavigation: Boolean = false,
navigationState: NavigationUiState = NavigationUiState(),
) {
@@ -59,9 +65,9 @@ fun AppNavigationHeader(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (webTabletNavigation) {
WebTabletNavigation(selectedRoute, onNavigate, navigationState)
WebTabletNavigation(selectedRoute, onNavigate, onLogout, navigationState)
} else {
CompactNavigation(selectedRoute, onNavigate, navigationState)
CompactNavigation(selectedRoute, onNavigate, onLogout, navigationState)
}
}
}
@@ -70,18 +76,79 @@ fun AppNavigationHeader(
private fun CompactNavigation(
selectedRoute: String?,
onNavigate: (String) -> Unit,
onLogout: () -> Unit,
navigationState: NavigationUiState = NavigationUiState(),
) {
BrandRow(onLogin = { onNavigate(Destinations.Login.route) })
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate, Modifier.weight(1f))
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate, Modifier.weight(1f))
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate, Modifier.weight(1f))
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate, Modifier.weight(1f))
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate, Modifier.weight(1f))
if (navigationState.isAdmin || navigationState.canAccessNewsletter || navigationState.canAccessContactRequests) {
CompactLink("CMS", Destinations.Cms.route, selectedRoute, onNavigate, Modifier.weight(1f))
val routeSection = menuSection(selectedRoute)
val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
val section = routeSection ?: sectionOverride.value
val subItems = submenu(section, navigationState)
val mainScroll = rememberScrollState()
val subScroll = rememberScrollState()
BrandRow(
loggedIn = navigationState.loggedIn,
onLogin = { onNavigate(Destinations.Login.route) },
onLogout = onLogout,
)
ScrollableMenuRow(scrollState = mainScroll) {
CompactLink("Start", Destinations.Home.route, selectedRoute, onNavigate) { sectionOverride.value = null }
CompactSectionLink("Verein", MenuSection.VEREIN, section) { sectionOverride.value = MenuSection.VEREIN }
CompactSectionLink("Mannschaften", MenuSection.MANNSCHAFTEN, section) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
CompactSectionLink("Training", MenuSection.TRAINING, section) { sectionOverride.value = MenuSection.TRAINING }
CompactLink("Termine", Destinations.Termine.route, selectedRoute, onNavigate) { sectionOverride.value = null }
CompactLink("Spielplan", Destinations.Spielplan.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.MANNSCHAFTEN }
CompactSectionLink("Newsletter", MenuSection.NEWSLETTER, section) { sectionOverride.value = MenuSection.NEWSLETTER }
if (navigationState.showGallery) {
CompactLink("Galerie", Destinations.Gallery.route, selectedRoute, onNavigate) { sectionOverride.value = MenuSection.VEREIN }
}
if (navigationState.loggedIn) {
CompactSectionLink("Intern", MenuSection.INTERN, section) { sectionOverride.value = MenuSection.INTERN }
if (navigationState.canAccessCms) {
CompactSectionLink("CMS", MenuSection.CMS, section) { sectionOverride.value = MenuSection.CMS }
}
} else {
CompactLink("Login", Destinations.Login.route, selectedRoute, onNavigate) { sectionOverride.value = null }
}
CompactLink("Kontakt", Destinations.Contact.route, selectedRoute, onNavigate) { sectionOverride.value = null }
}
ScrollableMenuRow(scrollState = subScroll, topPadding = 3.dp) {
subItems.forEach { item ->
SubLink(item.label, item.route == selectedRoute) {
if (item.route == LOGOUT_ROUTE) {
onLogout()
} else {
onNavigate(item.route)
}
}
}
}
}
@Composable
private fun CompactSectionLink(
label: String,
section: MenuSection,
activeSection: MenuSection?,
modifier: Modifier = Modifier,
onSelect: () -> Unit,
) {
Surface(
color = if (activeSection == section) Primary600 else Color.Transparent,
shape = RoundedCornerShape(8.dp),
modifier = modifier.clickable { onSelect() },
) {
Text(
label,
color = if (activeSection == section) Color.White else Color(0xFFD4D4D8),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
maxLines = 1,
)
}
}
@@ -89,12 +156,13 @@ private fun CompactNavigation(
private fun WebTabletNavigation(
selectedRoute: String?,
onNavigate: (String) -> Unit,
onLogout: () -> Unit,
navigationState: NavigationUiState,
) {
val section = menuSection(selectedRoute)
var cmsExpanded = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
// Helper that closes the CMS submenu when navigating away
val navigateAndClose: (String) -> Unit = { route -> cmsExpanded.value = false; onNavigate(route) }
val sectionOverride = androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf<MenuSection?>(null) }
val section = sectionOverride.value ?: menuSection(selectedRoute)
val subScroll = rememberScrollState()
Row(verticalAlignment = Alignment.CenterVertically) {
Brand()
Spacer(Modifier.width(16.dp))
@@ -103,75 +171,91 @@ private fun WebTabletNavigation(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
MainLink("Start", selectedRoute == Destinations.Home.route, onClick = { navigateAndClose(Destinations.Home.route) })
MainLink("Verein", section == MenuSection.VEREIN, onClick = { navigateAndClose(Destinations.VereinAbout.route) })
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { navigateAndClose(Destinations.Mannschaften.route) })
MainLink("Training", section == MenuSection.TRAINING, onClick = { navigateAndClose(Destinations.Training.route) })
MainLink("Mitgliedschaft", selectedRoute == Destinations.Membership.route, onClick = { navigateAndClose(Destinations.Membership.route) })
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = { navigateAndClose(Destinations.Termine.route) })
MainLink("Start", selectedRoute == Destinations.Home.route, onClick = {
sectionOverride.value = null
onNavigate(Destinations.Home.route)
})
MainLink("Verein", section == MenuSection.VEREIN, onClick = {
sectionOverride.value = MenuSection.VEREIN
onNavigate(Destinations.VereinAbout.route)
})
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = {
sectionOverride.value = MenuSection.MANNSCHAFTEN
onNavigate(Destinations.Mannschaften.route)
})
MainLink("Training", section == MenuSection.TRAINING, onClick = {
sectionOverride.value = MenuSection.TRAINING
onNavigate(Destinations.Training.route)
})
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = {
sectionOverride.value = null
onNavigate(Destinations.Termine.route)
})
if (navigationState.showGallery) {
MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = { onNavigate(Destinations.Gallery.route) })
MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = {
sectionOverride.value = MenuSection.VEREIN
onNavigate(Destinations.Gallery.route)
})
}
MainLink("Newsletter", section == MenuSection.NEWSLETTER, onClick = { onNavigate(Destinations.NewsletterSubscribe.route) })
MainLink("Newsletter", section == MenuSection.NEWSLETTER, onClick = {
sectionOverride.value = MenuSection.NEWSLETTER
onNavigate(Destinations.NewsletterSubscribe.route)
})
if (navigationState.loggedIn) {
MainLink("Intern", section == MenuSection.INTERN, onClick = { onNavigate(Destinations.MemberArea.route) })
}
MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = { navigateAndClose(Destinations.Contact.route) })
TextButton(onClick = { navigateAndClose(Destinations.Login.route) }) { Text("Login", color = Color.White) }
}
}
val subItems = submenu(section, navigationState)
// determine CMS parent index and children
val cmsIndex = subItems.indexOfFirst { it.label == "CMS" }
val cmsChildren = if (cmsIndex >= 0) subItems.subList(cmsIndex + 1, subItems.size) else emptyList<MenuTarget>()
if (cmsChildren.any { it.route == selectedRoute }) {
cmsExpanded.value = true
}
// First row: render all subitems but do NOT render CMS children here
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(top = 3.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
subItems.forEachIndexed { idx, item ->
if (idx == cmsIndex) {
// CMS parent toggle
SubLink(item.label, item.route == selectedRoute) {
cmsExpanded.value = !cmsExpanded.value
MainLink("Intern", section == MenuSection.INTERN, onClick = {
sectionOverride.value = MenuSection.INTERN
})
if (navigationState.canAccessCms) {
MainLink("CMS", section == MenuSection.CMS, onClick = {
sectionOverride.value = MenuSection.CMS
onNavigate(Destinations.Cms.route)
})
}
} else if (idx > cmsIndex && cmsIndex >= 0) {
// skip cms children here; they'll be rendered in the second row when expanded
} else {
// normal item before CMS: close cms submenu on navigate
SubLink(item.label, item.route == selectedRoute) { navigateAndClose(item.route) }
}
MainLink("Kontakt", selectedRoute == Destinations.Contact.route, primary = true, onClick = {
sectionOverride.value = null
onNavigate(Destinations.Contact.route)
})
}
Spacer(Modifier.width(12.dp))
if (navigationState.loggedIn) {
TextButton(onClick = onLogout) { Text("Logout", color = Color.White) }
} else {
TextButton(onClick = {
sectionOverride.value = null
onNavigate(Destinations.Login.route)
}) { Text("Login", color = Color.White) }
}
}
// Second row: when CMS expanded, render its children beneath
if (cmsExpanded.value && cmsChildren.isNotEmpty()) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(top = 6.dp, bottom = 3.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
cmsChildren.forEach { child ->
SubLink(child.label, child.route == selectedRoute) { onNavigate(child.route) }
val subItems = submenu(section, navigationState)
ScrollableMenuRow(scrollState = subScroll, topPadding = 3.dp) {
subItems.forEach { item ->
SubLink(item.label, item.route == selectedRoute) {
if (item.route == LOGOUT_ROUTE) {
onLogout()
} else {
onNavigate(item.route)
}
}
}
}
}
@Composable
private fun BrandRow(onLogin: () -> Unit) {
private fun BrandRow(
loggedIn: Boolean,
onLogin: () -> Unit,
onLogout: () -> Unit,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Brand()
Spacer(Modifier.weight(1f))
TextButton(onClick = onLogin) { Text("Login", color = Color.White) }
if (loggedIn) {
TextButton(onClick = onLogout) { Text("Logout", color = Color.White) }
} else {
TextButton(onClick = onLogin) { Text("Login", color = Color.White) }
}
}
}
@@ -214,8 +298,8 @@ private fun MainLink(
) {
Text(
label,
color = Color.White.copy(alpha = if (selected || primary) 1f else 0.82f),
style = MaterialTheme.typography.labelSmall,
color = Color.White.copy(alpha = if (selected || primary) 1f else 0.94f),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 9.dp),
maxLines = 1,
)
@@ -229,23 +313,62 @@ private fun CompactLink(
selectedRoute: String?,
onNavigate: (String) -> Unit,
modifier: Modifier = Modifier,
beforeNavigate: () -> Unit = {},
) {
Surface(
color = if (route == selectedRoute) Primary600 else Color.Transparent,
shape = RoundedCornerShape(8.dp),
modifier = modifier.clickable { onNavigate(route) },
modifier = modifier.clickable {
beforeNavigate()
onNavigate(route)
},
) {
Text(
label,
color = if (route == selectedRoute) Color.White else Color(0xFFD4D4D8),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
maxLines = 1,
)
}
}
@Composable
private fun ScrollableMenuRow(
scrollState: ScrollState,
topPadding: Dp = 0.dp,
content: @Composable RowScope.() -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = topPadding),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
if (scrollState.canScrollBackward) "" else "",
color = Color(0xFFD4D4D8),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.width(14.dp),
)
Row(
modifier = Modifier
.weight(1f)
.horizontalScroll(scrollState),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
content = content,
)
Text(
if (scrollState.canScrollForward) "" else "",
color = Color(0xFFD4D4D8),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.width(14.dp),
)
}
}
@Composable
private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) {
Surface(
@@ -256,7 +379,7 @@ private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) {
Text(
label,
color = if (selected) Color.White else Color(0xFFD4D4D8),
style = MaterialTheme.typography.labelSmall,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp),
maxLines = 1,
)
@@ -287,11 +410,15 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
Destinations.NewsletterConfirm.route,
Destinations.NewsletterConfirmed.route,
Destinations.NewsletterUnsubscribed.route -> MenuSection.NEWSLETTER
Destinations.MemberArea.route,
Destinations.Members.route,
Destinations.Qttr.route,
Destinations.MemberNews.route,
Destinations.Profile.route,
Destinations.MemberApi.route,
Destinations.NotificationSettings.route,
Destinations.MemberApi.route -> MenuSection.INTERN
Destinations.CmsStartseite.route,
Destinations.CmsInhalte.route,
Destinations.CmsVereinsmeisterschaften.route,
@@ -302,7 +429,8 @@ private fun menuSection(route: String?): MenuSection? = when (route) {
Destinations.CmsEinstellungen.route,
Destinations.CmsBenutzer.route,
Destinations.CmsPasswordResetDiagnostics.route,
Destinations.Cms.route -> MenuSection.INTERN
Destinations.Cms.route -> MenuSection.CMS
else -> null
}.let { section ->
if (section == null && route?.startsWith("mannschaften/") == true) MenuSection.MANNSCHAFTEN else section
@@ -319,42 +447,52 @@ private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuT
MenuTarget("Links", Destinations.Links.route),
MenuTarget("Impressum", Destinations.Impressum.route),
)
MenuSection.MANNSCHAFTEN -> listOf(
MenuTarget("Übersicht", Destinations.Mannschaften.route),
) + state.teams.map { MenuTarget(it.mannschaft, Destinations.MannschaftDetail.create(it.slug)) } + listOf(
MenuTarget("Spielpläne", Destinations.Spielplan.route),
MenuTarget("Spielsysteme", Destinations.Spielsysteme.route),
)
MenuSection.TRAINING -> listOf(
MenuTarget("Trainingszeiten", Destinations.Training.route),
MenuTarget("Trainer", Destinations.Trainer.route),
MenuTarget("Anfänger", Destinations.Anfaenger.route),
MenuTarget("TT-Regeln", Destinations.Regeln.route),
)
MenuSection.NEWSLETTER -> listOf(
MenuTarget("Abonnieren", Destinations.NewsletterSubscribe.route),
MenuTarget("Abmelden", Destinations.NewsletterUnsubscribe.route),
MenuTarget("Bestätigt", Destinations.NewsletterConfirmed.route),
)
MenuSection.INTERN -> buildList {
add(MenuTarget("Übersicht", Destinations.MemberArea.route))
add(MenuTarget("Mitgliederliste", Destinations.Members.route))
add(MenuTarget("QTTR", Destinations.Qttr.route))
add(MenuTarget("News", Destinations.MemberNews.route))
add(MenuTarget("Mein Profil", Destinations.Profile.route))
add(MenuTarget("Benachrichtigungen", Destinations.NotificationSettings.route))
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route))
if (state.isAdmin || state.canAccessContactRequests || state.canAccessNewsletter) add(MenuTarget("CMS", Destinations.Cms.route))
// CMS child items (will be rendered when CMS parent is expanded)
if (state.isAdmin || state.canAccessContactRequests || state.canAccessNewsletter) add(MenuTarget("Übersicht", Destinations.Cms.route))
if (state.isAdmin) add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
if (state.isAdmin) add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
if (state.isAdmin) add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
if (state.isAdmin) add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route))
if (state.isAdmin) add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
}
MenuSection.CMS -> buildList {
if (state.canAccessFullCms) {
add(MenuTarget("Übersicht", Destinations.Cms.route))
add(MenuTarget("Startseite", Destinations.CmsStartseite.route))
add(MenuTarget("Inhalte", Destinations.CmsInhalte.route))
add(MenuTarget("Vereinsmeisterschaften", Destinations.CmsVereinsmeisterschaften.route))
add(MenuTarget("News", Destinations.MemberNews.route))
add(MenuTarget("Sportbetrieb", Destinations.CmsSportbetrieb.route))
add(MenuTarget("Mitgliederverwaltung", Destinations.CmsMitgliederverwaltung.route))
add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
add(MenuTarget("Benutzerverwaltung", Destinations.CmsBenutzer.route))
}
if (state.canAccessNewsletter) add(MenuTarget("Newsletter", Destinations.CmsNewsletter.route))
if (state.canAccessContactRequests) add(MenuTarget("Kontaktanfragen", Destinations.CmsContactRequests.route))
if (state.isAdmin) add(MenuTarget("Einstellungen", Destinations.CmsEinstellungen.route))
if (state.isAdmin) add(MenuTarget("Benutzer", Destinations.CmsBenutzer.route))
if (state.isAdmin) add(MenuTarget("Reset-Diagnose", Destinations.CmsPasswordResetDiagnostics.route))
}
null -> emptyList()
}

View File

@@ -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))
}
}

View File

@@ -10,8 +10,12 @@ sealed class Destinations(val route: String) {
object Links : Destinations("verein/links")
object Impressum : Destinations("impressum")
object Mannschaften : Destinations("mannschaften")
object MannschaftDetail : Destinations("mannschaften/{slug}") {
fun create(slug: String): String = "mannschaften/$slug"
object MannschaftDetail : Destinations("mannschaften/{slug}?season={season}") {
fun create(slug: String, season: String? = null): String {
val encodedSlug = android.net.Uri.encode(slug)
val selectedSeason = season?.takeIf { it.isNotBlank() } ?: return "mannschaften/$encodedSlug"
return "mannschaften/$encodedSlug?season=${android.net.Uri.encode(selectedSeason)}"
}
}
object MannschaftLegacyDetail : Destinations("mannschaft/{slug}") {
fun create(slug: String): String = "mannschaft/$slug"
@@ -36,8 +40,10 @@ sealed class Destinations(val route: String) {
object Register : Destinations("register")
object MemberArea : Destinations("intern")
object Members : Destinations("intern/mitglieder")
object Qttr : Destinations("intern/qttr")
object MemberNews : Destinations("intern/news")
object Profile : Destinations("intern/profil")
object NotificationSettings : Destinations("intern/benachrichtigungen")
object MemberApi : Destinations("intern/api")
object CmsStartseite : Destinations("cms/startseite")
object CmsInhalte : Destinations("cms/inhalte")

View File

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

View File

@@ -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())
}

View File

@@ -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) {

View File

@@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -50,6 +49,7 @@ import de.harheimertc.data.PasswordResetMatchingUserDto
import de.harheimertc.data.PasswordResetAttemptDto
import de.harheimertc.data.PasswordResetStepDto
import de.harheimertc.ui.components.FormMessages
import de.harheimertc.ui.components.LoadingState
import de.harheimertc.ui.components.NativeRichTextEditor
import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.repositories.MeisterschaftResult
@@ -67,7 +67,7 @@ import java.util.Locale
fun CmsDashboardScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState()
CmsPage(navController, showBackNavigation, "CMS", "Verwaltung und interne Werkzeuge") {
if (state.loading) item { CircularProgressIndicator(color = Primary600) }
if (state.loading) item { LoadingState("CMS-Daten werden geladen...") }
item { CmsSummaryGrid(navController, state) }
}
}
@@ -85,7 +85,7 @@ fun CmsStartseiteScreen(navController: NavController, showBackNavigation: Boolea
CmsPage(navController, showBackNavigation, "Startseite konfigurieren", "Legen Sie die Reihenfolge der Elemente auf der Startseite fest.") {
when {
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
state.loading || config == null -> item { LoadingState("Startseitenkonfiguration wird geladen...") }
else -> {
item {
Button(
@@ -171,7 +171,7 @@ fun CmsInhalteScreen(navController: NavController, showBackNavigation: Boolean,
CmsPage(navController, showBackNavigation, "Inhalte", "Vereinsseiten und strukturierte Inhalte") {
when {
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
state.loading || config == null -> item { LoadingState("Inhalte werden geladen...") }
else -> {
item {
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {
@@ -262,7 +262,7 @@ fun CmsVereinsmeisterschaftenScreen(navController: NavController, showBackNaviga
CmsPage(navController, showBackNavigation, "Vereinsmeisterschaften", "Ergebnisse und Hinweise als CSV-Inhalt bearbeiten") {
when {
state.loading -> item { CircularProgressIndicator(color = Primary600) }
state.loading -> item { LoadingState("Vereinsmeisterschaften werden geladen...") }
else -> {
item {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
@@ -456,7 +456,7 @@ fun CmsSportbetriebScreen(navController: NavController, showBackNavigation: Bool
CmsPage(navController, showBackNavigation, "Sportbetrieb", "Trainingszeiten, Trainingsort und Trainer pflegen") {
when {
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
state.loading || config == null -> item { LoadingState("Sportbetriebsdaten werden geladen...") }
else -> {
item {
Button(
@@ -555,7 +555,7 @@ fun CmsMitgliederverwaltungScreen(navController: NavController, showBackNavigati
fun CmsContactRequestsScreen(navController: NavController, showBackNavigation: Boolean, viewModel: CmsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsState()
CmsPage(navController, showBackNavigation, "Kontaktanfragen", "Eingegangene Nachrichten") {
if (state.loading) item { CircularProgressIndicator(color = Primary600) }
if (state.loading) item { LoadingState("Kontaktanfragen werden geladen...") }
if (!state.loading && state.contactRequests.isEmpty()) item { EmptyCard("Keine Kontaktanfragen gefunden.") }
items(state.contactRequests.size) { index -> ContactRequestCard(state.contactRequests[index], viewModel) }
}
@@ -592,7 +592,7 @@ fun CmsNewsletterScreen(
var grpTargetGroup by remember { mutableStateOf("") }
var grpSendToExternal by remember { mutableStateOf(true) }
CmsPage(navController, showBackNavigation, "Newsletter", "Newsletter und Gruppen") {
if (state.loading) item { CircularProgressIndicator(color = Primary600) }
if (state.loading) item { LoadingState("Newsletter-Daten werden geladen...") }
item {
if (canWrite) Button(onClick = {
editingNewsletter = null
@@ -769,7 +769,7 @@ fun CmsEinstellungenScreen(navController: NavController, showBackNavigation: Boo
CmsPage(navController, showBackNavigation, "Einstellungen", "Vereins- und Website-Konfiguration") {
when {
state.loading || config == null -> item { CircularProgressIndicator(color = Primary600) }
state.loading || config == null -> item { LoadingState("Einstellungen werden geladen...") }
else -> {
item {
Button(
@@ -883,7 +883,7 @@ fun CmsPasswordResetDiagnosticsScreen(navController: NavController, showBackNavi
}
if (state.loading) {
item { CircularProgressIndicator(color = Primary600) }
item { LoadingState("Diagnosedaten werden geladen...") }
}
if (state.passwordResetSearchTerm.isNotBlank()) {
@@ -1073,7 +1073,7 @@ private fun CmsConfigPage(
) {
CmsPage(navController, showBackNavigation, title, subtitle) {
if (config == null) {
item { CircularProgressIndicator(color = Primary600) }
item { LoadingState("Konfiguration wird geladen...") }
} else {
item {
Surface(color = Color.White, shape = RoundedCornerShape(14.dp), shadowElevation = 3.dp) {

View File

@@ -3,6 +3,7 @@ package de.harheimertc.ui.screens.cms
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ConnectivityMonitor
import de.harheimertc.data.CmsUserDto
import de.harheimertc.data.ConfigResponse
import de.harheimertc.data.ContactRequestDto
@@ -43,12 +44,22 @@ data class CmsUiState(
@HiltViewModel
class CmsViewModel @Inject constructor(
private val repository: CmsRepository,
private val connectivityMonitor: ConnectivityMonitor,
) : ViewModel() {
private val _state = MutableStateFlow(CmsUiState())
val state: StateFlow<CmsUiState> = _state
init {
load()
viewModelScope.launch {
var wasOnline: Boolean? = null
connectivityMonitor.online.collect { online ->
if (online && wasOnline == false) {
load()
}
wasOnline = online
}
}
}
fun load() {
@@ -207,7 +218,7 @@ class CmsViewModel @Inject constructor(
}
}
fun bulkSetPublic(ids: List<Int>, makePublic: Boolean) {
fun bulkSetPublic(ids: List<String>, makePublic: Boolean) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
ids.forEach { id ->
@@ -229,7 +240,7 @@ class CmsViewModel @Inject constructor(
}
}
fun bulkSetHidden(ids: List<Int>, makeHidden: Boolean) {
fun bulkSetHidden(ids: List<String>, makeHidden: Boolean) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
ids.forEach { id ->
@@ -251,7 +262,7 @@ class CmsViewModel @Inject constructor(
}
}
fun bulkDelete(ids: List<Int>) {
fun bulkDelete(ids: List<String>) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
ids.forEach { id ->
@@ -262,7 +273,7 @@ class CmsViewModel @Inject constructor(
}
}
fun deleteNews(id: Int) {
fun deleteNews(id: String) {
viewModelScope.launch {
_state.value = _state.value.copy(saving = true, error = null, message = null)
repository.deleteNews(id)

View File

@@ -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 {

View File

@@ -44,15 +44,14 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import de.harheimertc.ui.navigation.NavigationViewModel
import androidx.navigation.NavController
import coil.compose.AsyncImage
import de.harheimertc.BuildConfig
import de.harheimertc.data.HomepageSectionDto
import de.harheimertc.data.NewsDto
import de.harheimertc.data.SeasonDto
@@ -77,9 +76,9 @@ import java.util.Locale
fun HomeScreen(
navController: NavController,
showNavigationHeader: Boolean = true,
viewModel: HomeViewModel = hiltViewModel(),
navigationViewModel: NavigationViewModel,
viewModel: HomeViewModel,
) {
val navigationViewModel: NavigationViewModel = hiltViewModel()
val navigationState by navigationViewModel.state.collectAsState()
val state by viewModel.state.collectAsState()
var selectedNews by remember { mutableStateOf<NewsDto?>(null) }
@@ -107,6 +106,13 @@ fun HomeScreen(
AppNavigationHeader(
selectedRoute = Destinations.Home.route,
onNavigate = navController::navigate,
onLogout = {
navigationViewModel.logout {
navController.navigate(Destinations.Home.route) {
launchSingleTop = true
}
}
},
navigationState = navigationState,
)
}
@@ -126,11 +132,54 @@ fun HomeScreen(
onReset = viewModel::resetSections,
)
}
if (state.error) {
item {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = state.errorMessage ?: "Daten konnten nicht geladen werden.",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
OutlinedButton(onClick = viewModel::load) {
Text("Erneut versuchen")
}
}
}
}
if (state.debugDiagnostics.isNotEmpty()) {
item {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Technische Diagnose (vorübergehend)",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.titleSmall,
)
Text(
text = state.debugDiagnostics.joinToString("\n\n---\n\n"),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.error,
)
}
}
}
state.homepageSections.forEachIndexed { index, section ->
if (!section.enabled) return@forEachIndexed
val sectionKey = homeSectionKey(section)
when (section.id) {
"banner" -> item(key = "home_section_${sectionKey}_$index") { WebHero() }
"banner" -> item(key = "home_section_${sectionKey}_$index") {
WebHero(imageUrl = state.heroImageUrl)
}
"termine" -> item(key = "home_section_${sectionKey}_$index") {
HomeTermineSection(
termine = state.termine,
@@ -454,7 +503,7 @@ private fun HomeSpielplanTeamWidgetSection(
}
@Composable
private fun WebHero() {
private fun WebHero(imageUrl: String?) {
val years = Calendar.getInstance().get(Calendar.YEAR) - 1954
Box(
modifier = Modifier
@@ -463,12 +512,14 @@ private fun WebHero() {
.background(Brush.verticalGradient(listOf(Color(0xFFFAFAFA), Color(0xFFF4F4F5)))),
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = "${BuildConfig.API_BASE_URL}images/club_about_us.png",
contentDescription = null,
modifier = Modifier.matchParentSize().alpha(0.10f),
contentScale = ContentScale.Crop,
)
if (!imageUrl.isNullOrBlank()) {
AsyncImage(
model = imageUrl,
contentDescription = null,
modifier = Modifier.matchParentSize().alpha(0.10f),
contentScale = ContentScale.Crop,
)
}
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 58.dp),
horizontalAlignment = Alignment.CenterHorizontally,

View File

@@ -34,6 +34,7 @@ data class HomeSpielplanTeamOption(
data class HomeUiState(
val loading: Boolean = true,
val heroImageUrl: String? = null,
val termine: List<TerminDto> = emptyList(),
val spiele: List<SpielDto> = emptyList(),
val news: List<NewsDto> = emptyList(),
@@ -44,6 +45,8 @@ data class HomeUiState(
val spielplanWidgetErrors: Map<String, String> = emptyMap(),
val widgetsLoading: Boolean = false,
val error: Boolean = false,
val errorMessage: String? = null,
val debugDiagnostics: List<String> = emptyList(),
)
@HiltViewModel
@@ -62,7 +65,12 @@ class HomeViewModel @Inject constructor(
fun load() {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = false)
_state.value = _state.value.copy(
loading = true,
error = false,
errorMessage = null,
debugDiagnostics = emptyList(),
)
repository.fetchHomeData()
.onSuccess { data ->
serverSections = normalizedHomepageSections(data.homepageSections)
@@ -80,6 +88,7 @@ class HomeViewModel @Inject constructor(
)
_state.value = HomeUiState(
loading = false,
heroImageUrl = data.heroImageUrl,
termine = data.termine
.filter { it.asDateTime()?.isBefore(LocalDateTime.now()) != true }
.sortedBy { it.asDateTime() }
@@ -99,10 +108,16 @@ class HomeViewModel @Inject constructor(
spielplanTeamsBySeason = widgetData.teamsBySeason,
spielplanWidgetPreviews = widgetData.previewGamesBySectionKey,
spielplanWidgetErrors = widgetData.errorsBySectionKey,
debugDiagnostics = data.diagnostics,
)
}
.onFailure {
_state.value = HomeUiState(loading = false, error = true)
.onFailure { err ->
_state.value = HomeUiState(
loading = false,
error = true,
errorMessage = err.message ?: "Daten konnten nicht geladen werden.",
debugDiagnostics = listOf(err.message ?: "Unbekannter Fehler"),
)
}
}
}

View File

@@ -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.")
}
}

View File

@@ -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

View File

@@ -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.",
)
}
}

View File

@@ -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&current-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)) {

View File

@@ -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.")
}
}
}
}

View File

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

View File

@@ -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() {

View File

@@ -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) {

View File

@@ -0,0 +1,276 @@
package de.harheimertc.ui.screens.notifications
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import de.harheimertc.notifications.HarheimerNotifications
import de.harheimertc.repositories.Mannschaft
import de.harheimertc.repositories.NotificationPreferences
import de.harheimertc.ui.components.LoadingState
import de.harheimertc.ui.navigation.NavigationUiState
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent700
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
private val notificationTimes = (6..22).flatMap { hour ->
listOf("%02d:00".format(hour), "%02d:30".format(hour))
}.dropLast(1)
@Composable
fun NotificationSettingsScreen(
navController: NavController,
showBackNavigation: Boolean,
navigationState: NavigationUiState,
viewModel: NotificationSettingsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
var hasPermission by remember { mutableStateOf(HarheimerNotifications.hasNotificationPermission(context)) }
val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
hasPermission = granted || HarheimerNotifications.hasNotificationPermission(context)
}
val isBoard = "vorstand" in navigationState.roles
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item {
if (showBackNavigation) {
TextButton(onClick = { navController.popBackStack() }) {
Text("< Intern", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
Text("Benachrichtigungen", style = MaterialTheme.typography.displayLarge, color = Accent900)
Text("Persönliche Android-Benachrichtigungen verwalten.", color = Accent500, modifier = Modifier.padding(top = 8.dp))
}
item {
NotificationCard("Android-Berechtigung") {
val permissionText = if (hasPermission) {
"Benachrichtigungen sind im Android-System erlaubt."
} else {
"Benachrichtigungen sind im Android-System noch nicht erlaubt."
}
Text(permissionText, color = if (hasPermission) Color(0xFF166534) else Accent700)
if (!hasPermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Button(
onClick = { permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) },
modifier = Modifier.fillMaxWidth(),
) {
Text("Berechtigung anfordern")
}
}
}
}
if (state.loading) {
item { LoadingState("Benachrichtigungseinstellungen werden geladen...") }
} else {
item {
NotificationCard("Benachrichtigungszeit") {
Text("Automatische Benachrichtigungen werden zu dieser Uhrzeit ausgelöst.", color = Accent700)
TimeSelection(state.settings.notificationTime) { selectedTime ->
viewModel.update(state.settings.copy(notificationTime = selectedTime))
}
}
}
item {
NotificationCard("News") {
ToggleRow("Neue News", state.settings.newNews) {
viewModel.update(state.settings.copy(newNews = it))
}
}
}
item {
NotificationCard("Termine") {
ToggleRow("Neue Termine", state.settings.newEvents) {
viewModel.update(state.settings.copy(newEvents = it))
}
ToggleRow("Termine von heute", state.settings.eventsToday) {
viewModel.update(state.settings.copy(eventsToday = it))
}
ToggleRow("Termine von morgen", state.settings.eventsTomorrow) {
viewModel.update(state.settings.copy(eventsTomorrow = it))
}
}
}
item {
NotificationCard("Punktspiele") {
ToggleRow("Punktspiele der eigenen Mannschaft", state.settings.ownTeamMatches) {
viewModel.update(state.settings.copy(ownTeamMatches = it))
}
Text("Die eigene Mannschaft wird später aus den Mannschaftsdefinitionen der aktuellen Saison ermittelt.", color = Accent700)
ToggleRow("Punktspiele aller Mannschaften", state.settings.allTeamMatches) {
viewModel.update(state.settings.copy(allTeamMatches = it))
}
TeamSelection(
teams = state.teams,
seasons = state.seasons,
selectedSeason = state.settings.selectedTeamSeason,
settings = state.settings,
onSelectSeason = viewModel::selectSeason,
onToggleTeam = viewModel::toggleTeam,
)
}
}
item {
NotificationCard("Mitglieder") {
ToggleRow("Geburtstage", state.settings.birthdays) {
viewModel.update(state.settings.copy(birthdays = it))
}
}
}
if (isBoard) {
item {
NotificationCard("Vorstand") {
ToggleRow("Neue Kontaktanfrage", state.settings.newContactRequest) {
viewModel.update(state.settings.copy(newContactRequest = it))
}
ToggleRow("Neue Benutzerregistrierung", state.settings.newUserRegistration) {
viewModel.update(state.settings.copy(newUserRegistration = it))
}
}
}
}
}
state.saveError?.let { message ->
item { Text(message, color = MaterialTheme.colorScheme.error) }
}
state.error?.let { message ->
item {
Text(message, color = MaterialTheme.colorScheme.error)
TextButton(onClick = viewModel::load) { Text("Erneut laden") }
}
}
}
}
@Composable
private fun NotificationCard(title: String, content: @Composable ColumnScope.() -> Unit) {
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 2.dp) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
content()
}
}
}
@Composable
private fun ToggleRow(label: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth(),
) {
Text(label, color = Accent900, modifier = Modifier.weight(1f))
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
@Composable
private fun TimeSelection(selectedTime: String, onSelectTime: (String) -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
notificationTimes.forEach { time ->
if (time == selectedTime) {
Button(onClick = { onSelectTime(time) }) { Text(time) }
} else {
OutlinedButton(onClick = { onSelectTime(time) }) { Text(time) }
}
}
}
}
@Composable
private fun TeamSelection(
teams: List<Mannschaft>,
seasons: List<String>,
selectedSeason: String?,
settings: NotificationPreferences,
onSelectSeason: (String) -> Unit,
onToggleTeam: (String, Boolean) -> Unit,
) {
Surface(color = Primary100, shape = RoundedCornerShape(10.dp)) {
Column(Modifier.fillMaxWidth().padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Ausgewählte Mannschaften", color = Accent900, fontWeight = FontWeight.SemiBold)
Text("Zusätzlich einzelne Mannschaften abonnieren.", color = Accent700)
if (seasons.isNotEmpty()) {
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
seasons.forEach { season ->
val selected = season == selectedSeason
if (selected) {
Button(onClick = { onSelectSeason(season) }) { Text(season) }
} else {
OutlinedButton(onClick = { onSelectSeason(season) }) { Text(season) }
}
}
}
}
if (teams.isEmpty()) {
Text("Keine Mannschaften verfügbar.", color = Accent700)
} else {
teams.forEach { team ->
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Checkbox(
checked = team.slug in settings.selectedTeamSlugs,
onCheckedChange = { onToggleTeam(team.slug, it) },
)
Text(team.mannschaft, color = Accent900)
}
}
}
}
}
}

View File

@@ -0,0 +1,110 @@
package de.harheimertc.ui.screens.notifications
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.repositories.Mannschaft
import de.harheimertc.repositories.MannschaftenRepository
import de.harheimertc.repositories.NotificationPreferences
import de.harheimertc.repositories.NotificationPreferencesRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class NotificationSettingsUiState(
val loading: Boolean = true,
val settings: NotificationPreferences = NotificationPreferences(),
val teams: List<Mannschaft> = emptyList(),
val seasons: List<String> = emptyList(),
val error: String? = null,
val saveError: String? = null,
)
@HiltViewModel
class NotificationSettingsViewModel @Inject constructor(
private val preferencesRepository: NotificationPreferencesRepository,
private val mannschaftenRepository: MannschaftenRepository,
) : ViewModel() {
private val _state = MutableStateFlow(NotificationSettingsUiState())
val state: StateFlow<NotificationSettingsUiState> = _state
init {
load()
}
fun load() {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null, saveError = null)
val storedSettings = preferencesRepository.loadRemote().getOrElse { preferencesRepository.loadLocal() }
val seasonsResponse = mannschaftenRepository.fetchSeasons().getOrNull()
val seasons = seasonsResponse?.seasons.orEmpty()
val selectedSeason = storedSettings.selectedTeamSeason
?: seasonsResponse?.defaultSeason?.takeIf { it.isNotBlank() }
?: seasons.firstOrNull()
loadTeams(storedSettings.copy(selectedTeamSeason = selectedSeason), seasons)
}
}
fun selectSeason(season: String) {
val current = _state.value.settings
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null, saveError = null)
loadTeams(current.copy(selectedTeamSeason = season), _state.value.seasons, syncRemote = true)
}
}
fun update(settings: NotificationPreferences) {
preferencesRepository.saveLocal(settings)
_state.value = _state.value.copy(settings = settings, saveError = null)
viewModelScope.launch {
preferencesRepository.saveRemote(settings)
.onSuccess { saved -> _state.value = _state.value.copy(settings = saved, saveError = null) }
.onFailure { error ->
_state.value = _state.value.copy(saveError = error.message ?: "Benachrichtigungseinstellungen konnten nicht gespeichert werden.")
}
}
}
fun toggleTeam(slug: String, selected: Boolean) {
val current = _state.value.settings
val nextTeams = if (selected) {
current.selectedTeamSlugs + slug
} else {
current.selectedTeamSlugs - slug
}
update(current.copy(selectedTeamSlugs = nextTeams))
}
private suspend fun loadTeams(settings: NotificationPreferences, seasons: List<String>, syncRemote: Boolean = false) {
mannschaftenRepository.fetchMannschaften(settings.selectedTeamSeason)
.onSuccess { teams ->
val knownSlugs = teams.map { it.slug }.toSet()
val nextSettings = settings.copy(selectedTeamSlugs = settings.selectedTeamSlugs.intersect(knownSlugs))
preferencesRepository.saveLocal(nextSettings)
val saveError = if (syncRemote) {
preferencesRepository.saveRemote(nextSettings).exceptionOrNull()?.message
} else null
_state.value = NotificationSettingsUiState(
loading = false,
settings = nextSettings,
teams = teams,
seasons = seasons,
saveError = saveError,
)
}
.onFailure { error ->
preferencesRepository.saveLocal(settings)
val saveError = if (syncRemote) {
preferencesRepository.saveRemote(settings).exceptionOrNull()?.message
} else null
_state.value = NotificationSettingsUiState(
loading = false,
settings = settings,
seasons = seasons,
error = error.message ?: "Mannschaften konnten nicht geladen werden.",
saveError = saveError,
)
}
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

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

View 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>

View File

@@ -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 {

File diff suppressed because one or more lines are too long

View 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=

View File

@@ -8,14 +8,15 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/
PRODUCTION_API_BASE_URL=https://harheimertc.de/
# Android app versioning for Play Store uploads
ANDROID_VERSION_CODE=4
ANDROID_VERSION_NAME=1.0.0
ANDROID_VERSION_CODE=25
ANDROID_VERSION_NAME=0.9.20
# Enable R8 for release by default so mapping.txt is generated for Play Console.
RELEASE_MINIFY_ENABLED=true
# Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping.
RELEASE_MINIFY_ENABLED=false
# Release signing (set in local, untracked gradle.properties or via CI secrets)
# RELEASE_STORE_FILE=/absolute/path/to/keystore.jks
RELEASE_STORE_FILE=/home/torsten/android\ keystore/harheimertc.jks
# Keep secrets out of git. Use ~/.gradle/gradle.properties or environment variables.
# RELEASE_STORE_PASSWORD=***
# RELEASE_KEY_ALIAS=***
# RELEASE_KEY_PASSWORD=***

Binary file not shown.

After

Width:  |  Height:  |  Size: 814 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 863 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 977 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

View File

@@ -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

View File

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

View File

@@ -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;
}
}

View 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.

View File

@@ -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;

View File

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

View File

@@ -1,7 +1,6 @@
<template>
<section
v-if="news.length > 0"
class="py-16 sm:py-20 bg-white"
class="py-16 sm:py-20 bg-white min-h-[32rem]"
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
@@ -14,7 +13,29 @@
</p>
</div>
<div class="flex justify-center">
<div
v-if="isLoading"
class="grid gap-8 grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
>
<div
v-for="placeholder in 3"
:key="`news-placeholder-${placeholder}`"
class="bg-gray-50 rounded-xl p-6 border border-gray-200"
>
<div class="h-4 w-32 bg-gray-200 rounded animate-pulse mb-4" />
<div class="h-7 w-3/4 bg-gray-200 rounded animate-pulse mb-4" />
<div class="space-y-2">
<div class="h-4 w-full bg-gray-200 rounded animate-pulse" />
<div class="h-4 w-5/6 bg-gray-200 rounded animate-pulse" />
<div class="h-4 w-2/3 bg-gray-200 rounded animate-pulse" />
</div>
</div>
</div>
<div
v-else-if="news.length > 0"
class="flex justify-center"
>
<div
class="grid gap-8"
:class="getGridClass()"
@@ -43,61 +64,85 @@
</article>
</div>
</div>
</div>
<!-- News Modal -->
<div
v-if="selectedNews"
class="fixed inset-0 z-50 bg-black bg-opacity-50 flex items-center justify-center p-4"
@click.self="closeNewsModal"
>
<div class="bg-white rounded-xl shadow-2xl max-w-4xl w-full max-h-[90vh] flex flex-col">
<!-- Modal Header -->
<div class="flex items-center justify-between p-6 border-b border-gray-200">
<div class="flex-1">
<div class="flex items-center text-sm text-gray-500 mb-2">
<Calendar
:size="16"
class="mr-2"
/>
{{ formatDate(selectedNews.created) }}
</div>
<h2 class="text-2xl font-display font-bold text-gray-900">
{{ selectedNews.title }}
</h2>
</div>
<button
class="ml-4 p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
@click="closeNewsModal"
>
<X :size="24" />
</button>
</div>
<!-- Modal Content (scrollable) -->
<div class="p-6 overflow-y-auto flex-1">
<div class="prose max-w-none text-gray-700 whitespace-pre-wrap">
{{ selectedNews.content }}
</div>
</div>
<div
v-else
class="max-w-xl mx-auto text-center bg-gray-50 border border-gray-200 rounded-xl p-8"
>
<p class="text-gray-700 font-semibold mb-2">
Aktuell keine News
</p>
<p class="text-gray-600 text-sm">
Neue Vereinsnachrichten erscheinen hier automatisch.
</p>
</div>
</div>
<Teleport to="body">
<Transition name="news-modal">
<div
v-if="selectedNews"
class="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950/65 px-4 py-6 sm:px-6"
role="dialog"
aria-modal="true"
:aria-labelledby="modalTitleId"
@click.self="closeNewsModal"
>
<article class="w-full max-w-3xl max-h-[min(44rem,calc(100vh-3rem))] overflow-hidden rounded-lg bg-white shadow-2xl ring-1 ring-black/10 flex flex-col">
<header class="flex items-start gap-4 border-b border-gray-200 bg-white px-5 py-4 sm:px-7 sm:py-6">
<div class="min-w-0 flex-1">
<div class="mb-3 inline-flex items-center gap-2 rounded-full bg-primary-50 px-3 py-1 text-sm font-medium text-primary-800">
<Calendar :size="15" />
<time :datetime="selectedNews.created">
{{ formatDate(selectedNews.created) }}
</time>
</div>
<h2
:id="modalTitleId"
class="text-2xl sm:text-3xl font-display font-bold leading-tight text-gray-950 break-words"
>
{{ selectedNews.title }}
</h2>
</div>
<button
type="button"
class="shrink-0 rounded-md border border-gray-200 bg-white p-2 text-gray-500 shadow-sm transition-colors hover:bg-gray-50 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary-500"
aria-label="News schließen"
@click="closeNewsModal"
>
<X :size="20" />
</button>
</header>
<div class="overflow-y-auto px-5 py-5 sm:px-7 sm:py-6">
<div class="news-modal-content text-base leading-7 text-gray-800 sm:text-lg sm:leading-8">
{{ selectedNews.content }}
</div>
</div>
</article>
</div>
</Transition>
</Teleport>
</section>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { Calendar, X } from 'lucide-vue-next'
const news = ref([])
const selectedNews = ref(null)
const isLoading = ref(true)
const modalTitleId = 'public-news-modal-title'
const loadNews = async () => {
try {
const response = await $fetch('/api/news-public')
news.value = response.news
news.value = Array.isArray(response?.news) ? response.news : []
} catch (error) {
console.error('Fehler beim Laden der öffentlichen News:', error)
} finally {
isLoading.value = false
}
}
@@ -128,19 +173,30 @@ const getGridClass = () => {
const openNewsModal = (item) => {
selectedNews.value = item
// Verhindere Scrollen im Hintergrund
document.body.style.overflow = 'hidden'
document.addEventListener('keydown', handleModalKeydown)
}
const closeNewsModal = () => {
selectedNews.value = null
// Erlaube Scrollen wieder
document.body.style.overflow = ''
document.removeEventListener('keydown', handleModalKeydown)
}
const handleModalKeydown = (event) => {
if (event.key === 'Escape') {
closeNewsModal()
}
}
onMounted(() => {
loadNews()
})
onUnmounted(() => {
document.body.style.overflow = ''
document.removeEventListener('keydown', handleModalKeydown)
})
</script>
<style scoped>
@@ -150,5 +206,20 @@ onMounted(() => {
-webkit-box-orient: vertical;
overflow: hidden;
}
.news-modal-content {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.news-modal-enter-active,
.news-modal-leave-active {
transition: opacity 160ms ease;
}
.news-modal-enter-from,
.news-modal-leave-to {
opacity: 0;
}
</style>

View File

@@ -57,7 +57,10 @@ has_tracked_files_under() {
install_dependencies() {
if [ -f "package-lock.json" ]; then
echo " Running: npm ci"
npm ci
if ! npm ci; then
echo " WARNING: npm ci fehlgeschlagen (Lockfile ggf. nicht synchron). Fallback auf npm install..."
npm install
fi
else
echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
npm install
@@ -92,6 +95,10 @@ install_dependencies_if_needed() {
echo " package-lock.json unverändert, überspringe npm ci"
fi
if [ -f "package-lock.json" ]; then
current_lock_hash="$(sha256sum package-lock.json | awk '{print $1}')"
fi
printf '%s\n' "$current_lock_hash" > "$lock_hash_file"
}

View File

@@ -70,7 +70,10 @@ has_tracked_files_under() {
install_dependencies() {
if [ -f "package-lock.json" ]; then
echo " Running: npm ci"
npm ci
if ! npm ci; then
echo " WARNING: npm ci fehlgeschlagen (Lockfile ggf. nicht synchron). Fallback auf npm install..."
npm install
fi
else
echo " WARNING: package-lock.json fehlt. Führe npm install aus..."
npm install
@@ -105,6 +108,10 @@ install_dependencies_if_needed() {
echo " package-lock.json unverändert, überspringe npm ci"
fi
if [ -f "package-lock.json" ]; then
current_lock_hash="$(sha256sum package-lock.json | awk '{print $1}')"
fi
printf '%s\n' "$current_lock_hash" > "$lock_hash_file"
}

View File

@@ -76,13 +76,7 @@ export default defineNuxtConfig({
{ property: 'twitter:description', content: 'Offizielle Website des Harheimer Tischtennis-Club 1954 e.V.' }
],
link: [
{ rel: 'canonical', href: 'https://www.harheimertc.de/' },
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Montserrat:wght@700;800;900&display=swap'
}
{ rel: 'canonical', href: 'https://www.harheimertc.de/' }
]
}
},

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "harheimertc-website",
"version": "1.6.2",
"version": "1.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "harheimertc-website",
"version": "1.6.2",
"version": "1.7.0",
"hasInstallScript": true,
"dependencies": {
"@pinia/nuxt": "^0.11.2",

View File

@@ -1,6 +1,6 @@
{
"name": "harheimertc-website",
"version": "1.7.0",
"version": "1.8.1",
"description": "Moderne Webseite für den Harheimer Tischtennis Club",
"private": true,
"type": "module",
@@ -16,13 +16,18 @@
"start": "nuxt start --port 3100",
"postinstall": "nuxt prepare",
"test": "vitest run",
"test:data-rotation": "vitest run tests/data-file-rotation.spec.ts",
"check-security": "node scripts/verify-no-public-writes.js",
"smoke-local": "BASE_URL=http://127.0.0.1:3100 node scripts/smoke-tests.js",
"sync-public-data": "node scripts/sync-public-data.js",
"data-backups:list": "node scripts/data-backup-restore.js list",
"data-backups:restore": "node scripts/data-backup-restore.js restore",
"hero:prepare": "node scripts/prepare-hero-variants.mjs",
"import-spielplan": "node scripts/import-spielplan.js",
"publish-spielplan": "node scripts/publish-imported-spielplan.js",
"playstore:assets": "./scripts/playstore-assets.sh",
"playstore:anonymize": "./scripts/anonymize-playstore-screenshot.sh",
"playstore:screenshots": "./scripts/playstore-screenshot-sizes.sh",
"test:watch": "vitest watch",
"lint": "eslint . --fix"
},

View File

@@ -207,8 +207,31 @@
</div>
</div>
<template v-if="heroSection">
<component :is="getComponentForSection(heroSection.id)" />
</template>
<div
v-if="featuredWidgetSection"
class="relative z-30 px-4 sm:px-6 lg:px-8"
:class="hasHeroSection ? '-mt-44 sm:-mt-48 lg:-mt-52' : 'mt-8'"
>
<div class="featured-widget-shell max-w-6xl mx-auto rounded-2xl border border-gray-200 bg-white/25 backdrop-blur-md overflow-hidden min-h-[22rem] sm:min-h-[25rem]">
<HomeSpielplanTeamWidget
v-if="featuredWidgetSection.id === 'spielplan_team'"
:season="featuredWidgetSection.config?.season"
:team-name="featuredWidgetSection.config?.teamName"
:team-age-group="featuredWidgetSection.config?.teamAgeGroup"
/>
<component
:is="getComponentForSection(featuredWidgetSection.id)"
v-else
/>
</div>
</div>
<template
v-for="section in enabledSections"
v-for="section in remainingWidgetSections"
:key="section.key"
>
<HomeSpielplanTeamWidget
@@ -226,17 +249,39 @@
</template>
<script setup>
import { computed, ref } from 'vue'
import { computed, defineAsyncComponent, h, ref } from 'vue'
import { SlidersHorizontal, X } from 'lucide-vue-next'
import Hero from '~/components/Hero.vue'
import HomeTermine from '~/components/HomeTermine.vue'
import Spielplan from '~/components/Spielplan.vue'
import PublicNews from '~/components/PublicNews.vue'
import HomeActions from '~/components/HomeActions.vue'
import HomeTrainingTeaser from '~/components/HomeTrainingTeaser.vue'
import HomeLinksTeaser from '~/components/HomeLinksTeaser.vue'
import HomeVereinsmeisterschaftenTeaser from '~/components/HomeVereinsmeisterschaftenTeaser.vue'
import HomeSpielplanTeamWidget from '~/components/HomeSpielplanTeamWidget.vue'
const SectionLoadingPlaceholder = {
name: 'SectionLoadingPlaceholder',
render() {
return h('section', { class: 'py-16 sm:py-20 bg-white' }, [
h('div', { class: 'max-w-7xl mx-auto px-4 sm:px-6 lg:px-8' }, [
h('div', { class: 'h-10 max-w-xs mx-auto rounded bg-gray-200 animate-pulse mb-8' }),
h('div', { class: 'h-56 rounded-2xl bg-gray-100 animate-pulse' })
])
])
}
}
function loadHomeSection(loader) {
return defineAsyncComponent({
loader,
loadingComponent: SectionLoadingPlaceholder,
delay: 0,
suspensible: false
})
}
const HomeTermine = loadHomeSection(() => import('~/components/HomeTermine.vue'))
const Spielplan = loadHomeSection(() => import('~/components/Spielplan.vue'))
const PublicNews = loadHomeSection(() => import('~/components/PublicNews.vue'))
const HomeActions = loadHomeSection(() => import('~/components/HomeActions.vue'))
const HomeTrainingTeaser = loadHomeSection(() => import('~/components/HomeTrainingTeaser.vue'))
const HomeLinksTeaser = loadHomeSection(() => import('~/components/HomeLinksTeaser.vue'))
const HomeVereinsmeisterschaftenTeaser = loadHomeSection(() => import('~/components/HomeVereinsmeisterschaftenTeaser.vue'))
const HomeSpielplanTeamWidget = loadHomeSection(() => import('~/components/HomeSpielplanTeamWidget.vue'))
const { data: config } = await useFetch('/api/config')
const { data: authStatus } = await useFetch('/api/auth/status')
@@ -344,6 +389,11 @@ function applyPersonalization(baseSections, settingsSections) {
const resolvedSections = computed(() => applyPersonalization([...sections.value], personalizedSections.value))
const enabledSections = computed(() => resolvedSections.value.filter(section => section.enabled !== false))
const heroSection = computed(() => enabledSections.value.find(section => section.id === 'banner') || null)
const hasHeroSection = computed(() => !!heroSection.value)
const widgetSections = computed(() => enabledSections.value.filter(section => section.id !== 'banner'))
const featuredWidgetSection = computed(() => widgetSections.value[0] || null)
const remainingWidgetSections = computed(() => widgetSections.value.slice(1))
const componentMap = {
banner: Hero,
@@ -560,3 +610,26 @@ async function saveEditor() {
}
}
</script>
<style scoped>
.featured-widget-shell :deep(section),
.featured-widget-shell :deep(.bg-white),
.featured-widget-shell :deep(.bg-gray-50) {
--tw-bg-opacity: 0 !important;
background: transparent !important;
background-color: transparent !important;
}
.featured-widget-shell :deep([class*='bg-white']),
.featured-widget-shell :deep([class*='bg-gray-50']) {
--tw-bg-opacity: 0 !important;
background: transparent !important;
background-color: transparent !important;
}
.featured-widget-shell :deep(.py-16),
.featured-widget-shell :deep(.sm\:py-20) {
padding-top: 1.5rem !important;
padding-bottom: 2rem !important;
}
</style>

View File

@@ -0,0 +1,176 @@
<template>
<div class="min-h-full py-16 bg-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 space-y-8">
<div>
<h1 class="text-4xl sm:text-5xl font-display font-bold text-gray-900 mb-4">
QTTR-Werte
</h1>
<div class="w-24 h-1 bg-primary-600 mb-6" />
<p class="text-lg text-gray-700 max-w-3xl">
Aus technischen Gründen sind nur die QTTR-Werte verfügbar. Für TTR bitte auf
<a
:href="externalUrl"
target="_blank"
rel="noopener noreferrer"
class="text-primary-600 hover:text-primary-800 underline"
>myTischtennis</a>
wechseln.
</p>
</div>
<div class="bg-white rounded-xl shadow-lg border border-gray-100 p-6">
<div class="flex flex-wrap items-center gap-4 justify-between mb-4">
<div>
<h2 class="text-xl font-semibold text-gray-900">
Harheimer TC Rangliste
</h2>
<p class="text-sm text-gray-600">
{{ data?.title || 'Andro-Rangliste' }} · {{ data?.rowCount || 0 }} Einträge
</p>
</div>
<div class="text-sm text-gray-500">
Aktualisiert: {{ formatDate(data?.importedAt) }}
</div>
</div>
<div v-if="pending" class="py-12 text-center text-gray-500">
Lade QTTR-Werte...
</div>
<div v-else-if="error" class="rounded-lg border border-red-200 bg-red-50 p-4 text-red-800">
{{ error.statusMessage || error.message || 'QTTR-Werte konnten nicht geladen werden.' }}
</div>
<div v-else class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Rang
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Spieler
</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
Verein
</th>
<th class="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500">
QTTR
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 bg-white">
<tr
v-for="row in data?.rows || []"
:key="`${row.rank}-${row.playerNumber || row.playerName}`"
:class="isOwnRow(row.playerName) ? 'bg-primary-100' : ''"
>
<td class="px-4 py-3 text-sm text-gray-600">
{{ row.rank ?? '' }}
</td>
<td class="px-4 py-3">
<div :class="['font-medium', getPlayerNameClass(row)]">
{{ row.playerName || 'Unbekannt' }}
</div>
</td>
<td class="px-4 py-3 text-sm text-gray-700">
{{ row.clubName || 'Harheimer TC' }}
</td>
<td class="px-4 py-3 text-right text-lg font-semibold text-gray-900 tabular-nums">
{{ row.currentQttr ?? '' }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const authStore = useAuthStore()
const externalUrl = 'https://www.mytischtennis.de/rankings/andro-rangliste?continent=all&country=Deutschland&all-players=on&as=DE.WE.R4.07&di=DE.WE.R4.07.04&area=DE.WE.R4.07.04.43&clubnr-search=Harheimer+TC&clubnr=43030&fednickname=HeTTV&gender=all&current-ranking=yes&ttr-range=100%3B3000&birth-range=1926%3B2021'
definePageMeta({
middleware: 'auth',
layout: 'default'
})
await authStore.checkAuth()
const { data, pending, error } = await useFetch('/api/mitgliederbereich/qttr')
const currentUserName = computed(() => authStore.user?.name?.trim() || '')
function normalizeName(value) {
return String(value || '').trim().toLowerCase().replace(/\s+/g, ' ')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/['`]/g, '')
}
function isMaleGender(value) {
const gender = normalizeName(value)
return gender.startsWith('m') || gender.includes('mann') || gender.includes('maenn')
}
function isFemaleGender(value) {
const gender = normalizeName(value)
return gender.startsWith('w') || gender.includes('weib') || gender.includes('frau')
}
function isOwnRow(playerName) {
const current = normalizeName(currentUserName.value)
if (!current) return false
return normalizeName(playerName) === current
}
function getPlayerNameClass(row) {
const minor = isMinor(row.birthdate)
if (minor && isMaleGender(row.gender)) return 'text-blue-400'
if (minor && isFemaleGender(row.gender)) return 'text-pink-400'
if (isMaleGender(row.gender)) return 'text-blue-700'
if (isFemaleGender(row.gender)) return 'text-pink-800'
return 'text-gray-900'
}
function isMinor(birthdate) {
const date = parseBirthdate(birthdate)
if (!date) return false
const today = new Date()
let age = today.getFullYear() - date.getFullYear()
const monthDiff = today.getMonth() - date.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < date.getDate())) {
age -= 1
}
return age < 18
}
function parseBirthdate(value) {
const raw = String(value || '').trim()
if (!raw) return null
if (/^\d{4}$/.test(raw)) {
const parsed = new Date(Number(raw), 0, 1)
return Number.isNaN(parsed.getTime()) ? null : parsed
}
const parsed = new Date(raw)
return Number.isNaN(parsed.getTime()) ? null : parsed
}
function formatDate(value) {
if (!value) return 'unbekannt'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return 'unbekannt'
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'medium',
timeStyle: 'short'
}).format(date)
}
useHead({
title: 'QTTR-Werte - Harheimer TC'
})
</script>

View File

@@ -6,7 +6,7 @@
Passwort zurücksetzen
</h2>
<p class="mt-2 text-sm text-gray-600">
Geben Sie Ihre E-Mail-Adresse ein, um Ihr Passwort zurückzusetzen
Geben Sie Ihre E-Mail-Adresse ein, um einen Reset-Link zu erhalten
</p>
</div>

Some files were not shown because too many files have changed in this diff Show More