Android-Umsetzung der Homepage
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m22s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped

This commit is contained in:
Torsten Schulz (local)
2026-05-27 17:54:24 +02:00
parent 817f5e02ca
commit 7e0c92368e
6816 changed files with 111919 additions and 53 deletions

View File

@@ -1,9 +1,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="de.harheimertc">
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".HarheimerApplication"
android:label="HarheimerTC"
android:icon="@mipmap/ic_launcher">
android:theme="@style/Theme.HarheimerTC"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity android:name="de.harheimertc.MainActivity"
android:exported="true">
<intent-filter>
@@ -11,5 +15,14 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.files"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
</manifest>

View File

@@ -0,0 +1,7 @@
package de.harheimertc
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class HarheimerApplication : Application()

View File

@@ -3,19 +3,33 @@ package de.harheimertc
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.navigation.compose.rememberNavController
import dagger.hilt.android.AndroidEntryPoint
import de.harheimertc.ui.navigation.NavGraph
import de.harheimertc.ui.theme.HarheimerTheme
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Text("HarheimerTC - App Scaffold")
App()
}
}
}
@Preview
fun PreviewMain() {
Text("Preview")
@Composable
fun App() {
HarheimerTheme {
val navController = rememberNavController()
NavGraph(navController = navController)
}
}
@Preview
@Composable
fun PreviewMain() {
App()
}

View File

@@ -0,0 +1,269 @@
package de.harheimertc.data
import com.squareup.moshi.Json
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
import retrofit2.http.Url
import retrofit2.http.Streaming
import okhttp3.ResponseBody
data class ContactRequest(val name: String, val email: String, val message: String)
data class ContactResponse(val ok: Boolean, val id: String? = null)
data class TermineResponse(val success: Boolean = true, val termine: List<TerminDto> = emptyList())
data class TerminDto(
val datum: String = "",
val uhrzeit: String? = null,
val titel: String = "",
val beschreibung: String? = null,
val kategorie: String? = null,
)
data class SpielplanResponse(
val success: Boolean = false,
val message: String? = null,
val data: List<SpielDto> = emptyList(),
val headers: List<String> = emptyList(),
val season: String? = null,
val seasons: List<SeasonDto> = emptyList(),
)
data class SeasonDto(val slug: String = "", val label: String = "")
data class SpielDto(
@param:Json(name = "Termin") val termin: String = "",
@param:Json(name = "HeimMannschaft") val heimMannschaft: String = "",
@param:Json(name = "GastMannschaft") val gastMannschaft: String = "",
@param:Json(name = "HeimMannschaftAltersklasse") val heimAltersklasse: String = "",
@param:Json(name = "GastMannschaftAltersklasse") val gastAltersklasse: String = "",
@param:Json(name = "Altersklasse") val altersklasse: String = "",
@param:Json(name = "Liga") val liga: String = "",
@param:Json(name = "Staffel") val staffel: String = "",
@param:Json(name = "Runde") val runde: String? = null,
@param:Json(name = "SpieleHeim") val spieleHeim: String = "",
@param:Json(name = "SpieleGast") val spieleGast: String = "",
)
data class TeamTableResponse(
val success: Boolean = false,
val message: String? = null,
val season: String? = null,
val table: TeamTableDto? = null,
)
data class TeamTableDto(
val teamName: String = "",
val leagueName: String = "",
val table: LeagueTableDto? = null,
)
data class LeagueTableDto(
val leagueTable: List<LeagueTableRowDto> = emptyList(),
)
data class LeagueTableRowDto(
@param:Json(name = "table_rank") val rank: Int? = null,
@param:Json(name = "team_name") val teamName: String = "",
@param:Json(name = "meetings_count") val meetings: Int? = null,
@param:Json(name = "meetings_won") val won: Int? = null,
@param:Json(name = "meetings_tie") val tied: Int? = null,
@param:Json(name = "meetings_lost") val lost: Int? = null,
@param:Json(name = "sets_won") val setsWon: Int? = null,
@param:Json(name = "sets_lost") val setsLost: Int? = null,
@param:Json(name = "games_won") val gamesWon: Int? = null,
@param:Json(name = "games_lost") val gamesLost: Int? = null,
@param:Json(name = "points_won") val pointsWon: Int? = null,
@param:Json(name = "points_lost") val pointsLost: Int? = null,
@param:Json(name = "rise_fall_state") val movement: String? = null,
)
data class NewsPublicResponse(val news: List<NewsDto> = emptyList())
data class NewsDto(
val id: Int? = null,
val title: String = "",
val content: String = "",
val created: String? = null,
)
data class PublicGalleryImageDto(
val filename: String = "",
val title: String = "",
)
data class MembershipRequest(
val vorname: String,
val nachname: String,
val strasse: String,
val plz: String,
val ort: String,
val geburtsdatum: String,
val email: String,
val telefon_privat: String? = null,
val telefon_mobil: String? = null,
val mitgliedschaftsart: String,
val lastschrift_erlaubt: Boolean,
val kontoinhaber: String,
val iban: String,
val bic: String? = null,
val bank: String? = null,
val datenschutz_einverstanden: Boolean,
val satzung_anerkannt: Boolean,
)
data class MembershipResponse(
val success: Boolean = false,
val message: String? = null,
val downloadUrl: String? = null,
)
data class LoginRequest(val email: String, val password: String)
data class AuthUserDto(
val id: String? = null,
val email: String = "",
val name: String? = null,
val roles: List<String> = emptyList(),
)
data class LoginResponse(
val success: Boolean = false,
val token: String? = null,
val user: AuthUserDto? = null,
val role: String? = null,
)
data class AuthStatusResponse(
val isLoggedIn: Boolean = false,
val user: AuthUserDto? = null,
val roles: List<String> = emptyList(),
val role: String? = null,
)
data class ResetPasswordRequest(val email: String)
data class AuthMessageResponse(val success: Boolean = false, val message: String? = null)
data class RegistrationVisibility(val showBirthday: Boolean)
data class RegistrationRequest(
val name: String,
val email: String,
val phone: String? = null,
val password: String,
val geburtsdatum: String,
val visibility: RegistrationVisibility,
)
data class TrainingLocationDto(
val name: String = "",
val strasse: String = "",
val plz: String = "",
val ort: String = "",
)
data class TrainingTimeDto(
val id: String = "",
val tag: String = "",
val von: String = "",
val bis: String = "",
val gruppe: String = "",
val info: String? = null,
)
data class TrainingDto(
val ort: TrainingLocationDto = TrainingLocationDto(),
val zeiten: List<TrainingTimeDto> = emptyList(),
)
data class TrainerDto(
val id: String = "",
val name: String = "",
val lizenz: String = "",
val schwerpunkt: String = "",
val zusatz: String? = null,
val imageFilename: String? = null,
)
data class BoardMemberDto(
val vorname: String = "",
val nachname: String = "",
val strasse: String = "",
val plz: String = "",
val ort: String = "",
val telefon: String = "",
val email: String = "",
val imageFilename: String? = null,
)
data class VorstandDto(
val vorsitzender: BoardMemberDto = BoardMemberDto(),
val stellvertreter: BoardMemberDto = BoardMemberDto(),
val kassenwart: BoardMemberDto = BoardMemberDto(),
val schriftfuehrer: BoardMemberDto = BoardMemberDto(),
val sportwart: BoardMemberDto = BoardMemberDto(),
val jugendwart: BoardMemberDto = BoardMemberDto(),
)
data class SatzungDto(
val pdfUrl: String = "",
val content: String = "",
)
data class LinkItemDto(
val label: String = "",
val href: String = "",
val description: String = "",
)
data class LinkSectionDto(
val title: String = "",
val items: List<LinkItemDto> = emptyList(),
)
data class SeitenDto(
val ueberUns: String = "",
val geschichte: String = "",
val ttRegeln: String = "",
val satzung: SatzungDto = SatzungDto(),
val links: String = "",
val linksStructured: List<LinkSectionDto> = emptyList(),
)
data class ConfigResponse(
val training: TrainingDto = TrainingDto(),
val trainer: List<TrainerDto> = emptyList(),
val vorstand: VorstandDto = VorstandDto(),
val seiten: SeitenDto = SeitenDto(),
)
interface ApiService {
@POST("/api/contact")
suspend fun postContact(@Body req: ContactRequest): Response<ContactResponse>
@GET("/api/galerie/list")
suspend fun galerieList(): Response<List<String>>
@GET("/api/galerie")
suspend fun publicGalleryImages(): Response<List<PublicGalleryImageDto>>
@GET("/api/termine")
suspend fun termine(): Response<TermineResponse>
@GET("/api/spielplan")
suspend fun spielplan(@Query("season") season: String? = null): Response<SpielplanResponse>
@GET("/api/spielplan/table")
suspend fun spielplanTable(
@Query("team") team: String,
@Query("season") season: String? = null,
): Response<TeamTableResponse>
@GET("/api/news-public")
suspend fun publicNews(): Response<NewsPublicResponse>
@GET("/api/mannschaften")
suspend fun mannschaften(@Query("season") season: String? = null): Response<ResponseBody>
@GET("/api/config")
suspend fun config(): Response<ConfigResponse>
@GET("/data/spielsysteme.csv")
suspend fun spielsysteme(): Response<ResponseBody>
@GET("/api/vereinsmeisterschaften")
suspend fun vereinsmeisterschaften(): Response<ResponseBody>
@POST("/api/membership/generate-pdf")
suspend fun generateMembershipPdf(@Body request: MembershipRequest): Response<MembershipResponse>
@Streaming
@GET
suspend fun downloadMembershipPdf(@Url downloadUrl: String): Response<ResponseBody>
@POST("/api/auth/login")
suspend fun login(@Body request: LoginRequest): Response<LoginResponse>
@POST("/api/auth/logout")
suspend fun logout(): Response<Unit>
@GET("/api/auth/status")
suspend fun authStatus(): Response<AuthStatusResponse>
@POST("/api/auth/reset-password")
suspend fun resetPassword(@Body request: ResetPasswordRequest): Response<AuthMessageResponse>
@POST("/api/auth/register")
suspend fun register(@Body request: RegistrationRequest): Response<AuthMessageResponse>
}

View File

@@ -0,0 +1,17 @@
package de.harheimertc.data
import de.harheimertc.repositories.AuthRepository
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
class AuthInterceptor @Inject constructor(private val authRepository: AuthRepository) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val requestBuilder = chain.request().newBuilder()
val token = authRepository.getToken()
if (!token.isNullOrBlank()) {
requestBuilder.addHeader("Authorization", "Bearer $token")
}
return chain.proceed(requestBuilder.build())
}
}

View File

@@ -0,0 +1,56 @@
package de.harheimertc.data
import de.harheimertc.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.JavaNetCookieJar
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import javax.inject.Singleton
import java.net.CookieManager
import java.net.CookiePolicy
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideMoshi(): Moshi = Moshi.Builder()
.addLast(KotlinJsonAdapterFactory())
.build()
@Provides
@Singleton
fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient {
val logging = HttpLoggingInterceptor()
logging.level = HttpLoggingInterceptor.Level.BASIC
val cookies = CookieManager().apply {
setCookiePolicy(CookiePolicy.ACCEPT_ALL)
}
return OkHttpClient.Builder()
.cookieJar(JavaNetCookieJar(cookies))
.addInterceptor(authInterceptor)
.addInterceptor(logging)
.build()
}
@Provides
@Singleton
fun provideRetrofit(moshi: Moshi, client: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(client)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
}
@Provides
@Singleton
fun provideApiService(retrofit: Retrofit): ApiService = retrofit.create(ApiService::class.java)
}

View File

@@ -0,0 +1,17 @@
package de.harheimertc.di
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import de.harheimertc.repositories.AuthRepository
import de.harheimertc.repositories.AuthRepositoryImpl
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindAuthRepository(impl: AuthRepositoryImpl): AuthRepository
}

View File

@@ -0,0 +1,8 @@
package de.harheimertc.repositories
import kotlinx.coroutines.flow.StateFlow
interface AuthRepository {
fun getToken(): String?
fun setToken(token: String?)
}

View File

@@ -0,0 +1,33 @@
package de.harheimertc.repositories
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class AuthRepositoryImpl @Inject constructor(@param:ApplicationContext private val context: Context) : AuthRepository {
private val tokenKey = "auth_token"
private val preferences by lazy {
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,
)
}
override fun getToken(): String? = preferences.getString(tokenKey, null)
override fun setToken(token: String?) {
preferences.edit().apply {
if (token == null) remove(tokenKey) else putString(tokenKey, token)
}.apply()
}
}

View File

@@ -0,0 +1,13 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import de.harheimertc.data.ContactRequest
import de.harheimertc.data.ContactResponse
import retrofit2.Response
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ContactRepository @Inject constructor(private val api: ApiService) {
suspend fun sendContact(req: ContactRequest): Response<ContactResponse> = api.postContact(req)
}

View File

@@ -0,0 +1,27 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class GalleryRepository @Inject constructor(private val api: ApiService) {
suspend fun hasPublicImages(): Result<Boolean> = runCatching {
val response = api.publicGalleryImages()
if (!response.isSuccessful) error("HTTP ${response.code()}")
response.body().orEmpty().isNotEmpty()
}
suspend fun fetchImages(): Result<List<String>> {
return try {
val resp = api.galerieList()
if (resp.isSuccessful) {
Result.success(resp.body() ?: emptyList())
} else {
Result.failure(Exception("HTTP ${resp.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}

View File

@@ -0,0 +1,24 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import de.harheimertc.data.NewsDto
import de.harheimertc.data.SpielDto
import de.harheimertc.data.TerminDto
import javax.inject.Inject
import javax.inject.Singleton
data class HomeData(
val termine: List<TerminDto>,
val spiele: List<SpielDto>,
val news: List<NewsDto>,
)
@Singleton
class HomeRepository @Inject constructor(private val api: ApiService) {
suspend fun fetchHomeData(): Result<HomeData> = runCatching {
val termine = api.termine().body()?.termine.orEmpty()
val spiele = api.spielplan().body()?.data.orEmpty()
val news = api.publicNews().body()?.news.orEmpty()
HomeData(termine, spiele, news)
}
}

View File

@@ -0,0 +1,56 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import de.harheimertc.data.LoginRequest
import de.harheimertc.data.LoginResponse
import de.harheimertc.data.AuthStatusResponse
import de.harheimertc.data.AuthMessageResponse
import de.harheimertc.data.RegistrationRequest
import de.harheimertc.data.ResetPasswordRequest
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class LoginRepository @Inject constructor(
private val api: ApiService,
private val authRepository: AuthRepository,
) {
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 body = response.body() ?: error("Leere Antwort")
val token = body.token?.takeIf(String::isNotBlank)
?: error("Der Server hat kein Zugriffstoken geliefert.")
authRepository.setToken(token)
body
}
suspend fun logout(): Result<Unit> = runCatching {
try {
api.logout()
} finally {
authRepository.setToken(null)
}
}
suspend fun status(): Result<AuthStatusResponse> = runCatching {
if (authRepository.getToken().isNullOrBlank()) return@runCatching AuthStatusResponse()
val response = api.authStatus()
if (!response.isSuccessful) error("Status konnte nicht geprüft werden.")
val status = response.body() ?: AuthStatusResponse()
if (!status.isLoggedIn) authRepository.setToken(null)
status
}
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")
}
suspend fun register(request: RegistrationRequest): Result<AuthMessageResponse> = runCatching {
val response = api.register(request)
if (!response.isSuccessful) error("Registrierung fehlgeschlagen.")
response.body() ?: error("Leere Antwort")
}
}

View File

@@ -0,0 +1,77 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
data class Mannschaft(
val mannschaft: String,
val liga: String,
val staffelleiter: String,
val telefon: String,
val heimspieltag: String,
val spielsystem: String,
val mannschaftsfuehrer: String,
val spieler: List<String>,
val informationenLink: String,
val letzteAktualisierung: String,
) {
val slug: String
get() = mannschaft.lowercase(Locale.GERMANY).replace(Regex("\\s+"), "-")
}
@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())
}
private fun parseCsv(csv: String): List<Mannschaft> = csv.lineSequence()
.filter(String::isNotBlank)
.drop(1)
.mapNotNull { row ->
val fields = parseCsvRow(row)
if (fields.size < 10 || fields[0].isBlank()) return@mapNotNull null
Mannschaft(
mannschaft = fields[0],
liga = fields[1],
staffelleiter = fields[2],
telefon = fields[3],
heimspieltag = fields[4],
spielsystem = fields[5],
mannschaftsfuehrer = fields[6],
spieler = fields[7].split(';').map(String::trim).filter(String::isNotBlank),
informationenLink = fields[8],
letzteAktualisierung = fields[9],
)
}
.toList()
private fun parseCsvRow(row: String): List<String> {
val values = mutableListOf<String>()
val current = StringBuilder()
var inQuotes = false
var index = 0
while (index < row.length) {
val character = row[index]
when {
character == '"' && inQuotes && row.getOrNull(index + 1) == '"' -> {
current.append('"')
index++
}
character == '"' -> inQuotes = !inQuotes
character == ',' && !inQuotes -> {
values += current.toString().trim()
current.clear()
}
else -> current.append(character)
}
index++
}
values += current.toString().trim()
return values
}
}

View File

@@ -0,0 +1,38 @@
package de.harheimertc.repositories
import android.content.Context
import androidx.core.content.FileProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import de.harheimertc.data.ApiService
import de.harheimertc.data.MembershipRequest
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
data class MembershipDocument(val message: String, val uri: String)
@Singleton
class MembershipRepository @Inject constructor(
private val api: ApiService,
@param:ApplicationContext private val context: Context,
) {
suspend fun submit(request: MembershipRequest): Result<MembershipDocument> = runCatching {
val response = api.generateMembershipPdf(request)
if (!response.isSuccessful) error("HTTP ${response.code()}")
val body = response.body() ?: error("Leere Antwort")
if (!body.success) error(body.message ?: "Antrag konnte nicht erstellt werden.")
val downloadUrl = body.downloadUrl ?: error("PDF-Download fehlt.")
val documentResponse = api.downloadMembershipPdf(downloadUrl)
if (!documentResponse.isSuccessful) error("PDF konnte nicht heruntergeladen werden.")
val directory = File(context.cacheDir, "membership").apply { mkdirs() }
val file = File(directory, "beitrittserklaerung.pdf")
documentResponse.body()?.byteStream()?.use { input ->
file.outputStream().use { output -> input.copyTo(output) }
} ?: error("Leere PDF-Antwort")
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
MembershipDocument(
message = body.message ?: "Beitrittsformular erfolgreich erstellt.",
uri = uri.toString(),
)
}
}

View File

@@ -0,0 +1,161 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import de.harheimertc.data.ConfigResponse
import de.harheimertc.data.LinkItemDto
import de.harheimertc.data.LinkSectionDto
import javax.inject.Inject
import javax.inject.Singleton
data class Spielsystem(
val name: String,
val description: String,
val teamSize: String,
val category: String,
val sequence: String,
val gameCount: String,
val features: String,
)
data class MeisterschaftResult(
val year: String,
val category: String,
val rank: String,
val playerOne: String,
val playerTwo: String,
val note: String,
val imageOne: String,
val imageTwo: String,
)
@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")
}
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],
)
}
}
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) { "" },
)
}
}
}
private fun parseCsv(csv: String): List<List<String>> =
csv.lineSequence().filter(String::isNotBlank).map(::parseCsvLine).toList()
private fun parseCsvLine(line: String): List<String> {
val values = mutableListOf<String>()
val value = StringBuilder()
var quoted = false
var index = 0
while (index < line.length) {
when (val char = line[index]) {
'"' -> {
if (quoted && index + 1 < line.length && line[index + 1] == '"') {
value.append('"')
index++
} else {
quoted = !quoted
}
}
',' -> if (quoted) value.append(char) else {
values += value.toString().trim()
value.clear()
}
else -> value.append(char)
}
index++
}
values += value.toString().trim()
return values
}
fun ConfigResponse.linkSections(): List<LinkSectionDto> =
seiten.linksStructured.filter { it.title.isNotBlank() && it.items.isNotEmpty() }
.ifEmpty {
parseLinkSections(seiten.links).ifEmpty { defaultLinkSections }
}
private fun parseLinkSections(html: String): List<LinkSectionDto> {
if (html.isBlank()) return emptyList()
val sectionRegex = Regex("""<h2[^>]*>(.*?)</h2>(.*?)(?=<h2[^>]*>|$)""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
val itemRegex = Regex("""<li[^>]*>(.*?)</li>""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
val anchorRegex = Regex("""<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)</a>""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
return sectionRegex.findAll(html).mapNotNull { section ->
val title = stripHtml(section.groupValues[1])
val items = itemRegex.findAll(section.groupValues[2]).mapNotNull { item ->
val match = anchorRegex.find(item.groupValues[1]) ?: return@mapNotNull null
LinkItemDto(
href = match.groupValues[1].trim(),
label = stripHtml(match.groupValues[2]),
description = stripHtml(item.groupValues[1].replace(match.value, "")),
)
}.toList()
title.takeIf { it.isNotBlank() && items.isNotEmpty() }?.let { LinkSectionDto(it, items) }
}.toList()
}
private fun stripHtml(html: String): String = html
.replace(Regex("<[^>]*>"), "")
.replace("&amp;", "&")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&nbsp;", " ")
.replace(Regex("\\s+"), " ")
.trim()
private val defaultLinkSections = listOf(
LinkSectionDto("Ergebnisse & Portale", listOf(
LinkItemDto("MyTischtennis.de", "http://www.mytischtennis.de/public/home", "(offizielle QTTR-Werte)"),
LinkItemDto("Click-tt Ergebnisse", "http://httv.click-tt.de/", "(offizieller Ergebnisdienst HTTV)"),
LinkItemDto("Tischtennis Pur", "https://www.tischtennis-pur.de/", "(Informationen, Blogs und Tipps)"),
LinkItemDto("Liveticker 2. und 3. TT-Bundesliga", "https://ticker.tt-news.com/"),
)),
LinkSectionDto("Verbände", listOf(
LinkItemDto("Hessischer Tischtennisverband (HTTV)", "http://www.httv.de/"),
LinkItemDto("Deutscher Tischtennisbund (DTTB)", "http://www.tischtennis.de/aktuelles/"),
LinkItemDto("European Table Tennis Union (ETTU)", "http://www.ettu.org/"),
LinkItemDto("International Table Tennis Federation (ITTF)", "https://www.ittf.com/"),
)),
LinkSectionDto("Regionale Links", listOf(
LinkItemDto("Stadt Frankfurt", "http://www.frankfurt.de/"),
LinkItemDto("Vereinsring Harheim", "http://www.harheim.com/"),
)),
LinkSectionDto("Partner & Vereine", listOf(
LinkItemDto("TTC OE Bad Homburg", "http://www.ttcoe.de/"),
LinkItemDto("SpVgg Steinkirchen e.V.", "https://www.spvgg-steinkirchen.de/menue-abteilungen/abteilungen/tischtennis"),
LinkItemDto("Ergebnisse SpVgg Steinkirchen", "https://www.mytischtennis.de/clicktt/ByTTV/24-25/ligen/Bezirksklasse-A-Gruppe-2-IN-PAF/gruppe/466925/tabelle/gesamt/"),
)),
)

View File

@@ -0,0 +1,26 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import de.harheimertc.data.SpielplanResponse
import de.harheimertc.data.TeamTableResponse
import javax.inject.Inject
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
}
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
}
}

View File

@@ -0,0 +1,15 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import de.harheimertc.data.TerminDto
import javax.inject.Inject
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()
}
}

View File

@@ -0,0 +1,15 @@
package de.harheimertc.repositories
import de.harheimertc.data.ApiService
import de.harheimertc.data.ConfigResponse
import javax.inject.Inject
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")
}
}

View File

@@ -0,0 +1,294 @@
package de.harheimertc.ui.components
import androidx.compose.foundation.Image
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.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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 de.harheimertc.BuildConfig
import de.harheimertc.R
import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.ui.navigation.NavigationUiState
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary600
import de.harheimertc.ui.theme.Primary900
private enum class MenuSection {
VEREIN,
MANNSCHAFTEN,
TRAINING,
NEWSLETTER,
INTERN,
}
private data class MenuTarget(val label: String, val route: String)
@Composable
fun AppNavigationHeader(
selectedRoute: String?,
onNavigate: (String) -> Unit,
webTabletNavigation: Boolean = false,
navigationState: NavigationUiState = NavigationUiState(),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(Brush.horizontalGradient(listOf(Accent900, Primary900, Accent900)))
.padding(horizontal = 18.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (webTabletNavigation) {
WebTabletNavigation(selectedRoute, onNavigate, navigationState)
} else {
CompactNavigation(selectedRoute, onNavigate)
}
}
}
@Composable
private fun CompactNavigation(selectedRoute: String?, onNavigate: (String) -> Unit) {
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))
}
}
@Composable
private fun WebTabletNavigation(
selectedRoute: String?,
onNavigate: (String) -> Unit,
navigationState: NavigationUiState,
) {
val section = menuSection(selectedRoute)
Row(verticalAlignment = Alignment.CenterVertically) {
Brand()
Spacer(Modifier.width(16.dp))
Row(
modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
MainLink("Start", selectedRoute == Destinations.Home.route, onClick = { onNavigate(Destinations.Home.route) })
MainLink("Verein", section == MenuSection.VEREIN, onClick = { onNavigate(Destinations.VereinAbout.route) })
MainLink("Mannschaften", section == MenuSection.MANNSCHAFTEN, onClick = { onNavigate(Destinations.Mannschaften.route) })
MainLink("Training", section == MenuSection.TRAINING, onClick = { onNavigate(Destinations.Training.route) })
MainLink("Mitgliedschaft", selectedRoute == Destinations.Membership.route, onClick = { onNavigate(Destinations.Membership.route) })
MainLink("Termine", selectedRoute == Destinations.Termine.route, onClick = { onNavigate(Destinations.Termine.route) })
if (navigationState.showGallery) {
MainLink("Galerie", selectedRoute == Destinations.Gallery.route, onClick = { onNavigate(Destinations.Gallery.route) })
}
MainLink("Newsletter", section == MenuSection.NEWSLETTER, onClick = { 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 = { onNavigate(Destinations.Contact.route) })
TextButton(onClick = { onNavigate(Destinations.Login.route) }) { Text("Login", color = Color.White) }
}
}
val subItems = submenu(section, navigationState)
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(top = 3.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
subItems.forEach { item ->
SubLink(item.label, item.route == selectedRoute) { onNavigate(item.route) }
}
}
}
@Composable
private fun BrandRow(onLogin: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically) {
Brand()
Spacer(Modifier.weight(1f))
TextButton(onClick = onLogin) { Text("Login", color = Color.White) }
}
}
@Composable
private fun Brand() {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
painter = painterResource(R.drawable.harheimer_tc_logo),
contentDescription = "Harheimer TC Logo",
modifier = Modifier.size(42.dp),
)
Spacer(Modifier.width(10.dp))
Text("Harheimer ", color = Color.White, style = MaterialTheme.typography.titleLarge)
Text("TC", color = Color(0xFFF87171), style = MaterialTheme.typography.titleLarge)
if (BuildConfig.ENVIRONMENT_NAME.isNotBlank()) {
Text(
BuildConfig.ENVIRONMENT_NAME,
color = Color.White,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier
.padding(start = 8.dp)
.background(Primary600, RoundedCornerShape(5.dp))
.padding(horizontal = 6.dp, vertical = 3.dp),
)
}
}
}
@Composable
private fun MainLink(
label: String,
selected: Boolean,
primary: Boolean = false,
onClick: () -> Unit,
) {
Surface(
color = if (selected || primary) Primary600 else Color.Transparent,
shape = RoundedCornerShape(8.dp),
modifier = Modifier.clickable(onClick = onClick),
) {
Text(
label,
color = Color.White.copy(alpha = if (selected || primary) 1f else 0.82f),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 9.dp),
maxLines = 1,
)
}
}
@Composable
private fun CompactLink(
label: String,
route: String,
selectedRoute: String?,
onNavigate: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
color = if (route == selectedRoute) Primary600 else Color.Transparent,
shape = RoundedCornerShape(8.dp),
modifier = modifier.clickable { onNavigate(route) },
) {
Text(
label,
color = if (route == selectedRoute) Color.White else Color(0xFFD4D4D8),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(vertical = 9.dp, horizontal = 2.dp),
maxLines = 1,
)
}
}
@Composable
private fun SubLink(label: String, selected: Boolean, onClick: () -> Unit) {
Surface(
color = if (selected) Primary600 else Color.Transparent,
shape = RoundedCornerShape(5.dp),
modifier = Modifier.clickable(onClick = onClick),
) {
Text(
label,
color = if (selected) Color.White else Color(0xFFD4D4D8),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 9.dp, vertical = 4.dp),
maxLines = 1,
)
}
}
private fun menuSection(route: String?): MenuSection? = when (route) {
Destinations.VereinAbout.route,
Destinations.Vorstand.route,
Destinations.Geschichte.route,
Destinations.Satzung.route,
Destinations.Vereinsmeisterschaften.route,
Destinations.Links.route,
Destinations.Gallery.route -> MenuSection.VEREIN
Destinations.Mannschaften.route,
Destinations.Spielplan.route,
Destinations.Spielsysteme.route -> MenuSection.MANNSCHAFTEN
Destinations.Training.route,
Destinations.Trainer.route,
Destinations.Anfaenger.route,
Destinations.Regeln.route -> MenuSection.TRAINING
Destinations.NewsletterSubscribe.route,
Destinations.NewsletterUnsubscribe.route -> MenuSection.NEWSLETTER
Destinations.MemberArea.route,
Destinations.Members.route,
Destinations.MemberNews.route,
Destinations.Profile.route,
Destinations.MemberApi.route,
Destinations.CmsNewsletter.route,
Destinations.CmsContactRequests.route,
Destinations.Cms.route -> MenuSection.INTERN
else -> null
}.let { section ->
if (section == null && route?.startsWith("mannschaften/") == true) MenuSection.MANNSCHAFTEN else section
}
private fun submenu(section: MenuSection?, state: NavigationUiState): List<MenuTarget> = when (section) {
MenuSection.VEREIN -> listOf(
MenuTarget("Über uns", Destinations.VereinAbout.route),
MenuTarget("Vorstand", Destinations.Vorstand.route),
MenuTarget("Geschichte", Destinations.Geschichte.route),
MenuTarget("Satzung", Destinations.Satzung.route),
MenuTarget("Vereinsmeisterschaften", Destinations.Vereinsmeisterschaften.route),
MenuTarget("Galerie", Destinations.Gallery.route),
MenuTarget("Links", Destinations.Links.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),
)
MenuSection.INTERN -> buildList {
add(MenuTarget("Übersicht", Destinations.MemberArea.route))
add(MenuTarget("Mitgliederliste", Destinations.Members.route))
add(MenuTarget("News", Destinations.MemberNews.route))
add(MenuTarget("Mein Profil", Destinations.Profile.route))
add(MenuTarget("API-Dokumentation", Destinations.MemberApi.route))
if (state.canAccessNewsletter) add(MenuTarget("Newsletter", Destinations.CmsNewsletter.route))
if (state.canAccessContactRequests) add(MenuTarget("Kontaktanfragen", Destinations.CmsContactRequests.route))
if (state.isAdmin) add(MenuTarget("CMS", Destinations.Cms.route))
}
null -> emptyList()
}

View File

@@ -0,0 +1,81 @@
package de.harheimertc.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
@Composable
fun HeroComponent(
imageUrl: String,
title: String,
subtitle: String,
ctaText: String,
onPrimaryCta: () -> Unit,
heightDp: Int = 280
) {
Box(modifier = Modifier
.fillMaxWidth()
.height(heightDp.dp)) {
AsyncImage(
model = imageUrl,
contentDescription = "Hero Image",
modifier = Modifier
.fillMaxWidth()
.height(heightDp.dp),
contentScale = ContentScale.Crop
)
Box(modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colors = listOf(Color(0x66000000), Color(0x00000000), Color(0x88000000))
)
)
)
Box(modifier = Modifier
.matchParentSize()
.padding(20.dp)) {
Column(modifier = Modifier.align(Alignment.CenterStart)) {
Text(text = title, style = MaterialTheme.typography.titleLarge, color = Color.White)
Text(text = subtitle, style = MaterialTheme.typography.bodyMedium, color = Color.White)
}
Surface(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 16.dp)
.clip(RoundedCornerShape(12.dp)),
color = MaterialTheme.colorScheme.primary
) {
Button(
onClick = onPrimaryCta,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary),
modifier = Modifier
) {
Text(ctaText, color = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.padding(horizontal = 12.dp))
}
}
}
}
}

View File

@@ -0,0 +1,62 @@
package de.harheimertc.ui.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.ui.Alignment
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.window.Dialog
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun ImageGrid(images: List<String>, modifier: Modifier = Modifier) {
val selected = remember { mutableStateOf<String?>(null) }
LazyVerticalGrid(columns = GridCells.Fixed(3), modifier = modifier.padding(8.dp)) {
items(images) { img ->
Card(modifier = Modifier.padding(4.dp), elevation = CardDefaults.cardElevation(4.dp)) {
AsyncImage(
model = img,
contentDescription = "Gallery image",
modifier = Modifier
.aspectRatio(1f)
.clickable { selected.value = img },
contentScale = ContentScale.Crop
)
}
}
}
if (selected.value != null) {
Dialog(onDismissRequest = { selected.value = null }) {
Surface(modifier = Modifier.fillMaxSize()) {
Box(contentAlignment = Alignment.Center) {
AsyncImage(model = selected.value, contentDescription = "Full image", modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Fit)
Button(onClick = { selected.value = null }, modifier = Modifier.align(Alignment.TopEnd), colors = ButtonDefaults.buttonColors()) {
Text("Schließen")
}
}
}
}
}
}

View File

@@ -0,0 +1,68 @@
package de.harheimertc.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.navigation.NavController
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
import de.harheimertc.ui.theme.Primary900
@Composable
fun PendingPage(
navController: NavController,
title: String,
webPath: String,
showBackNavigation: Boolean,
) {
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
item {
if (showBackNavigation) {
TextButton(onClick = { navController.popBackStack() }) {
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
Column(
modifier = Modifier.fillMaxWidth().padding(top = 30.dp, bottom = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900, textAlign = TextAlign.Center)
Text("Webseite: $webPath", color = Accent500)
}
}
item {
Surface(color = Primary100, shape = RoundedCornerShape(9.dp)) {
Text(
"Die native Android-Seite wird in einem der nächsten Portierungsschritte umgesetzt.",
color = Primary900,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(18.dp),
)
}
}
}
}

View File

@@ -0,0 +1,38 @@
package de.harheimertc.ui.navigation
sealed class Destinations(val route: String) {
object Home : Destinations("home")
object VereinAbout : Destinations("verein/about")
object Vorstand : Destinations("verein/vorstand")
object Geschichte : Destinations("verein/geschichte")
object Satzung : Destinations("verein/satzung")
object Vereinsmeisterschaften : Destinations("verein/vereinsmeisterschaften")
object Links : Destinations("verein/links")
object Mannschaften : Destinations("mannschaften")
object MannschaftDetail : Destinations("mannschaften/{slug}") {
fun create(slug: String): String = "mannschaften/$slug"
}
object Termine : Destinations("termine")
object Spielplan : Destinations("spielplan")
object Spielsysteme : Destinations("mannschaften/spielsysteme")
object Training : Destinations("training")
object Trainer : Destinations("training/trainer")
object Anfaenger : Destinations("training/anfaenger")
object Regeln : Destinations("training/regeln")
object Gallery : Destinations("gallery")
object NewsletterSubscribe : Destinations("newsletter/subscribe")
object NewsletterUnsubscribe : Destinations("newsletter/unsubscribe")
object Contact : Destinations("contact")
object Membership : Destinations("membership")
object Login : Destinations("login")
object PasswordReset : Destinations("passwordReset")
object Register : Destinations("register")
object MemberArea : Destinations("intern")
object Members : Destinations("intern/mitglieder")
object MemberNews : Destinations("intern/news")
object Profile : Destinations("intern/profil")
object MemberApi : Destinations("intern/api")
object CmsNewsletter : Destinations("cms/newsletter")
object CmsContactRequests : Destinations("cms/kontaktanfragen")
object Cms : Destinations("cms")
}

View File

@@ -0,0 +1,223 @@
package de.harheimertc.ui.navigation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
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.hilt.navigation.compose.hiltViewModel
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import de.harheimertc.ui.components.AppNavigationHeader
import de.harheimertc.ui.components.PendingPage
@Composable
fun NavGraph(
navController: NavHostController,
startDestination: String = Destinations.Home.route,
navigationViewModel: NavigationViewModel = hiltViewModel(),
) {
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)
} else route
val navigationState by navigationViewModel.state.collectAsState()
LaunchedEffect(currentRoute) {
navigationViewModel.refreshSession()
}
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val persistentNavigation = maxWidth >= 600.dp
Column(modifier = Modifier.fillMaxSize()) {
if (persistentNavigation) {
AppNavigationHeader(
selectedRoute = currentRoute,
onNavigate = navController::navigateTopLevel,
webTabletNavigation = true,
navigationState = navigationState,
)
}
NavHost(
navController = navController,
startDestination = startDestination,
modifier = Modifier.weight(1f),
) {
composable(Destinations.Home.route) {
de.harheimertc.ui.screens.home.HomeScreen(
navController = navController,
showNavigationHeader = !persistentNavigation,
)
}
composable(Destinations.VereinAbout.route) {
de.harheimertc.ui.screens.publicpages.AboutScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Vorstand.route) {
de.harheimertc.ui.screens.publicpages.VorstandScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Geschichte.route) {
de.harheimertc.ui.screens.publicpages.GeschichteScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Satzung.route) {
de.harheimertc.ui.screens.publicpages.SatzungScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Vereinsmeisterschaften.route) {
de.harheimertc.ui.screens.publicpages.VereinsmeisterschaftenScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Links.route) {
de.harheimertc.ui.screens.publicpages.LinksScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Mannschaften.route) {
de.harheimertc.ui.screens.mannschaften.MannschaftenScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.MannschaftDetail.route) { entry ->
de.harheimertc.ui.screens.mannschaften.MannschaftDetailScreen(
slug = entry.arguments?.getString("slug").orEmpty(),
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Termine.route) {
de.harheimertc.ui.screens.termine.TermineScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Spielplan.route) {
de.harheimertc.ui.screens.spielplan.SpielplanScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Spielsysteme.route) {
de.harheimertc.ui.screens.publicpages.SpielsystemeScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Training.route) {
de.harheimertc.ui.screens.training.TrainingScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Trainer.route) {
de.harheimertc.ui.screens.training.TrainerScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Anfaenger.route) {
de.harheimertc.ui.screens.training.AnfaengerScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Regeln.route) {
de.harheimertc.ui.screens.publicpages.RegelnScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Gallery.route) {
de.harheimertc.ui.screens.gallery.GalleryScreen()
}
composable(Destinations.NewsletterSubscribe.route) {
PendingPage(navController, "Newsletter abonnieren", "/newsletter/subscribe", !persistentNavigation)
}
composable(Destinations.NewsletterUnsubscribe.route) {
PendingPage(navController, "Newsletter abmelden", "/newsletter/unsubscribe", !persistentNavigation)
}
composable(Destinations.Contact.route) {
de.harheimertc.ui.screens.contact.ContactScreen()
}
composable(Destinations.Membership.route) {
de.harheimertc.ui.screens.membership.MembershipScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Login.route) {
de.harheimertc.ui.screens.login.LoginScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.PasswordReset.route) {
de.harheimertc.ui.screens.login.PasswordResetScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.Register.route) {
de.harheimertc.ui.screens.login.RegisterScreen(
navController = navController,
showBackNavigation = !persistentNavigation,
)
}
composable(Destinations.MemberArea.route) {
PendingPage(navController, "Intern", "/mitgliederbereich", !persistentNavigation)
}
composable(Destinations.Members.route) {
PendingPage(navController, "Mitgliederliste", "/mitgliederbereich/mitglieder", !persistentNavigation)
}
composable(Destinations.MemberNews.route) {
PendingPage(navController, "News", "/mitgliederbereich/news", !persistentNavigation)
}
composable(Destinations.Profile.route) {
PendingPage(navController, "Mein Profil", "/mitgliederbereich/profil", !persistentNavigation)
}
composable(Destinations.MemberApi.route) {
PendingPage(navController, "API-Dokumentation", "/mitgliederbereich/api", !persistentNavigation)
}
composable(Destinations.CmsNewsletter.route) {
PendingPage(navController, "Newsletter", "/cms/newsletter", !persistentNavigation)
}
composable(Destinations.CmsContactRequests.route) {
PendingPage(navController, "Kontaktanfragen", "/cms/kontaktanfragen", !persistentNavigation)
}
composable(Destinations.Cms.route) {
PendingPage(navController, "CMS", "/cms", !persistentNavigation)
}
}
}
}
}
private fun NavHostController.navigateTopLevel(route: String) {
val isTeamDetail = route.startsWith("mannschaften/") &&
route != Destinations.Spielsysteme.route
navigate(route) {
launchSingleTop = !isTeamDetail
restoreState = !isTeamDetail
popUpTo(Destinations.Home.route) {
saveState = !isTeamDetail
}
}
}

View File

@@ -0,0 +1,65 @@
package de.harheimertc.ui.navigation
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.repositories.GalleryRepository
import de.harheimertc.repositories.LoginRepository
import de.harheimertc.repositories.Mannschaft
import de.harheimertc.repositories.MannschaftenRepository
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class NavigationUiState(
val teams: List<Mannschaft> = emptyList(),
val hasGalleryImages: Boolean = false,
val loggedIn: Boolean = false,
val roles: Set<String> = emptySet(),
) {
val isAdmin: Boolean get() = "admin" in roles
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 showGallery: Boolean get() = hasGalleryImages || canAccessNewsletter
}
@HiltViewModel
class NavigationViewModel @Inject constructor(
private val mannschaftenRepository: MannschaftenRepository,
private val galleryRepository: GalleryRepository,
private val loginRepository: LoginRepository,
) : ViewModel() {
private val _state = MutableStateFlow(NavigationUiState())
val state: StateFlow<NavigationUiState> = _state
init {
loadNavigationData()
}
fun loadNavigationData() {
viewModelScope.launch {
val teams = async { mannschaftenRepository.fetchMannschaften().getOrDefault(emptyList()) }
val gallery = async { galleryRepository.hasPublicImages().getOrDefault(false) }
val auth = async { loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse()) }
val status = auth.await()
_state.value = NavigationUiState(
teams = teams.await(),
hasGalleryImages = gallery.await(),
loggedIn = status.isLoggedIn,
roles = (status.roles + status.user?.roles.orEmpty()).toSet(),
)
}
}
fun refreshSession() {
viewModelScope.launch {
val status = loginRepository.status().getOrDefault(de.harheimertc.data.AuthStatusResponse())
_state.value = _state.value.copy(
loggedIn = status.isLoggedIn,
roles = (status.roles + status.user?.roles.orEmpty()).toSet(),
)
}
}
}

View File

@@ -0,0 +1,38 @@
package de.harheimertc.ui.screens.contact
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@Composable
fun ContactScreen(viewModel: ContactViewModel = hiltViewModel()) {
val name by viewModel.name.collectAsState()
val email by viewModel.email.collectAsState()
val message by viewModel.message.collectAsState()
val sending by viewModel.sending.collectAsState()
val result by viewModel.result.collectAsState()
Surface(modifier = Modifier.padding(16.dp)) {
Column {
OutlinedTextField(value = name, onValueChange = { viewModel.onName(it) }, label = { Text("Name") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = email, onValueChange = { viewModel.onEmail(it) }, label = { Text("E-Mail") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = message, onValueChange = { viewModel.onMessage(it) }, label = { Text("Nachricht") }, modifier = Modifier.fillMaxWidth())
Button(onClick = { viewModel.send() }, enabled = !sending, modifier = Modifier.padding(top = 8.dp)) {
Text(if (sending) "Sende…" else "Absenden")
}
if (result != null) {
Text(text = result!!, modifier = Modifier.padding(top = 8.dp))
}
}
}
}

View File

@@ -0,0 +1,61 @@
package de.harheimertc.ui.screens.contact
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ContactRequest
import de.harheimertc.repositories.ContactRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ContactViewModel @Inject constructor(private val repo: ContactRepository) : ViewModel() {
private val _name = MutableStateFlow("")
val name: StateFlow<String> = _name
private val _email = MutableStateFlow("")
val email: StateFlow<String> = _email
private val _message = MutableStateFlow("")
val message: StateFlow<String> = _message
private val _sending = MutableStateFlow(false)
val sending: StateFlow<Boolean> = _sending
private val _result = MutableStateFlow<String?>(null)
val result: StateFlow<String?> = _result
fun onName(v: String) { _name.value = v }
fun onEmail(v: String) { _email.value = v }
fun onMessage(v: String) { _message.value = v }
fun send() {
val n = _name.value.trim()
val e = _email.value.trim()
val m = _message.value.trim()
if (n.isEmpty() || e.isEmpty() || m.isEmpty()) {
_result.value = "Bitte alle Felder ausfüllen"
return
}
viewModelScope.launch {
_sending.value = true
try {
val resp = repo.sendContact(ContactRequest(n, e, m))
if (resp.isSuccessful) {
_result.value = "Nachricht gesendet"
_name.value = ""
_email.value = ""
_message.value = ""
} else {
_result.value = "Fehler: ${resp.code()}"
}
} catch (e: Exception) {
_result.value = "Netzwerkfehler"
} finally {
_sending.value = false
}
}
}
}

View File

@@ -0,0 +1,33 @@
package de.harheimertc.ui.screens.gallery
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import de.harheimertc.ui.components.ImageGrid
@Composable
fun GalleryScreen(viewModel: GalleryViewModel = hiltViewModel()) {
val images by viewModel.images.collectAsState()
val loading by viewModel.loading.collectAsState()
val error by viewModel.error.collectAsState()
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
if (loading) {
CircularProgressIndicator()
} else if (error != null) {
Text(text = "Fehler: $error")
} else {
ImageGrid(images = images)
}
}
// load on first composition
androidx.compose.runtime.LaunchedEffect(Unit) { viewModel.load() }
}

View File

@@ -0,0 +1,33 @@
package de.harheimertc.ui.screens.gallery
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.repositories.GalleryRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class GalleryViewModel @Inject constructor(private val repo: GalleryRepository) : ViewModel() {
private val _images = MutableStateFlow<List<String>>(emptyList())
val images: StateFlow<List<String>> = _images
private val _loading = MutableStateFlow(false)
val loading: StateFlow<Boolean> = _loading
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error
fun load() {
viewModelScope.launch {
_loading.value = true
_error.value = null
repo.fetchImages()
.onSuccess { _images.value = it }
.onFailure { _error.value = it.message ?: "Fehler" }
_loading.value = false
}
}
}

View File

@@ -0,0 +1,430 @@
package de.harheimertc.ui.screens.home
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
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
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.draw.alpha
import androidx.compose.ui.draw.clip
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.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 androidx.navigation.NavController
import coil.compose.AsyncImage
import de.harheimertc.BuildConfig
import de.harheimertc.data.NewsDto
import de.harheimertc.data.SpielDto
import de.harheimertc.data.TerminDto
import de.harheimertc.ui.components.AppNavigationHeader
import de.harheimertc.ui.navigation.Destinations
import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent200
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
import de.harheimertc.ui.theme.Primary900
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
@Composable
fun HomeScreen(
navController: NavController,
showNavigationHeader: Boolean = true,
viewModel: HomeViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
var selectedNews by remember { mutableStateOf<NewsDto?>(null) }
selectedNews?.let { item ->
AlertDialog(
onDismissRequest = { selectedNews = null },
title = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(formatNewsDate(item.created), style = MaterialTheme.typography.labelSmall, color = Accent500)
Text(item.title, style = MaterialTheme.typography.titleLarge)
}
},
text = { Text(item.content, style = MaterialTheme.typography.bodyMedium, color = Accent700) },
confirmButton = { TextButton(onClick = { selectedNews = null }) { Text("Schließen") } },
)
}
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color.White),
) {
if (showNavigationHeader) {
item {
AppNavigationHeader(
selectedRoute = Destinations.Home.route,
onNavigate = navController::navigate,
)
}
}
item { WebHero() }
item {
HomeTermineSection(
termine = state.termine,
loading = state.loading,
onAll = { navController.navigate(Destinations.Termine.route) },
)
}
item {
HomeGamesSection(
spiele = state.spiele,
loading = state.loading,
onAll = { navController.navigate(Destinations.Spielplan.route) },
)
}
if (state.news.isNotEmpty()) {
item {
HomeNewsSection(
news = state.news,
onOpen = { selectedNews = it },
)
}
}
item {
HomeActionSection(
onMembership = { navController.navigate(Destinations.Membership.route) },
onContact = { navController.navigate(Destinations.Contact.route) },
)
}
item { HomeFooter() }
}
}
@Composable
private fun WebHero() {
val years = Calendar.getInstance().get(Calendar.YEAR) - 1954
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 390.dp)
.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,
)
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 58.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Text(
"Willkommen beim",
style = MaterialTheme.typography.displayLarge,
color = Accent900,
textAlign = TextAlign.Center,
)
Text(
"Harheimer TC",
style = MaterialTheme.typography.displayLarge.copy(fontSize = 40.sp),
fontWeight = FontWeight.Bold,
color = Primary600,
textAlign = TextAlign.Center,
)
Text(
"Tradition trifft Moderne - Ihr Tischtennisverein in Frankfurt-Harheim seit $years Jahren",
style = MaterialTheme.typography.bodyLarge,
color = Accent700,
textAlign = TextAlign.Center,
)
}
}
}
@Composable
private fun HomeTermineSection(termine: List<TerminDto>, loading: Boolean, onAll: () -> Unit) {
HomeSection(title = "Kommende Termine", background = Color(0xFFFAFAFA)) {
if (loading) {
LoadingRow("Termine werden geladen...")
} else if (termine.isEmpty()) {
EmptyRow("Keine kommenden Termine")
} else {
termine.forEach { termin -> AppointmentCard(termin) }
}
PrimaryAction("Alle Termine anzeigen", onAll)
}
}
@Composable
private fun HomeGamesSection(spiele: List<SpielDto>, loading: Boolean, onAll: () -> Unit) {
HomeSection(title = "Nächste Spiele", background = Color.White) {
if (loading) {
LoadingRow("Spielplan wird geladen...")
} else if (spiele.isEmpty()) {
EmptyRow("Derzeit sind keine Spiele geplant.")
} else {
spiele.forEach { spiel -> MatchCard(spiel) }
}
PrimaryAction("Alle Spiele anzeigen", onAll)
}
}
@Composable
private fun HomeNewsSection(news: List<NewsDto>, onOpen: (NewsDto) -> Unit) {
HomeSection(
title = "Aktuelles",
subtitle = "Die neuesten Nachrichten aus unserem Verein",
background = Color.White,
) {
news.forEach { item ->
Surface(
color = Color(0xFFFAFAFA),
shape = RoundedCornerShape(12.dp),
tonalElevation = 0.dp,
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp).clickable { onOpen(item) },
) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
Text(formatNewsDate(item.created), style = MaterialTheme.typography.labelSmall, color = Accent500)
Text(item.title, style = MaterialTheme.typography.titleLarge, color = Accent900)
Text(
item.content,
style = MaterialTheme.typography.bodyMedium,
color = Accent700,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
@Composable
private fun HomeActionSection(onMembership: () -> Unit, onContact: () -> Unit) {
HomeSection(title = null, background = Color(0xFFFAFAFA)) {
ActionCard(
title = "Mitglied werden",
body = "Werden Sie Teil unserer Tischtennisfamilie und profitieren Sie von Training, Wettkämpfen und Gemeinschaft.",
action = "Mehr erfahren",
onClick = onMembership,
)
ActionCard(
title = "Kontakt aufnehmen",
body = "Haben Sie Fragen oder möchten ein kostenloses Probetraining vereinbaren? Wir freuen uns auf Ihre Nachricht!",
action = "Jetzt kontaktieren",
onClick = onContact,
)
}
}
@Composable
private fun HomeSection(
title: String?,
subtitle: String? = null,
background: Color,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth().background(background).padding(horizontal = 18.dp, vertical = 38.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
title?.let {
Text(it, style = MaterialTheme.typography.displayLarge, color = Accent900, textAlign = TextAlign.Center)
Spacer(Modifier.height(12.dp))
Box(Modifier.width(74.dp).height(4.dp).background(Primary600))
subtitle?.let { text ->
Spacer(Modifier.height(14.dp))
Text(text, style = MaterialTheme.typography.bodyLarge, color = Accent500, textAlign = TextAlign.Center)
}
Spacer(Modifier.height(26.dp))
}
content()
}
}
@Composable
private fun AppointmentCard(termin: TerminDto) {
Surface(
shape = RoundedCornerShape(9.dp),
color = Color(0xFFF4F4F5),
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Surface(color = Primary600, shape = RoundedCornerShape(8.dp)) {
Column(
modifier = Modifier.width(65.dp).padding(vertical = 8.dp, horizontal = 3.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(formatDate(termin.datum, "dd"), color = Color.White, fontWeight = FontWeight.Bold, fontSize = 19.sp)
Text(formatDate(termin.datum, "MMM yyyy"), color = Color.White, style = MaterialTheme.typography.labelSmall)
termin.uhrzeit?.let { Text("$it Uhr", color = Color.White, style = MaterialTheme.typography.labelSmall) }
}
}
Column(Modifier.weight(1f).padding(start = 13.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(termin.titel, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = Accent900)
termin.beschreibung?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = Accent700, maxLines = 2)
}
}
termin.kategorie?.takeIf { it.isNotBlank() }?.let {
Text(
it,
color = Accent700,
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.background(Primary100, RoundedCornerShape(14.dp)).padding(horizontal = 8.dp, vertical = 5.dp),
)
}
}
}
}
@Composable
private fun MatchCard(spiel: SpielDto) {
Surface(
shape = RoundedCornerShape(12.dp),
color = Color.White,
shadowElevation = 3.dp,
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
) {
Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(formatMatchDate(spiel.termin), fontWeight = FontWeight.SemiBold, color = Accent900)
Text(spiel.termin.substringAfter(' ', "-"), style = MaterialTheme.typography.bodyMedium, color = Accent500)
}
Row(verticalAlignment = Alignment.CenterVertically) {
TeamLabel("Heim", spiel.heimMannschaft, Modifier.weight(1f))
Box(
Modifier.size(34.dp).background(Primary100, RoundedCornerShape(20.dp)),
contentAlignment = Alignment.Center,
) { Text("vs", color = Primary600, fontWeight = FontWeight.Bold) }
TeamLabel("Gast", spiel.gastMannschaft, Modifier.weight(1f), right = true)
}
spiel.runde?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.bodyMedium, color = Accent500)
}
}
}
}
@Composable
private fun TeamLabel(label: String, value: String, modifier: Modifier, right: Boolean = false) {
Column(modifier.padding(horizontal = 8.dp), horizontalAlignment = if (right) Alignment.End else Alignment.Start) {
Text(label, style = MaterialTheme.typography.labelSmall, color = Accent500)
Text(value, fontWeight = FontWeight.SemiBold, color = Accent900, textAlign = if (right) TextAlign.End else TextAlign.Start)
}
}
@Composable
private fun ActionCard(title: String, body: String, action: String, onClick: () -> Unit) {
Surface(
shape = RoundedCornerShape(12.dp),
color = Color.White,
shadowElevation = 3.dp,
modifier = Modifier.fillMaxWidth().padding(bottom = 15.dp).clickable(onClick = onClick),
) {
Column(Modifier.padding(22.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier.size(48.dp).background(Primary100, RoundedCornerShape(10.dp)),
contentAlignment = Alignment.Center,
) { Text("HTC", color = Primary600, fontWeight = FontWeight.Bold, fontSize = 11.sp) }
Spacer(Modifier.width(14.dp))
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
}
Text(body, style = MaterialTheme.typography.bodyMedium, color = Accent700)
Text("$action >", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
}
@Composable
private fun PrimaryAction(label: String, onClick: () -> Unit) {
Spacer(Modifier.height(16.dp))
Button(
onClick = onClick,
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
shape = RoundedCornerShape(8.dp),
) { Text("$label >", modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp)) }
}
@Composable
private fun EmptyRow(text: String) {
Surface(color = Accent100, shape = RoundedCornerShape(9.dp), modifier = Modifier.fillMaxWidth()) {
Text(text, color = Accent500, textAlign = TextAlign.Center, modifier = Modifier.padding(vertical = 30.dp, horizontal = 12.dp))
}
}
@Composable
private fun LoadingRow(text: String) {
Column(Modifier.fillMaxWidth().padding(22.dp), horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(modifier = Modifier.size(28.dp), color = Primary600)
Spacer(Modifier.height(10.dp))
Text(text, color = Accent500)
}
}
@Composable
private fun HomeFooter() {
Column(
Modifier.fillMaxWidth().background(Accent900).padding(horizontal = 18.dp, vertical = 28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Harheimer TC", style = MaterialTheme.typography.titleLarge, color = Color.White)
Text("Tischtennis in Frankfurt-Harheim seit 1954", color = Accent200, style = MaterialTheme.typography.bodyMedium)
}
}
private fun formatDate(value: String, pattern: String): String = runCatching {
val source = SimpleDateFormat("yyyy-MM-dd", Locale.GERMANY)
SimpleDateFormat(pattern, Locale.GERMANY).format(source.parse(value)!!)
}.getOrDefault(value)
private fun formatMatchDate(value: String): String = runCatching {
val source = SimpleDateFormat("dd.MM.yyyy", Locale.GERMANY)
val date = source.parse(value.substringBefore(' '))!!
SimpleDateFormat("EEE dd.MM.yyyy", Locale.GERMANY).format(date)
}.getOrDefault(value.substringBefore(' '))
private fun formatNewsDate(value: String?): String {
if (value.isNullOrBlank()) return ""
return runCatching {
val source = SimpleDateFormat("yyyy-MM-dd", Locale.GERMANY)
SimpleDateFormat("dd. MMMM yyyy", Locale.GERMANY).format(source.parse(value.take(10))!!)
}.getOrDefault(value.take(10))
}

View File

@@ -0,0 +1,72 @@
package de.harheimertc.ui.screens.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.NewsDto
import de.harheimertc.data.SpielDto
import de.harheimertc.data.TerminDto
import de.harheimertc.repositories.HomeRepository
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class HomeUiState(
val loading: Boolean = true,
val termine: List<TerminDto> = emptyList(),
val spiele: List<SpielDto> = emptyList(),
val news: List<NewsDto> = emptyList(),
val error: Boolean = false,
)
@HiltViewModel
class HomeViewModel @Inject constructor(private val repository: HomeRepository) : ViewModel() {
private val _state = MutableStateFlow(HomeUiState())
val state: StateFlow<HomeUiState> = _state
init {
load()
}
fun load() {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = false)
repository.fetchHomeData()
.onSuccess { data ->
_state.value = HomeUiState(
loading = false,
termine = data.termine
.filter { it.asDateTime()?.isBefore(LocalDateTime.now()) != true }
.sortedBy { it.asDateTime() }
.take(3),
spiele = data.spiele
.filter { game ->
game.asDate()?.let { date ->
!date.isBefore(LocalDate.now()) &&
!date.isAfter(LocalDate.now().plusDays(7))
} == true
}
.sortedBy { it.asDate() }
.take(3),
news = data.news.take(3),
)
}
.onFailure {
_state.value = HomeUiState(loading = false, error = true)
}
}
}
}
private fun TerminDto.asDateTime(): LocalDateTime? = runCatching {
val time = uhrzeit?.takeIf { it.matches(Regex("\\d{2}:\\d{2}")) } ?: "00:00"
LocalDateTime.parse("$datum $time", DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
}.getOrNull()
fun SpielDto.asDate(): LocalDate? = runCatching {
LocalDate.parse(termin.substringBefore(' '), DateTimeFormatter.ofPattern("dd.MM.yyyy"))
}.getOrNull()

View File

@@ -0,0 +1,131 @@
package de.harheimertc.ui.screens.login
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
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.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
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.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
import de.harheimertc.ui.theme.Primary900
import de.harheimertc.ui.navigation.Destinations
@Composable
fun LoginScreen(
navController: NavController,
showBackNavigation: Boolean = true,
viewModel: LoginViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
item {
if (showBackNavigation) {
TextButton(onClick = { navController.popBackStack() }) {
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
Column(Modifier.fillMaxWidth().padding(vertical = 24.dp)) {
Text(
"Mitglieder-Login",
style = MaterialTheme.typography.displayLarge,
color = Accent900,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
Text(
"Melden Sie sich an, um auf den Mitgliederbereich zuzugreifen.",
color = Accent500,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(top = 9.dp),
)
}
}
item {
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 3.dp) {
Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
if (state.restoring) {
CircularProgressIndicator(color = Primary600, modifier = Modifier.size(28.dp))
Text("Sitzung wird geprüft...", color = Accent500)
} else if (!state.loggedIn) {
OutlinedTextField(
value = state.email,
onValueChange = viewModel::setEmail,
label = { Text("E-Mail-Adresse") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.password,
onValueChange = viewModel::setPassword,
label = { Text("Passwort") },
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Button(onClick = viewModel::login, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
if (state.loading) CircularProgressIndicator(color = Color.White, strokeWidth = 2.dp, modifier = Modifier.size(18.dp).padding(end = 4.dp))
Text(if (state.loading) "Anmeldung läuft..." else "Anmelden")
}
TextButton(onClick = { navController.navigate(Destinations.PasswordReset.route) }, modifier = Modifier.fillMaxWidth()) {
Text("Passwort vergessen?")
}
TextButton(onClick = { navController.navigate(Destinations.Register.route) }, modifier = Modifier.fillMaxWidth()) {
Text("Registrierung beantragen")
}
} else {
Text("Angemeldet", style = MaterialTheme.typography.titleLarge, color = Color(0xFF166534))
Text(state.userName.orEmpty(), color = Accent900)
if (state.roles.isNotEmpty()) Text(state.roles.joinToString(", "), color = Accent500)
OutlinedButton(onClick = viewModel::logout, modifier = Modifier.fillMaxWidth()) { Text("Abmelden") }
}
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
state.message?.let { Text(it, color = Color(0xFF166534)) }
}
}
}
item {
Surface(color = Primary100, shape = RoundedCornerShape(9.dp)) {
Text(
"Nur für Vereinsmitglieder. Kein Zugang? Kontaktieren Sie den Vorstand.",
color = Primary900,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(16.dp),
)
}
}
}
}

View File

@@ -0,0 +1,84 @@
package de.harheimertc.ui.screens.login
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.repositories.LoginRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class LoginUiState(
val email: String = "",
val password: String = "",
val loading: Boolean = false,
val restoring: Boolean = true,
val loggedIn: Boolean = false,
val userName: String? = null,
val roles: List<String> = emptyList(),
val error: String? = null,
val message: String? = null,
)
@HiltViewModel
class LoginViewModel @Inject constructor(private val repository: LoginRepository) : ViewModel() {
private val _state = MutableStateFlow(LoginUiState())
val state: StateFlow<LoginUiState> = _state
init {
viewModelScope.launch {
repository.status()
.onSuccess { status ->
_state.value = _state.value.copy(
restoring = false,
loggedIn = status.isLoggedIn,
userName = status.user?.name ?: status.user?.email,
roles = status.roles.ifEmpty { status.user?.roles.orEmpty() },
)
}
.onFailure { _state.value = _state.value.copy(restoring = false) }
}
}
fun setEmail(value: String) {
_state.value = _state.value.copy(email = value, error = null)
}
fun setPassword(value: String) {
_state.value = _state.value.copy(password = value, error = null)
}
fun login() {
val current = _state.value
if (current.email.isBlank() || current.password.isBlank()) {
_state.value = current.copy(error = "Bitte E-Mail-Adresse und Passwort eingeben.")
return
}
viewModelScope.launch {
_state.value = current.copy(loading = true, error = null, message = null)
repository.login(current.email, current.password)
.onSuccess { response ->
_state.value = current.copy(
password = "",
loading = false,
restoring = false,
loggedIn = true,
userName = response.user?.name ?: response.user?.email,
roles = response.user?.roles.orEmpty(),
message = "Anmeldung erfolgreich.",
)
}
.onFailure {
_state.value = current.copy(loading = false, error = it.message ?: "Anmeldung fehlgeschlagen.")
}
}
}
fun logout() {
viewModelScope.launch {
repository.logout()
_state.value = LoginUiState(restoring = false, message = "Sie wurden abgemeldet.")
}
}
}

View File

@@ -0,0 +1,176 @@
package de.harheimertc.ui.screens.login
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.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.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
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.ui.navigation.Destinations
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
import de.harheimertc.ui.theme.Primary900
@Composable
fun PasswordResetScreen(
navController: NavController,
showBackNavigation: Boolean = true,
viewModel: PasswordResetViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
AuthFormPage(
title = "Passwort zurücksetzen",
subtitle = "Geben Sie Ihre E-Mail-Adresse ein, um Ihr Passwort zurückzusetzen.",
onBack = { navController.navigate(Destinations.Login.route) },
showBackNavigation = showBackNavigation,
) {
OutlinedTextField(
state.email,
viewModel::setEmail,
label = { Text("E-Mail-Adresse") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
MessageLines(state.error, state.message)
Button(onClick = viewModel::submit, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
Text(if (state.loading) "Wird gesendet..." else "Passwort zurücksetzen")
}
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.")
}
}
@Composable
fun RegisterScreen(
navController: NavController,
showBackNavigation: Boolean = true,
viewModel: RegisterViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val form = state.form
AuthFormPage(
title = "Registrierung",
subtitle = "Beantragen Sie einen Zugang zum Mitgliederbereich.",
onBack = { navController.navigate(Destinations.Login.route) },
showBackNavigation = showBackNavigation,
) {
OutlinedTextField(form.name, { viewModel.update(form.copy(name = it)) }, label = { Text("Name *") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(
form.email,
{ viewModel.update(form.copy(email = it)) },
label = { Text("E-Mail-Adresse *") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
form.phone,
{ viewModel.update(form.copy(phone = it)) },
label = { Text("Telefon") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
form.birthDate,
{ viewModel.update(form.copy(birthDate = it)) },
label = { Text("Geburtsdatum * (JJJJ-MM-TT)") },
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
form.password,
{ viewModel.update(form.copy(password = it)) },
label = { Text("Passwort *") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
form.passwordRepeat,
{ viewModel.update(form.copy(passwordRepeat = it)) },
label = { Text("Passwort wiederholen *") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(form.showBirthday, { viewModel.update(form.copy(showBirthday = it)) })
Text("Geburtstag im Mitgliederbereich anzeigen")
}
MessageLines(state.error, state.message)
Button(onClick = viewModel::submit, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
Text(if (state.loading) "Wird gesendet..." else "Registrierung beantragen")
}
AuthNotice("Ihre Registrierung muss vor der Anmeldung vom Vorstand freigegeben werden.")
}
}
@Composable
private fun AuthFormPage(
title: String,
subtitle: String,
onBack: () -> Unit,
showBackNavigation: Boolean,
content: @Composable ColumnScope.() -> Unit,
) {
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 = onBack) { Text("< Login", color = Primary600, fontWeight = FontWeight.SemiBold) }
}
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(top = 22.dp))
Text(subtitle, color = Accent500, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(top = 9.dp, bottom = 14.dp))
}
item {
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 3.dp) {
Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(15.dp)) {
content()
}
}
}
}
}
@Composable
private fun MessageLines(error: String?, message: String?) {
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
message?.let { Text(it, color = Color(0xFF166534)) }
}
@Composable
private fun AuthNotice(text: String) {
Surface(color = Primary100, shape = RoundedCornerShape(8.dp)) {
Text(text, color = Primary900, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(13.dp))
}
}

View File

@@ -0,0 +1,107 @@
package de.harheimertc.ui.screens.login
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.RegistrationRequest
import de.harheimertc.data.RegistrationVisibility
import de.harheimertc.repositories.LoginRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class PasswordResetUiState(
val email: String = "",
val loading: Boolean = false,
val error: String? = null,
val message: String? = null,
)
@HiltViewModel
class PasswordResetViewModel @Inject constructor(private val repository: LoginRepository) : ViewModel() {
private val _state = MutableStateFlow(PasswordResetUiState())
val state: StateFlow<PasswordResetUiState> = _state
fun setEmail(value: String) {
_state.value = _state.value.copy(email = value, error = null)
}
fun submit() {
val email = _state.value.email.trim()
if (!email.contains("@")) {
_state.value = _state.value.copy(error = "Bitte eine gültige E-Mail-Adresse eingeben.")
return
}
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null, message = null)
repository.resetPassword(email)
.onSuccess { response ->
_state.value = PasswordResetUiState(message = response.message ?: "Anfrage wurde gesendet.")
}
.onFailure {
_state.value = _state.value.copy(loading = false, error = "Anfrage konnte nicht gesendet werden.")
}
}
}
}
data class RegisterFormState(
val name: String = "",
val email: String = "",
val phone: String = "",
val birthDate: String = "",
val password: String = "",
val passwordRepeat: String = "",
val showBirthday: Boolean = true,
)
data class RegisterUiState(
val form: RegisterFormState = RegisterFormState(),
val loading: Boolean = false,
val error: String? = null,
val message: String? = null,
)
@HiltViewModel
class RegisterViewModel @Inject constructor(private val repository: LoginRepository) : ViewModel() {
private val _state = MutableStateFlow(RegisterUiState())
val state: StateFlow<RegisterUiState> = _state
fun update(form: RegisterFormState) {
_state.value = _state.value.copy(form = form, error = null)
}
fun submit() {
val form = _state.value.form
val error = when {
form.name.isBlank() || form.email.isBlank() || form.birthDate.isBlank() || form.password.isBlank() ->
"Bitte alle Pflichtfelder ausfüllen."
!form.email.contains("@") -> "Bitte eine gültige E-Mail-Adresse eingeben."
form.password.length < 8 -> "Das Passwort muss mindestens 8 Zeichen lang sein."
form.password != form.passwordRepeat -> "Die Passwörter stimmen nicht überein."
else -> null
}
if (error != null) {
_state.value = _state.value.copy(error = error)
return
}
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null, message = null)
repository.register(
RegistrationRequest(
name = form.name.trim(),
email = form.email.trim(),
phone = form.phone.trim().takeIf(String::isNotBlank),
password = form.password,
geburtsdatum = form.birthDate.trim(),
visibility = RegistrationVisibility(showBirthday = form.showBirthday),
),
).onSuccess { response ->
_state.value = RegisterUiState(message = response.message ?: "Registrierung wurde eingereicht.")
}.onFailure {
_state.value = _state.value.copy(loading = false, error = it.message ?: "Registrierung fehlgeschlagen.")
}
}
}
}

View File

@@ -0,0 +1,398 @@
package de.harheimertc.ui.screens.mannschaften
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
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.width
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.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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.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.navigation.Destinations
import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary100
import de.harheimertc.ui.theme.Primary600
import de.harheimertc.ui.theme.Primary700
@Composable
fun MannschaftenScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: MannschaftenViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
item {
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))
}
when {
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)) }
}
}
item {
Surface(color = Primary100, shape = RoundedCornerShape(8.dp)) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Spielpläne & Ergebnisse", style = MaterialTheme.typography.titleLarge, color = Accent900)
Text("Alle aktuellen Spielpläne und Ergebnisse unserer Mannschaften.", color = Accent500)
Button(
onClick = { navController.navigate(Destinations.Spielplan.route) },
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
) { Text("Zu den Spielplänen") }
}
}
}
}
}
@Composable
private fun TeamCard(team: Mannschaft, onOpen: () -> Unit) {
Surface(
color = Color.White,
shape = RoundedCornerShape(8.dp),
shadowElevation = 2.dp,
modifier = Modifier.fillMaxWidth().clickable(onClick = onOpen),
) {
Column {
Column(Modifier.fillMaxWidth().background(Brush.horizontalGradient(listOf(Primary600, Primary700))).padding(16.dp)) {
Text(team.mannschaft, style = MaterialTheme.typography.titleLarge, color = Color.White)
Text(team.liga, color = Primary100, modifier = Modifier.padding(top = 4.dp))
}
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
TeamInfo("Staffelleiter", team.staffelleiter)
TeamInfo("Heimspieltag", team.heimspieltag)
TeamInfo("Spielsystem", team.spielsystem)
Text("${team.spieler.size} Spieler - Details anzeigen", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
}
}
@Composable
fun MannschaftDetailScreen(
slug: 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) }
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item { BackLink(navController, showBackNavigation) }
if (state.loading) {
item { Loading() }
} else {
state.team?.let { team ->
item { TeamHeader(team) }
item {
InfoCard("Liga-Informationen") {
TeamInfo("Staffelleiter", team.staffelleiter)
TeamInfo("Telefon", team.telefon)
TeamInfo("Heimspieltag", team.heimspieltag)
TeamInfo("Spielsystem", team.spielsystem)
}
}
item {
InfoCard("Mannschaftsaufstellung") {
team.spieler.forEach { player ->
val captain = player == team.mannschaftsfuehrer
Surface(color = if (captain) Primary100 else Accent100, shape = RoundedCornerShape(6.dp)) {
Row(Modifier.fillMaxWidth().padding(10.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text(player, color = Accent900)
if (captain) Text("Mannschaftsführer", color = Primary600, style = MaterialTheme.typography.labelSmall)
}
}
}
}
}
item {
SchedulePanelHeader(
season = state.season,
selectedTab = selectedTab,
hasTable = team.informationenLink.isNotBlank(),
onSelected = { selectedTab = it },
)
}
when (selectedTab) {
DetailTab.Matches -> {
if (state.matchesError != null) item { Text(state.matchesError.orEmpty(), color = Primary700) }
if (state.matches.isEmpty() && state.matchesError == null) {
item { Text("Für diese Mannschaft sind aktuell keine Spiele vorhanden.", color = Accent500) }
} else {
items(state.matches) { MatchCard(it) }
}
}
DetailTab.Table -> {
when {
state.tableLoading -> item { Loading() }
state.tableError != null -> item { Text(state.tableError.orEmpty(), color = Primary700) }
state.tableRows.isEmpty() -> item { Text("Für diese Mannschaft ist aktuell keine Tabelle hinterlegt.", color = Accent500) }
else -> {
item { TableLegend() }
items(state.tableRows) { TableRow(it) }
}
}
}
}
} ?: item { ErrorPanel(state.matchesError ?: "Mannschaft nicht gefunden.") { viewModel.load(slug) } }
}
}
}
private enum class DetailTab { Matches, Table }
@Composable
private fun SchedulePanelHeader(
season: String?,
selectedTab: DetailTab,
hasTable: Boolean,
onSelected: (DetailTab) -> Unit,
) {
Surface(color = Color.White, shape = RoundedCornerShape(8.dp), shadowElevation = 2.dp) {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Aktueller Spielplan", style = MaterialTheme.typography.titleLarge, color = Accent900)
season?.let { Text("Saison ${seasonLabel(it)}", color = Accent500) }
Row(horizontalArrangement = Arrangement.spacedBy(5.dp)) {
DetailTabButton("Matches", selectedTab == DetailTab.Matches) { onSelected(DetailTab.Matches) }
if (hasTable) {
DetailTabButton("Tabelle", selectedTab == DetailTab.Table) { onSelected(DetailTab.Table) }
}
}
}
}
}
@Composable
private fun DetailTabButton(label: String, selected: Boolean, onClick: () -> Unit) {
Surface(
color = if (selected) Color.White else Accent100,
shape = RoundedCornerShape(6.dp),
shadowElevation = if (selected) 2.dp else 0.dp,
modifier = Modifier.clickable(onClick = onClick),
) {
Text(
label,
color = if (selected) Primary700 else Accent500,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 15.dp, vertical = 9.dp),
)
}
}
@Composable
private fun TeamHeader(team: Mannschaft) {
Column(Modifier.fillMaxWidth().background(Primary600, RoundedCornerShape(8.dp)).padding(20.dp)) {
Text(team.mannschaft, style = MaterialTheme.typography.displayLarge, color = Color.White)
Text(team.liga, color = Primary100, modifier = Modifier.padding(top = 5.dp))
}
}
@Composable
private fun InfoCard(title: String, content: @Composable ColumnScope.() -> Unit) {
Surface(color = Color.White, shape = RoundedCornerShape(8.dp), shadowElevation = 2.dp) {
Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
content()
}
}
}
@Composable
private fun TeamInfo(label: String, value: String) {
Row(Modifier.fillMaxWidth()) {
Text("$label:", color = Accent500, modifier = Modifier.weight(0.38f))
Text(value.ifBlank { "-" }, color = Accent900, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(0.62f))
}
}
@Composable
private fun MatchCard(game: SpielDto) {
Surface(color = Color.White, shape = RoundedCornerShape(8.dp), shadowElevation = 1.dp) {
Column(Modifier.fillMaxWidth().padding(13.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(game.termin, color = Accent900, fontWeight = FontWeight.SemiBold)
val result = if (game.spieleHeim.isNotBlank() || game.spieleGast.isNotBlank()) "${game.spieleHeim}:${game.spieleGast}" else "-"
Text(result, color = Primary600, fontWeight = FontWeight.SemiBold)
}
Text("${game.heimMannschaft} - ${game.gastMannschaft}", color = Accent900)
Text("${game.altersklasse} / ${game.staffel.removePrefix("E")}", color = Accent500, style = MaterialTheme.typography.labelSmall)
}
}
}
@Composable
private fun TableLegend() {
Surface(color = Accent100, shape = RoundedCornerShape(6.dp)) {
BoxWithConstraints(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp)) {
val compact = maxWidth < 560.dp
if (compact) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Platz / Mannschaft", color = Accent500, style = MaterialTheme.typography.labelSmall)
TableMetricsHeader()
}
} else {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text("Platz", color = Accent500, style = MaterialTheme.typography.labelSmall, modifier = Modifier.width(55.dp))
Text("Mannschaft", color = Accent500, style = MaterialTheme.typography.labelSmall, modifier = Modifier.weight(1f))
TableMetricsHeader()
}
}
}
}
}
@Composable
private fun TableRow(row: LeagueTableRowDto) {
val ourTeam = row.teamName.contains("Harheimer TC", ignoreCase = true)
Surface(
color = if (ourTeam) Primary100 else Color.White,
shape = RoundedCornerShape(8.dp),
shadowElevation = 1.dp,
) {
BoxWithConstraints(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 10.dp)) {
val compact = maxWidth < 560.dp
Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
if (compact) {
Row(horizontalArrangement = Arrangement.spacedBy(7.dp)) {
Text("${row.rank ?: "-"}.", color = Accent900, fontWeight = FontWeight.SemiBold)
Text(row.teamName.ifBlank { "-" }, color = Accent900, fontWeight = if (ourTeam) FontWeight.Bold else FontWeight.Normal)
Movement(row.movement)
}
TableMetrics(row)
} else {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text("${row.rank ?: "-"}.", color = Accent900, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(55.dp))
Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(7.dp)) {
Text(row.teamName.ifBlank { "-" }, color = Accent900, fontWeight = if (ourTeam) FontWeight.Bold else FontWeight.Normal)
Movement(row.movement)
}
TableStanding(row)
}
}
Text(
"Sätze ${formatSets(row)} Bälle ${formatGames(row)}",
color = Accent500,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
@Composable
private fun Movement(value: String?) {
when (value) {
"rise" -> Text("", color = Color(0xFF15803D))
"fall" -> Text("", color = Primary700)
}
}
@Composable
private fun TableMetricsHeader() {
Row(verticalAlignment = Alignment.CenterVertically) {
TableCell("Sp.", 48.dp, Accent500)
TableCell("S", 38.dp, Accent500)
TableCell("U", 38.dp, Accent500)
TableCell("N", 38.dp, Accent500)
TableCell("Punkte", 66.dp, Accent500)
}
}
@Composable
private fun TableStanding(row: LeagueTableRowDto) = TableMetrics(row)
@Composable
private fun TableMetrics(row: LeagueTableRowDto) {
Row(verticalAlignment = Alignment.CenterVertically) {
TableCell((row.meetings ?: "-").toString(), 48.dp, Accent900)
TableCell((row.won ?: 0).toString(), 38.dp, Accent900)
TableCell((row.tied ?: 0).toString(), 38.dp, Accent900)
TableCell((row.lost ?: 0).toString(), 38.dp, Accent900)
TableCell(formatPoints(row), 66.dp, Accent900)
}
}
@Composable
private fun TableCell(value: String, width: androidx.compose.ui.unit.Dp, color: Color) {
Text(
value,
color = color,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.width(width),
)
}
@Composable
private fun BackLink(navController: NavController, visible: Boolean) {
if (visible) TextButton(onClick = { navController.popBackStack() }) { Text("< Startseite", color = Primary600) }
}
@Composable
private fun Loading() {
Column(Modifier.fillMaxWidth().padding(30.dp), horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(color = Primary600)
}
}
@Composable
private fun ErrorPanel(message: String, retry: () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(message, color = Primary700)
Button(onClick = retry, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) { Text("Erneut laden") }
}
}
private fun seasonLabel(value: String): String =
Regex("^(\\d{2})--(\\d{2})$").matchEntire(value)?.let { "20${it.groupValues[1]}/${it.groupValues[2]}" } ?: value
private fun formatSets(row: LeagueTableRowDto): String = "${row.setsWon ?: 0}:${row.setsLost ?: 0}"
private fun formatGames(row: LeagueTableRowDto): String = "${row.gamesWon ?: 0}:${row.gamesLost ?: 0}"
private fun formatPoints(row: LeagueTableRowDto): String = "${row.pointsWon ?: 0}:${row.pointsLost ?: 0}"

View File

@@ -0,0 +1,139 @@
package de.harheimertc.ui.screens.mannschaften
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.SpielDto
import de.harheimertc.data.LeagueTableRowDto
import de.harheimertc.repositories.Mannschaft
import de.harheimertc.repositories.MannschaftenRepository
import de.harheimertc.repositories.SpielplanRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class MannschaftenUiState(
val loading: Boolean = true,
val error: String? = null,
val teams: List<Mannschaft> = emptyList(),
)
@HiltViewModel
class MannschaftenViewModel @Inject constructor(
private val repository: MannschaftenRepository,
) : ViewModel() {
private val _state = MutableStateFlow(MannschaftenUiState())
val state: StateFlow<MannschaftenUiState> = _state
init {
load()
}
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.") }
}
}
}
data class MannschaftDetailUiState(
val loading: Boolean = true,
val matchesError: String? = null,
val team: Mannschaft? = null,
val matches: List<SpielDto> = emptyList(),
val season: String? = null,
val tableLoading: Boolean = false,
val tableError: String? = null,
val tableRows: List<LeagueTableRowDto> = emptyList(),
)
@HiltViewModel
class MannschaftDetailViewModel @Inject constructor(
private val mannschaftenRepository: MannschaftenRepository,
private val spielplanRepository: SpielplanRepository,
) : ViewModel() {
private val _state = MutableStateFlow(MannschaftDetailUiState())
val state: StateFlow<MannschaftDetailUiState> = _state
private var loadedSlug: String? = null
fun load(slug: String) {
if (loadedSlug == slug) return
loadedSlug = slug
viewModelScope.launch {
_state.value = MannschaftDetailUiState(loading = true)
val team = mannschaftenRepository.fetchMannschaften().getOrDefault(emptyList()).find { it.slug == slug }
if (team == null) {
_state.value = MannschaftDetailUiState(loading = false, matchesError = "Mannschaft nicht gefunden.")
return@launch
}
spielplanRepository.fetchSpielplan()
.onSuccess { plan ->
_state.value = MannschaftDetailUiState(
loading = false,
team = team,
matches = plan.data.filter { matchesTeam(it, team.mannschaft) },
season = plan.season,
)
if (team.informationenLink.isNotBlank()) {
loadTable(team, plan.season)
}
}
.onFailure {
_state.value = MannschaftDetailUiState(
loading = false,
team = team,
matchesError = "Der aktuelle Spielplan konnte nicht geladen werden.",
)
}
}
}
private suspend fun loadTable(team: Mannschaft, season: String?) {
_state.value = _state.value.copy(tableLoading = true, tableError = null)
spielplanRepository.fetchTeamTable(team.mannschaft, season)
.onSuccess { response ->
_state.value = _state.value.copy(
tableLoading = false,
tableRows = response.table?.table?.leagueTable.orEmpty(),
)
}
.onFailure {
_state.value = _state.value.copy(
tableLoading = false,
tableError = "Tabelle konnte nicht geladen werden.",
tableRows = emptyList(),
)
}
}
private fun matchesTeam(game: SpielDto, cmsName: String): Boolean {
val variant = when (cmsName) {
"Erwachsene 1" -> "harheimer tc"
"Erwachsene 2" -> "harheimer tc ii"
"Erwachsene 3" -> "harheimer tc iii"
"Erwachsene 4" -> "harheimer tc iv"
"Erwachsene 5" -> "harheimer tc v"
"Jugendmannschaft", "Jugend I" -> "harheimer tc"
else -> return false
}
fun exact(value: String): Boolean =
if (variant == "harheimer tc") {
value == variant || (value.startsWith("$variant ") && !Regex("harheimer tc\\s+[ivx]+").containsMatchIn(value))
} else value == variant || value.startsWith("$variant ")
val home = game.heimMannschaft.lowercase()
val away = game.gastMannschaft.lowercase()
if (!exact(home) && !exact(away)) return false
return if (cmsName.startsWith("Erwachsene")) {
(exact(home) && game.heimAltersklasse.contains("Erwachsene", true)) ||
(exact(away) && game.gastAltersklasse.contains("Erwachsene", true))
} else {
game.heimAltersklasse.contains("Jugend", true) || game.gastAltersklasse.contains("Jugend", true) ||
home.contains("jugend") || away.contains("jugend")
}
}
}

View File

@@ -0,0 +1,213 @@
package de.harheimertc.ui.screens.membership
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.layout.width
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.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface
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.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.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import de.harheimertc.ui.navigation.Destinations
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
import de.harheimertc.ui.theme.Primary900
@Composable
fun MembershipScreen(
navController: NavController,
showBackNavigation: Boolean = true,
viewModel: MembershipViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
val form = state.form
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 18.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item {
if (showBackNavigation) {
TextButton(onClick = { navController.popBackStack() }) {
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
Text("Mitgliedschaft", style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 14.dp))
Text(
"Werden Sie Teil unserer Tischtennis-Familie.",
color = Accent500,
modifier = Modifier.padding(top = 7.dp, bottom = 10.dp),
)
}
item {
InfoCard(
title = "Vereinssatzung",
text = "Unsere aktuelle Vereinssatzung und der Mitgliedsantrag stehen auf der Website als PDF bereit.",
)
}
item {
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 3.dp) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(13.dp)) {
Text("Beitrittserklärung", style = MaterialTheme.typography.titleLarge, color = Accent900)
FormHeading("Persönliche Daten")
TextInput("Vorname *", form.vorname) { viewModel.update(form.copy(vorname = it)) }
TextInput("Nachname *", form.nachname) { viewModel.update(form.copy(nachname = it)) }
TextInput("Straße und Hausnummer *", form.strasse) { viewModel.update(form.copy(strasse = it)) }
Row(horizontalArrangement = Arrangement.spacedBy(9.dp)) {
TextInput("PLZ *", form.plz, Modifier.weight(0.38f), KeyboardType.Number) { viewModel.update(form.copy(plz = it)) }
TextInput("Wohnort *", form.ort, Modifier.weight(0.62f)) { viewModel.update(form.copy(ort = it)) }
}
TextInput("Geburtsdatum * (JJJJ-MM-TT)", form.geburtsdatum) { viewModel.update(form.copy(geburtsdatum = it)) }
TextInput("E-Mail *", form.email, keyboard = KeyboardType.Email) { viewModel.update(form.copy(email = it)) }
TextInput("Telefon (Mobil)", form.telefon, keyboard = KeyboardType.Phone) { viewModel.update(form.copy(telefon = it)) }
FormHeading("Mitgliedschaftsart")
ChoiceRow("Aktives Mitglied", form.art == "aktiv") { viewModel.update(form.copy(art = "aktiv")) }
ChoiceRow("Passives Mitglied", form.art == "passiv") { viewModel.update(form.copy(art = "passiv")) }
FeeInfo()
AgreementRow("Hierzu erteile ich das SEPA-Lastschriftmandat. *", form.lastschrift) {
viewModel.update(form.copy(lastschrift = it))
}
FormHeading("Bankdaten für SEPA-Lastschrift")
TextInput("Kontoinhaber *", form.kontoinhaber) { viewModel.update(form.copy(kontoinhaber = it)) }
TextInput("IBAN *", form.iban) { viewModel.update(form.copy(iban = it)) }
TextInput("BIC", form.bic) { viewModel.update(form.copy(bic = it)) }
TextInput("Kreditinstitut", form.bank) { viewModel.update(form.copy(bank = it)) }
FormHeading("Datenschutz und Vereinssatzung")
AgreementRow("Ich willige in die erforderliche Verarbeitung und Veröffentlichung gemäß Antrag ein. *", form.datenschutz) {
viewModel.update(form.copy(datenschutz = it))
}
AgreementRow("Ich erkenne die Vereinssatzung an. *", form.satzung) {
viewModel.update(form.copy(satzung = it))
}
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
state.message?.let { Text(it, color = Color(0xFF166534), fontWeight = FontWeight.SemiBold) }
Button(onClick = viewModel::submit, enabled = !state.sending, modifier = Modifier.fillMaxWidth()) {
if (state.sending) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = Color.White)
Spacer(Modifier.width(8.dp))
}
Text(if (state.sending) "Formular wird erstellt..." else "Beitrittsformular erstellen")
}
state.pdfUri?.let { uri ->
OutlinedButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(Uri.parse(uri), "application/pdf")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
},
)
},
modifier = Modifier.fillMaxWidth(),
) { Text("Erstelltes PDF öffnen") }
}
}
}
}
item {
Surface(color = Primary600, shape = RoundedCornerShape(14.dp)) {
Column(Modifier.fillMaxWidth().padding(22.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text("Noch Fragen zur Mitgliedschaft?", color = Color.White, style = MaterialTheme.typography.titleLarge)
Text("Kontaktieren Sie uns - wir beraten Sie gerne persönlich.", color = Primary100, modifier = Modifier.padding(vertical = 12.dp))
OutlinedButton(onClick = { navController.navigate(Destinations.Contact.route) }) {
Text("Jetzt Kontakt aufnehmen")
}
}
}
}
}
}
@Composable
private fun FormHeading(text: String) {
Text(text, fontWeight = FontWeight.SemiBold, color = Accent900, modifier = Modifier.padding(top = 12.dp))
}
@Composable
private fun TextInput(
label: String,
value: String,
modifier: Modifier = Modifier.fillMaxWidth(),
keyboard: KeyboardType = KeyboardType.Text,
onChange: (String) -> Unit,
) {
OutlinedTextField(
value = value,
onValueChange = onChange,
label = { Text(label) },
keyboardOptions = KeyboardOptions(keyboardType = keyboard),
modifier = modifier,
singleLine = true,
)
}
@Composable
private fun ChoiceRow(label: String, selected: Boolean, onClick: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = selected, onClick = onClick)
Text(label, color = Accent700)
}
}
@Composable
private fun AgreementRow(label: String, selected: Boolean, onChange: (Boolean) -> Unit) {
Row(verticalAlignment = Alignment.Top) {
Checkbox(checked = selected, onCheckedChange = onChange)
Text(label, color = Accent700, modifier = Modifier.padding(top = 12.dp))
}
}
@Composable
private fun FeeInfo() {
Surface(color = Color(0xFFF4F4F5), shape = RoundedCornerShape(8.dp)) {
Column(Modifier.fillMaxWidth().padding(13.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text("Jährlicher Mitgliedsbeitrag", fontWeight = FontWeight.SemiBold, color = Accent900)
Text("120 EUR Erwachsene | 72 EUR Jugendliche | 30 EUR passive Mitglieder", color = Accent700)
}
}
}
@Composable
private fun InfoCard(title: String, text: String) {
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 2.dp) {
Column(Modifier.fillMaxWidth().padding(18.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
Text(text, color = Accent500, modifier = Modifier.padding(top = 7.dp))
}
}
}

View File

@@ -0,0 +1,93 @@
package de.harheimertc.ui.screens.membership
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.MembershipRequest
import de.harheimertc.repositories.MembershipRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class MembershipFormState(
val vorname: String = "",
val nachname: String = "",
val strasse: String = "",
val plz: String = "",
val ort: String = "",
val geburtsdatum: String = "",
val email: String = "",
val telefon: String = "",
val art: String = "aktiv",
val kontoinhaber: String = "",
val iban: String = "",
val bic: String = "",
val bank: String = "",
val lastschrift: Boolean = false,
val datenschutz: Boolean = false,
val satzung: Boolean = false,
)
data class MembershipUiState(
val form: MembershipFormState = MembershipFormState(),
val sending: Boolean = false,
val message: String? = null,
val error: String? = null,
val pdfUri: String? = null,
)
@HiltViewModel
class MembershipViewModel @Inject constructor(private val repository: MembershipRepository) : ViewModel() {
private val _state = MutableStateFlow(MembershipUiState())
val state: StateFlow<MembershipUiState> = _state
fun update(form: MembershipFormState) {
_state.value = _state.value.copy(form = form, error = null)
}
fun submit() {
val form = _state.value.form
validate(form)?.let {
_state.value = _state.value.copy(error = it)
return
}
viewModelScope.launch {
_state.value = _state.value.copy(sending = true, error = null, message = null)
val request = MembershipRequest(
vorname = form.vorname.trim(),
nachname = form.nachname.trim(),
strasse = form.strasse.trim(),
plz = form.plz.trim(),
ort = form.ort.trim(),
geburtsdatum = form.geburtsdatum.trim(),
email = form.email.trim(),
telefon_mobil = form.telefon.trim().takeIf(String::isNotBlank),
mitgliedschaftsart = form.art,
lastschrift_erlaubt = form.lastschrift,
kontoinhaber = form.kontoinhaber.trim(),
iban = form.iban.trim(),
bic = form.bic.trim().takeIf(String::isNotBlank),
bank = form.bank.trim().takeIf(String::isNotBlank),
datenschutz_einverstanden = form.datenschutz,
satzung_anerkannt = form.satzung,
)
repository.submit(request)
.onSuccess { document ->
_state.value = _state.value.copy(sending = false, message = document.message, pdfUri = document.uri)
}
.onFailure {
_state.value = _state.value.copy(sending = false, error = "Beitrittsformular konnte nicht erstellt werden.")
}
}
}
private fun validate(form: MembershipFormState): String? = when {
listOf(form.vorname, form.nachname, form.strasse, form.plz, form.ort, form.geburtsdatum, form.email, form.kontoinhaber, form.iban)
.any { it.isBlank() } -> "Bitte alle Pflichtfelder ausfüllen."
!form.plz.matches(Regex("\\d{5}")) -> "Bitte eine gültige fünfstellige PLZ eingeben."
!form.email.contains("@") -> "Bitte eine gültige E-Mail-Adresse eingeben."
!form.lastschrift || !form.datenschutz || !form.satzung -> "Bitte die erforderlichen Einwilligungen bestätigen."
else -> null
}
}

View File

@@ -0,0 +1,88 @@
package de.harheimertc.ui.screens.publicpages
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ConfigResponse
import de.harheimertc.repositories.MeisterschaftResult
import de.harheimertc.repositories.PublicPagesRepository
import de.harheimertc.repositories.Spielsystem
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class PublicConfigUiState(
val loading: Boolean = true,
val error: String? = null,
val config: ConfigResponse? = null,
)
@HiltViewModel
class PublicConfigViewModel @Inject constructor(private val repository: PublicPagesRepository) : ViewModel() {
private val _state = MutableStateFlow(PublicConfigUiState())
val state: StateFlow<PublicConfigUiState> = _state
init {
load()
}
fun load() {
viewModelScope.launch {
_state.value = PublicConfigUiState()
repository.fetchConfig()
.onSuccess { _state.value = PublicConfigUiState(loading = false, config = it) }
.onFailure { _state.value = PublicConfigUiState(loading = false, error = "Inhalte konnten nicht geladen werden.") }
}
}
}
data class SpielsystemeUiState(
val loading: Boolean = true,
val error: String? = null,
val systems: List<Spielsystem> = emptyList(),
)
@HiltViewModel
class SpielsystemeViewModel @Inject constructor(private val repository: PublicPagesRepository) : ViewModel() {
private val _state = MutableStateFlow(SpielsystemeUiState())
val state: StateFlow<SpielsystemeUiState> = _state
init {
load()
}
fun load() {
viewModelScope.launch {
_state.value = SpielsystemeUiState()
repository.fetchSpielsysteme()
.onSuccess { _state.value = SpielsystemeUiState(loading = false, systems = it) }
.onFailure { _state.value = SpielsystemeUiState(loading = false, error = "Spielsysteme konnten nicht geladen werden.") }
}
}
}
data class VereinsmeisterschaftenUiState(
val loading: Boolean = true,
val error: String? = null,
val results: List<MeisterschaftResult> = emptyList(),
)
@HiltViewModel
class VereinsmeisterschaftenViewModel @Inject constructor(private val repository: PublicPagesRepository) : ViewModel() {
private val _state = MutableStateFlow(VereinsmeisterschaftenUiState())
val state: StateFlow<VereinsmeisterschaftenUiState> = _state
init {
load()
}
fun load() {
viewModelScope.launch {
_state.value = VereinsmeisterschaftenUiState()
repository.fetchVereinsmeisterschaften()
.onSuccess { _state.value = VereinsmeisterschaftenUiState(loading = false, results = it) }
.onFailure { _state.value = VereinsmeisterschaftenUiState(loading = false, error = "Vereinsmeisterschaften konnten nicht geladen werden.") }
}
}
}

View File

@@ -0,0 +1,112 @@
package de.harheimertc.ui.screens.publicpages
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.text.method.LinkMovementMethod
import android.widget.TextView
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.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
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.text.HtmlCompat
import androidx.navigation.NavController
import de.harheimertc.BuildConfig
import de.harheimertc.ui.theme.Accent500
import de.harheimertc.ui.theme.Accent900
import de.harheimertc.ui.theme.Primary600
@Composable
internal fun PublicPage(
navController: NavController,
showBackNavigation: Boolean,
title: String,
subtitle: String? = null,
content: LazyListScope.() -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item {
if (showBackNavigation) {
TextButton(onClick = { navController.popBackStack() }) { Text("< Startseite", color = Primary600) }
}
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 18.dp))
subtitle?.let { Text(it, color = Accent500, modifier = Modifier.padding(top = 8.dp)) }
}
content()
}
}
@Composable
internal fun PublicCard(title: String? = null, content: @Composable () -> Unit) {
Surface(color = Color.White, shape = RoundedCornerShape(8.dp), shadowElevation = 2.dp) {
Column(Modifier.fillMaxWidth().padding(17.dp), verticalArrangement = Arrangement.spacedBy(9.dp)) {
title?.let { Text(it, style = MaterialTheme.typography.titleLarge, color = Accent900) }
content()
}
}
}
@Composable
internal fun PublicLoading() {
Column(Modifier.fillMaxWidth().padding(28.dp), horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(color = Primary600)
}
}
@Composable
internal fun PublicError(message: String, retry: () -> Unit) {
PublicCard {
Text(message, color = MaterialTheme.colorScheme.error)
Button(onClick = retry, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) { Text("Erneut laden") }
}
}
@Composable
internal fun HtmlContent(html: String) {
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = { context ->
TextView(context).apply {
textSize = 17f
setTextColor(android.graphics.Color.rgb(63, 63, 70))
movementMethod = LinkMovementMethod.getInstance()
setLineSpacing(0f, 1.2f)
}
},
update = { textView ->
textView.text = HtmlCompat.fromHtml(html, HtmlCompat.FROM_HTML_MODE_COMPACT)
},
)
}
internal fun Context.openPublicUri(value: String) {
val uri = if (value.startsWith("http://") || value.startsWith("https://") || value.startsWith("mailto:")) {
value
} else {
BuildConfig.API_BASE_URL.trimEnd('/') + "/" + value.trimStart('/')
}
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uri)))
}

View File

@@ -0,0 +1,281 @@
package de.harheimertc.ui.screens.publicpages
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.draw.clip
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 coil.compose.AsyncImage
import de.harheimertc.BuildConfig
import de.harheimertc.repositories.MeisterschaftResult
import de.harheimertc.repositories.Spielsystem
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
@Composable
fun SpielsystemeScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: SpielsystemeViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
var selectedCategory by rememberSaveable { mutableStateOf("Alle Kategorien") }
val categories = state.systems.map(Spielsystem::category).filter(String::isNotBlank).distinct().sorted()
val displayed = if (selectedCategory == "Alle Kategorien") state.systems else state.systems.filter { it.category == selectedCategory }
PublicPage(
navController,
showBackNavigation,
"Spielsysteme",
"Übersicht der verschiedenen Mannschafts-Spielsysteme im Tischtennis",
) {
when {
state.loading -> item { PublicLoading() }
state.error != null -> item { PublicError(state.error.orEmpty(), viewModel::load) }
else -> {
item {
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
(categories + "Alle Kategorien").forEach { category ->
CategoryButton(category, category == selectedCategory) { selectedCategory = category }
}
}
}
displayed.forEach { system -> item { SpielsystemCard(system) } }
item {
Surface(color = Primary600, shape = RoundedCornerShape(8.dp)) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Weitere Informationen", style = MaterialTheme.typography.titleLarge, color = Color.White)
Text(
"Die Spielsysteme werden je nach Liga und Verband unterschiedlich eingesetzt. Regionale Ligen verwenden meist das Bundessystem oder das Braunschweiger System.",
color = Primary100,
)
}
}
}
}
}
}
}
@Composable
private fun CategoryButton(label: String, selected: Boolean, onClick: () -> Unit) {
Button(
onClick = onClick,
colors = ButtonDefaults.buttonColors(containerColor = if (selected) Primary600 else Color.White, contentColor = if (selected) Color.White else Accent700),
shape = RoundedCornerShape(7.dp),
) { Text(label) }
}
@Composable
private fun SpielsystemCard(system: Spielsystem) {
PublicCard {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(system.name, style = MaterialTheme.typography.titleLarge, color = Accent900, modifier = Modifier.weight(1f))
Surface(color = Primary100, shape = RoundedCornerShape(20.dp)) {
Text(system.category, color = Primary600, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp))
}
}
Text(system.teamSize, color = Primary600, fontWeight = FontWeight.SemiBold)
Text(system.description, color = Accent700)
Text("Spielabfolge: ${system.sequence}", color = Accent500)
Text("Anzahl Spiele: ${system.gameCount}", color = Accent500)
Text("Besonderheiten: ${system.features}", color = Accent500)
}
}
@Composable
fun VereinsmeisterschaftenScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: VereinsmeisterschaftenViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
var selectedYear by rememberSaveable { mutableStateOf("Alle Jahre") }
var selectedPortrait by rememberSaveable { mutableStateOf<Pair<String, String>?>(null) }
val years = state.results.map(MeisterschaftResult::year).filter(String::isNotBlank).distinct().sortedDescending()
val displayed = if (selectedYear == "Alle Jahre") state.results else state.results.filter { it.year == selectedYear }
PublicPage(
navController,
showBackNavigation,
"Vereinsmeisterschaften",
"Die Ergebnisse unserer Vereinsmeisterschaften der letzten Jahre",
) {
when {
state.loading -> item { PublicLoading() }
state.error != null -> item { PublicError(state.error.orEmpty(), viewModel::load) }
else -> {
item {
Row(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
(years + "Alle Jahre").forEach { year ->
CategoryButton(year, selectedYear == year) { selectedYear = year }
}
}
}
displayed.groupBy(MeisterschaftResult::year).toSortedMap(compareByDescending { it }).forEach { (year, results) ->
item { ChampionshipYearCard(year, results) { image, name -> selectedPortrait = image to name } }
}
item {
val singleWinners = state.results.count { it.rank == "1" && it.category.contains("Einzel") }
val doublesWinners = state.results.count { it.rank == "1" && it.category == "Doppel" }
Surface(color = Primary600, shape = RoundedCornerShape(8.dp)) {
Row(Modifier.fillMaxWidth().padding(18.dp), horizontalArrangement = Arrangement.SpaceAround) {
Statistic("${years.size}", "Jahre")
Statistic("$singleWinners", "Einzelgewinner")
Statistic("$doublesWinners", "Doppelgewinner")
}
}
}
}
}
}
selectedPortrait?.let { (image, name) ->
AlertDialog(
onDismissRequest = { selectedPortrait = null },
confirmButton = {
Button(onClick = { selectedPortrait = null }) { Text("Schließen") }
},
title = { Text(name) },
text = {
AsyncImage(
model = "${BuildConfig.API_BASE_URL.trimEnd('/')}/api/personen/$image",
contentDescription = name,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxWidth(),
)
},
)
}
}
@Composable
private fun ChampionshipYearCard(year: String, results: List<MeisterschaftResult>, onImage: (String, String) -> Unit) {
PublicCard(year) {
results.map(MeisterschaftResult::note).firstOrNull(String::isNotBlank)?.let { note ->
Surface(color = Color(0xFFFEF3C7), shape = RoundedCornerShape(6.dp)) {
Text(note, color = Color(0xFF92400E), modifier = Modifier.fillMaxWidth().padding(10.dp))
}
}
results.filter { it.category.isNotBlank() }.groupBy(MeisterschaftResult::category).forEach { (category, placements) ->
Text(category, color = Accent900, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 6.dp))
placements.sortedBy { it.rank.toIntOrNull() ?: Int.MAX_VALUE }.forEach { result ->
val names = listOf(result.playerOne, result.playerTwo).filter(String::isNotBlank).joinToString(" / ")
Surface(color = if (result.rank == "1") Color(0xFFFEF3C7) else Accent100, shape = RoundedCornerShape(6.dp)) {
Row(Modifier.fillMaxWidth().padding(10.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text("${result.rank}.", color = Accent900, fontWeight = FontWeight.Bold)
result.imageOne.takeIf(String::isNotBlank)?.let { image ->
Portrait(image, result.playerOne) { onImage(image, result.playerOne) }
}
Text(names, color = Accent900, modifier = Modifier.weight(1f))
result.imageTwo.takeIf(String::isNotBlank)?.let { image ->
Portrait(image, result.playerTwo) { onImage(image, result.playerTwo) }
}
}
}
}
}
}
}
@Composable
private fun Portrait(filename: String, name: String, onClick: () -> Unit) {
AsyncImage(
model = "${BuildConfig.API_BASE_URL.trimEnd('/')}/api/personen/$filename?width=48&height=48",
contentDescription = name,
contentScale = ContentScale.Crop,
modifier = Modifier.size(32.dp).clip(CircleShape).clickable(onClick = onClick),
)
}
@Composable
private fun Statistic(value: String, label: String) {
Column {
Text(value, color = Color.White, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(label, color = Primary100, style = MaterialTheme.typography.labelSmall)
}
}
@Composable
fun RegelnScreen(navController: NavController, showBackNavigation: Boolean) {
val context = LocalContext.current
val dttbUrl = "https://www.tischtennis.de/dttb/regeln-satzung/satzung-ordnungen.html"
PublicPage(
navController,
showBackNavigation,
"Tischtennis-Regeln",
"Offizielle Regeln und Bestimmungen für den Tischtennissport",
) {
item {
PublicCard("Offizielles ITTF-Reglement") {
Text("Die offiziellen Regeln des Internationalen Tischtennis-Verbands gelten weltweit für Wettkämpfe und Turniere.", color = Accent700)
Button(onClick = { context.openPublicUri(dttbUrl) }, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) {
Text("Offizielle Regeln öffnen")
}
}
}
item {
PublicCard("Tischtennis-Regeln Light") {
Text("Eine kompakte Übersicht der wichtigsten Regeln für Einsteiger und Hobbyspieler.", color = Accent700)
Button(
onClick = { context.openPublicUri("/documents/Tischtennisregeln%20light.pdf") },
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
) { Text("Regeln Light als PDF öffnen") }
}
}
item { Text("Grundregeln im Überblick", style = MaterialTheme.typography.titleLarge, color = Accent900) }
listOf(
"Spielfeld" to "Tisch: 2,74 m x 1,525 m, Höhe: 76 cm. Netz: 15,25 cm hoch.",
"Ball" to "Durchmesser: 40 mm. Gewicht: 2,7 g.",
"Schläger" to "Belag: schwarz und farbig. Holz: mindestens 85 Prozent.",
"Aufschlag" to "Ball muss sichtbar mindestens 16 cm hochgeworfen werden.",
"Satz" to "Gewinn bei 11 Punkten mit mindestens 2 Punkten Vorsprung.",
"Spiel" to "Best of 5 oder 7 Sätze; Aufschlagwechsel alle 2 Punkte.",
).forEach { rule ->
item { PublicCard(rule.first) { Text(rule.second, color = Accent700) } }
}
item {
Surface(color = Primary600, shape = RoundedCornerShape(8.dp)) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Weitere Informationen", style = MaterialTheme.typography.titleLarge, color = Color.White)
Text("Für regionale Turniere können ergänzende Bestimmungen gelten.", color = Primary100)
Button(onClick = { context.openPublicUri(dttbUrl) }) { Text("DTTB-Regeln und Ordnungen") }
}
}
}
}
}

View File

@@ -0,0 +1,219 @@
package de.harheimertc.ui.screens.publicpages
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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 coil.compose.AsyncImage
import androidx.compose.ui.layout.ContentScale
import de.harheimertc.BuildConfig
import de.harheimertc.data.ConfigResponse
import de.harheimertc.data.BoardMemberDto
import de.harheimertc.data.LinkSectionDto
import de.harheimertc.repositories.linkSections
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
@Composable
fun AboutScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: PublicConfigViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
CmsHtmlScreen(navController, showBackNavigation, "Über uns", state.config?.seiten?.ueberUns, state.loading, state.error, viewModel::load)
}
@Composable
fun GeschichteScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: PublicConfigViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
CmsHtmlScreen(navController, showBackNavigation, "Geschichte", state.config?.seiten?.geschichte, state.loading, state.error, viewModel::load)
}
@Composable
private fun CmsHtmlScreen(
navController: NavController,
showBackNavigation: Boolean,
title: String,
html: String?,
loading: Boolean,
error: String?,
retry: () -> Unit,
) {
PublicPage(navController, showBackNavigation, title) {
when {
loading -> item { PublicLoading() }
error != null -> item { PublicError(error, retry) }
html.isNullOrBlank() -> item { Text("Für diese Seite ist derzeit kein Inhalt hinterlegt.", color = Accent500) }
else -> item { PublicCard { HtmlContent(html) } }
}
}
}
@Composable
fun SatzungScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: PublicConfigViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
PublicPage(navController, showBackNavigation, "Satzung") {
when {
state.loading -> item { PublicLoading() }
state.error != null -> item { PublicError(state.error.orEmpty(), viewModel::load) }
else -> state.config?.seiten?.satzung?.let { satzung ->
item {
PublicCard {
if (satzung.content.isNotBlank()) HtmlContent(satzung.content)
if (satzung.pdfUrl.isNotBlank()) {
Button(
onClick = { context.openPublicUri(satzung.pdfUrl) },
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
) { Text("Satzung als PDF öffnen") }
}
}
}
}
}
}
}
@Composable
fun VorstandScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: PublicConfigViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
PublicPage(
navController,
showBackNavigation,
"Vorstand",
"Unser Vorstandsteam leitet den Harheimer TC.",
) {
when {
state.loading -> item { PublicLoading() }
state.error != null -> item { PublicError(state.error.orEmpty(), viewModel::load) }
else -> {
val vorstand = state.config?.vorstand
listOf(
"Vorsitzender" to vorstand?.vorsitzender,
"Stellvertreter" to vorstand?.stellvertreter,
"Kassenwart" to vorstand?.kassenwart,
"Schriftführer" to vorstand?.schriftfuehrer,
"Sportwart" to vorstand?.sportwart,
"Jugendwart" to vorstand?.jugendwart,
).filter { !it.second?.vorname.isNullOrBlank() }.forEach { (role, member) ->
item { BoardMemberCard(role, member!!, onMail = { context.openPublicUri("mailto:$it") }) }
}
}
}
}
}
@Composable
private fun BoardMemberCard(role: String, member: BoardMemberDto, onMail: (String) -> Unit) {
PublicCard {
member.imageFilename?.takeIf(String::isNotBlank)?.let { filename ->
AsyncImage(
model = "${BuildConfig.API_BASE_URL.trimEnd('/')}/api/personen/$filename?width=120&height=120",
contentDescription = "${member.vorname} ${member.nachname}",
contentScale = ContentScale.Crop,
modifier = Modifier.size(64.dp).clip(CircleShape),
)
}
Surface(color = Primary100, shape = RoundedCornerShape(6.dp)) {
Text(role, color = Primary600, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp))
}
Text("${member.vorname} ${member.nachname}", style = MaterialTheme.typography.titleLarge, color = Accent900)
member.strasse.takeIf(String::isNotBlank)?.let { Text(it, color = Accent700) }
if (member.plz.isNotBlank() || member.ort.isNotBlank()) Text("${member.plz} ${member.ort}".trim(), color = Accent700)
member.telefon.takeIf(String::isNotBlank)?.let { Text("Tel. $it", color = Accent700) }
member.email.takeIf(String::isNotBlank)?.let { email ->
Button(
onClick = { onMail(email) },
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
) { Text(email) }
}
}
}
@Composable
fun LinksScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: PublicConfigViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
PublicPage(
navController,
showBackNavigation,
"Links",
"Nützliche Verweise rund um Tischtennis, Verbände, Ergebnisse und Partner.",
) {
when {
state.loading -> item { PublicLoading() }
else -> (state.config ?: ConfigResponse()).linkSections().forEach { section ->
item { LinkSectionCard(section) { context.openPublicUri(it) } }
}
}
}
}
@Composable
private fun LinkSectionCard(section: LinkSectionDto, onOpen: (String) -> Unit) {
PublicCard(section.title) {
section.items.forEach { item ->
Surface(color = Accent100, shape = RoundedCornerShape(6.dp)) {
Column(Modifier.fillMaxWidth().padding(11.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row {
Text(
item.label,
color = Primary600,
fontWeight = FontWeight.SemiBold,
modifier = Modifier
.weight(1f)
.padding(end = 8.dp),
)
Button(
onClick = { onOpen(item.href) },
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
) { Text("Öffnen") }
}
item.description.takeIf(String::isNotBlank)?.let { Text(it, color = Accent500) }
}
}
}
}
}

View File

@@ -0,0 +1,253 @@
package de.harheimertc.ui.screens.spielplan
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.layout.width
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.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
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import de.harheimertc.data.SeasonDto
import de.harheimertc.data.SpielDto
import de.harheimertc.ui.theme.Accent100
import de.harheimertc.ui.theme.Accent200
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
import java.text.SimpleDateFormat
import java.util.Locale
@Composable
fun SpielplanScreen(
navController: NavController,
showBackNavigation: Boolean = true,
viewModel: SpielplanViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 18.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item {
if (showBackNavigation) {
TextButton(onClick = { navController.popBackStack() }) {
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
Text("Spielpläne", style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 15.dp))
Text("Alle Spielpläne der Mannschaften", color = Accent500, modifier = Modifier.padding(top = 5.dp, bottom = 15.dp))
}
item {
FilterPanel(
state = state,
onSeason = viewModel::selectSeason,
onCompetition = viewModel::selectWettbewerb,
onTeam = viewModel::selectTeam,
onReload = { viewModel.load() },
)
}
if (state.loading) {
item { LoadingPlan() }
} else if (state.error != null) {
item {
StatusPanel("Fehler beim Laden", state.error.orEmpty())
Button(onClick = { viewModel.load() }, modifier = Modifier.fillMaxWidth()) { Text("Erneut versuchen") }
}
} else if (state.spiele.isEmpty()) {
item { StatusPanel("Keine Spielpläne verfügbar", "Es wurden noch keine Spielplandaten hochgeladen.") }
} else {
item {
Surface(color = Color.White, shape = RoundedCornerShape(9.dp), shadowElevation = 2.dp) {
Column(Modifier.fillMaxWidth().padding(16.dp)) {
Text("Spielplan", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, color = Accent900)
Text(
"${state.selectedWettbewerb.label} - ${state.filtered.size} von ${state.spiele.size} Einträgen",
color = Accent500,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
if (state.filtered.isEmpty()) {
item { StatusPanel("Keine Einträge", "Für die gewählte Filterung sind keine Spiele vorhanden.") }
} else {
items(state.filtered) { game -> MatchRow(game) }
}
}
}
}
@Composable
private fun FilterPanel(
state: SpielplanUiState,
onSeason: (String) -> Unit,
onCompetition: (Wettbewerb) -> Unit,
onTeam: (String) -> Unit,
onReload: () -> Unit,
) {
Surface(color = Color.White, shape = RoundedCornerShape(10.dp), shadowElevation = 3.dp) {
Column(Modifier.fillMaxWidth().padding(15.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (state.seasons.isNotEmpty()) {
SelectMenu(
label = "Saison",
selected = state.seasons.firstOrNull { it.slug == state.selectedSeason }?.label ?: state.selectedSeason.orEmpty(),
options = state.seasons,
text = { it.label },
onSelected = { onSeason(it.slug) },
)
}
SelectMenu(
label = "Wettbewerb",
selected = state.selectedWettbewerb.label,
options = Wettbewerb.entries.toList(),
text = { it.label },
onSelected = onCompetition,
)
SelectMenu(
label = "Mannschaft",
selected = state.selectedTeam,
options = state.teams,
text = { it },
onSelected = onTeam,
)
Button(
onClick = onReload,
enabled = !state.loading,
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
modifier = Modifier.fillMaxWidth(),
) { Text("Spielplan laden") }
Text(
"${state.selectedWettbewerb.label} - ${state.selectedTeam} (${state.filtered.size} von ${state.spiele.size} Einträgen)",
color = Accent500,
style = MaterialTheme.typography.labelSmall,
)
}
}
}
@Composable
private fun <T> SelectMenu(
label: String,
selected: String,
options: List<T>,
text: (T) -> String,
onSelected: (T) -> Unit,
) {
var open by remember { mutableStateOf(false) }
Column {
Text(label, style = MaterialTheme.typography.labelSmall, color = Accent700)
Box {
OutlinedButton(onClick = { open = true }, modifier = Modifier.fillMaxWidth()) {
Text(selected.ifBlank { "-" }, modifier = Modifier.weight(1f), textAlign = TextAlign.Start)
Text("v")
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(text(option)) },
onClick = {
open = false
onSelected(option)
},
)
}
}
}
}
}
@Composable
private fun MatchRow(game: SpielDto) {
Surface(color = Color.White, shape = RoundedCornerShape(9.dp), shadowElevation = 1.dp) {
Column(Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(formatTermin(game.termin), fontWeight = FontWeight.SemiBold, color = Accent900)
if (game.spieleHeim.isNotBlank() || game.spieleGast.isNotBlank()) {
Text("${game.spieleHeim}:${game.spieleGast}", color = Primary600, fontWeight = FontWeight.Bold)
}
}
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(game.heimMannschaft.ifBlank { "-" }, modifier = Modifier.weight(1f), color = Accent900)
Text(" - ", color = Accent500)
Text(
game.gastMannschaft.ifBlank { "-" },
modifier = Modifier.weight(1f),
color = Accent900,
textAlign = TextAlign.End,
)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(game.runde.orEmpty(), style = MaterialTheme.typography.labelSmall, color = Accent500)
Column(horizontalAlignment = Alignment.End) {
Text(game.altersklasse.ifBlank { "-" }, style = MaterialTheme.typography.labelSmall, color = Accent700)
Text(formatStaffel(game.staffel), style = MaterialTheme.typography.labelSmall, color = Accent500)
}
}
}
}
}
@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))
}
}
@Composable
private fun StatusPanel(title: String, body: String) {
Surface(color = Color.White, shape = RoundedCornerShape(10.dp), shadowElevation = 2.dp) {
Column(Modifier.fillMaxWidth().padding(vertical = 34.dp, horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, color = Accent900)
Text(body, color = Accent500, textAlign = TextAlign.Center, modifier = Modifier.padding(top = 8.dp))
}
}
}
private fun formatTermin(value: String): String = runCatching {
val source = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.GERMANY)
SimpleDateFormat("EEE dd.MM.yyyy, HH:mm", Locale.GERMANY).format(source.parse(value)!!)
}.getOrDefault(value)
private fun formatStaffel(value: String): String =
value.trim().removePrefix("E").trimStart()

View File

@@ -0,0 +1,102 @@
package de.harheimertc.ui.screens.spielplan
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.SeasonDto
import de.harheimertc.data.SpielDto
import de.harheimertc.repositories.SpielplanRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
enum class Wettbewerb(val label: String) {
Punktrunde("Punktrunde"),
Pokal("Pokal"),
Alle("Alle"),
}
data class SpielplanUiState(
val loading: Boolean = true,
val error: String? = null,
val spiele: List<SpielDto> = emptyList(),
val seasons: List<SeasonDto> = emptyList(),
val selectedSeason: String? = null,
val selectedWettbewerb: Wettbewerb = Wettbewerb.Punktrunde,
val selectedTeam: String = "Gesamt",
) {
val teams: List<String>
get() = listOf("Gesamt", "Erwachsene", "Nachwuchs") +
spiele.flatMap { listOf(it.heimMannschaft, it.gastMannschaft) }
.filter { it.contains("Harheimer TC", ignoreCase = true) }
.distinct()
.sorted()
val filtered: List<SpielDto>
get() = spiele.filter(::matchesCompetition).filter(::matchesTeam)
private fun matchesCompetition(game: SpielDto): Boolean {
val text = "${game.runde.orEmpty()} ${game.staffel} ${game.liga}".lowercase()
val pokal = "pokal" in text
return when (selectedWettbewerb) {
Wettbewerb.Punktrunde -> !pokal
Wettbewerb.Pokal -> pokal
Wettbewerb.Alle -> true
}
}
private fun matchesTeam(game: SpielDto): Boolean {
val homeHtc = game.heimMannschaft.contains("Harheimer TC", ignoreCase = true)
val awayHtc = game.gastMannschaft.contains("Harheimer TC", ignoreCase = true)
return when (selectedTeam) {
"Gesamt" -> true
"Erwachsene" -> (homeHtc && game.heimAltersklasse.contains("Erwachsene", ignoreCase = true)) ||
(awayHtc && game.gastAltersklasse.contains("Erwachsene", ignoreCase = true))
"Nachwuchs" -> (homeHtc && isYouth(game.heimAltersklasse, game.heimMannschaft)) ||
(awayHtc && isYouth(game.gastAltersklasse, game.gastMannschaft))
else -> game.heimMannschaft == selectedTeam || game.gastMannschaft == selectedTeam
}
}
private fun isYouth(age: String, team: String): Boolean =
age.contains("Jugend", ignoreCase = true) || team.contains("Jugend", ignoreCase = true)
}
@HiltViewModel
class SpielplanViewModel @Inject constructor(private val repository: SpielplanRepository) : ViewModel() {
private val _state = MutableStateFlow(SpielplanUiState())
val state: StateFlow<SpielplanUiState> = _state
init {
load()
}
fun load(season: String? = _state.value.selectedSeason) {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null)
repository.fetchSpielplan(season)
.onSuccess { response ->
_state.value = _state.value.copy(
loading = false,
spiele = response.data,
seasons = response.seasons.ifEmpty { _state.value.seasons },
selectedSeason = response.season ?: season,
)
}
.onFailure {
_state.value = _state.value.copy(loading = false, error = "Spielplan konnte nicht geladen werden.")
}
}
}
fun selectSeason(slug: String) = load(slug)
fun selectWettbewerb(value: Wettbewerb) {
_state.value = _state.value.copy(selectedWettbewerb = value)
}
fun selectTeam(value: String) {
_state.value = _state.value.copy(selectedTeam = value)
}
}

View File

@@ -0,0 +1,166 @@
package de.harheimertc.ui.screens.termine
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.layout.width
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
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.TerminDto
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
import de.harheimertc.ui.theme.Primary900
import java.text.SimpleDateFormat
import java.util.Locale
@Composable
fun TermineScreen(
navController: NavController,
showBackNavigation: Boolean = true,
viewModel: TermineViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 18.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
item {
if (showBackNavigation) {
PageHeader(onBack = { navController.popBackStack() })
}
Column(
modifier = Modifier.fillMaxWidth().padding(top = 30.dp, bottom = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("Termine & Events", style = MaterialTheme.typography.displayLarge, color = Accent900)
Spacer(Modifier.size(13.dp))
Box(Modifier.width(74.dp).size(width = 74.dp, height = 4.dp).background(Primary600))
Text(
"Alle kommenden Termine und Veranstaltungen des Harheimer TC",
color = Accent500,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 17.dp),
)
}
}
if (state.loading) {
item { LoadingPanel() }
} else if (state.error != null) {
item {
EmptyPanel(state.error.orEmpty())
Button(onClick = viewModel::load, modifier = Modifier.fillMaxWidth()) { Text("Erneut versuchen") }
}
} else if (state.termine.isEmpty()) {
item { EmptyPanel("Aktuell sind keine Termine geplant. Schauen Sie bald wieder vorbei!") }
} else {
items(state.termine) { termin -> TerminCard(termin) }
}
item { NoticePanel() }
}
}
@Composable
private fun PageHeader(onBack: () -> Unit) {
TextButton(onClick = onBack) {
Text("< Startseite", color = Primary600, fontWeight = FontWeight.SemiBold)
}
}
@Composable
private fun TerminCard(termin: TerminDto) {
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 3.dp) {
Row(Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.Top) {
Surface(color = Primary600, shape = RoundedCornerShape(10.dp)) {
Column(Modifier.width(64.dp).padding(vertical = 10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text(datePart(termin.datum, "dd"), color = Color.White, fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleLarge)
Text(datePart(termin.datum, "MMM"), color = Color.White, style = MaterialTheme.typography.labelSmall)
}
}
Column(Modifier.weight(1f).padding(horizontal = 13.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(termin.titel, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.titleLarge, color = Accent900)
termin.beschreibung?.takeIf(String::isNotBlank)?.let { Text(it, color = Accent700) }
Text(fullDate(termin), style = MaterialTheme.typography.labelSmall, color = Accent500)
}
termin.kategorie?.takeIf(String::isNotBlank)?.let {
Text(
it,
style = MaterialTheme.typography.labelSmall,
color = Primary900,
modifier = Modifier.background(Primary100, RoundedCornerShape(16.dp)).padding(horizontal = 8.dp, vertical = 5.dp),
)
}
}
}
}
@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))
}
}
@Composable
private fun EmptyPanel(message: String) {
Surface(color = Color.White, shape = RoundedCornerShape(12.dp), shadowElevation = 3.dp) {
Column(Modifier.fillMaxWidth().padding(32.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text("Keine kommenden Termine", style = MaterialTheme.typography.titleLarge, color = Accent900)
Text(message, color = Accent500, textAlign = TextAlign.Center, modifier = Modifier.padding(top = 8.dp))
}
}
}
@Composable
private fun NoticePanel() {
Surface(color = Color(0xFFFEF2F2), shape = RoundedCornerShape(12.dp), modifier = Modifier.padding(top = 16.dp)) {
Column(Modifier.fillMaxWidth().padding(18.dp)) {
Text("Hinweis", fontWeight = FontWeight.SemiBold, color = Primary900)
Text(
"Alle Termine sind vorbehaltlich kurzfristiger Änderungen. Bei Fragen zu einzelnen Veranstaltungen kontaktieren Sie uns gerne.",
color = Primary900,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
private fun datePart(value: String, pattern: String): String = runCatching {
val parsed = SimpleDateFormat("yyyy-MM-dd", Locale.GERMANY).parse(value)!!
SimpleDateFormat(pattern, Locale.GERMANY).format(parsed)
}.getOrDefault(value)
private fun fullDate(termin: TerminDto): String {
val date = datePart(termin.datum, "EEEE, d. MMMM yyyy")
return termin.uhrzeit?.takeIf(String::isNotBlank)?.let { "$date - $it Uhr" } ?: date
}

View File

@@ -0,0 +1,53 @@
package de.harheimertc.ui.screens.termine
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.TerminDto
import de.harheimertc.repositories.TermineRepository
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class TermineUiState(
val loading: Boolean = true,
val termine: List<TerminDto> = emptyList(),
val error: String? = null,
)
@HiltViewModel
class TermineViewModel @Inject constructor(private val repository: TermineRepository) : ViewModel() {
private val _state = MutableStateFlow(TermineUiState())
val state: StateFlow<TermineUiState> = _state
init {
load()
}
fun load() {
viewModelScope.launch {
_state.value = TermineUiState(loading = true)
repository.fetchTermine()
.onSuccess { termine ->
_state.value = TermineUiState(
loading = false,
termine = termine
.filter { it.eventDateTime()?.toLocalDate()?.isBefore(LocalDate.now()) != true }
.sortedBy { it.eventDateTime() },
)
}
.onFailure {
_state.value = TermineUiState(loading = false, error = "Termine konnten nicht geladen werden.")
}
}
}
}
private fun TerminDto.eventDateTime(): LocalDateTime? = runCatching {
val time = uhrzeit?.takeIf { it.matches(Regex("\\d{2}:\\d{2}")) } ?: "00:00"
LocalDateTime.parse("$datum $time", DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"))
}.getOrNull()

View File

@@ -0,0 +1,207 @@
package de.harheimertc.ui.screens.training
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.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
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.TrainingTimeDto
import de.harheimertc.data.TrainerDto
import de.harheimertc.ui.navigation.Destinations
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
@Composable
fun TrainingScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: TrainingViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
Page(
navController = navController,
showBackNavigation = showBackNavigation,
title = "Trainingszeiten",
) {
when {
state.loading -> item { Loading() }
state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) }
else -> state.config?.let { config ->
item {
ContentCard("Trainingsort") {
Text(config.training.ort.name, color = Accent900, fontWeight = FontWeight.SemiBold)
Text(config.training.ort.strasse, color = Accent700)
Text("${config.training.ort.plz} ${config.training.ort.ort}", color = Accent700)
}
}
item { Text("Trainingszeiten", style = MaterialTheme.typography.titleLarge, color = Accent900) }
val groups = config.training.zeiten.groupBy { it.gruppe }
items(groups.entries.toList()) { group ->
ContentCard(group.key) {
group.value.forEach { time ->
Text("${time.tag}: ${time.von} - ${time.bis} Uhr", color = Primary600, fontWeight = FontWeight.SemiBold)
time.info?.takeIf(String::isNotBlank)?.let { Text(it, color = Accent500) }
}
}
}
item {
Surface(color = Primary100, shape = RoundedCornerShape(8.dp)) {
Column(Modifier.fillMaxWidth().padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Interessiert?", style = MaterialTheme.typography.titleLarge, color = Accent900)
Text("Komm einfach zum Schnuppertraining vorbei oder kontaktiere uns für weitere Informationen.", color = Accent700)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Button(onClick = { navController.navigate(Destinations.Anfaenger.route) }, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) {
Text("Infos für Anfänger")
}
OutlinedButton(onClick = { navController.navigate(Destinations.Contact.route) }) { Text("Kontakt") }
}
}
}
}
}
}
}
}
@Composable
fun TrainerScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: TrainingViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
Page(navController, showBackNavigation, "Unsere Trainer") {
item { Text("Erfahrene und qualifizierte Trainer für alle Leistungsstufen", color = Accent500) }
when {
state.loading -> item { Loading() }
state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) }
else -> items(state.config?.trainer.orEmpty()) { trainer -> TrainerCard(trainer) }
}
}
}
@Composable
fun AnfaengerScreen(
navController: NavController,
showBackNavigation: Boolean,
viewModel: TrainingViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsState()
Page(navController, showBackNavigation, "Tischtennis für Anfänger") {
item { Text("Du möchtest mit Tischtennis anfangen? Bei uns bist du richtig.", color = Accent500) }
item {
ContentCard("Was du wissen solltest") {
listOf(
"Keine Vorkenntnisse nötig",
"Schläger und Material werden gestellt",
"Sportkleidung und Hallenschuhe mitbringen",
"3x kostenlos Probetraining",
"Einstieg jederzeit möglich",
).forEach { Text("+ $it", color = Accent700) }
}
}
when {
state.loading -> item { Loading() }
state.error != null -> item { ErrorPanel(state.error.orEmpty(), viewModel::load) }
else -> {
val groups = state.config?.training?.zeiten.orEmpty().groupBy(TrainingTimeDto::gruppe)
item { Text("Anfängergruppen", style = MaterialTheme.typography.titleLarge, color = Accent900) }
items(groups.entries.toList()) { group ->
ContentCard(group.key) {
group.value.forEach { Text("${it.tag}, ${it.von} - ${it.bis} Uhr", color = Accent700) }
}
}
item {
Button(
onClick = { navController.navigate(Destinations.Contact.route) },
colors = ButtonDefaults.buttonColors(containerColor = Primary600),
modifier = Modifier.fillMaxWidth(),
) { Text("Zum Probetraining anmelden") }
}
}
}
}
}
@Composable
private fun Page(
navController: NavController,
showBackNavigation: Boolean,
title: String,
content: androidx.compose.foundation.lazy.LazyListScope.() -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize().background(Color(0xFFFAFAFA)),
contentPadding = PaddingValues(horizontal = 18.dp, vertical = 22.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
item {
if (showBackNavigation) {
TextButton(onClick = { navController.popBackStack() }) { Text("< Startseite", color = Primary600) }
}
Text(title, style = MaterialTheme.typography.displayLarge, color = Accent900, modifier = Modifier.padding(top = 18.dp))
}
content()
}
}
@Composable
private fun ContentCard(title: String, content: @Composable () -> Unit) {
Surface(color = Color.White, shape = RoundedCornerShape(8.dp), shadowElevation = 2.dp) {
Column(Modifier.fillMaxWidth().padding(17.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge, color = Accent900)
content()
}
}
}
@Composable
private fun TrainerCard(trainer: TrainerDto) {
ContentCard(trainer.lizenz) {
Text(trainer.name, color = Accent700, fontWeight = FontWeight.SemiBold)
Text("Schwerpunkt: ${trainer.schwerpunkt}", color = Accent500)
trainer.zusatz?.takeIf(String::isNotBlank)?.let { Text(it, color = Accent500) }
}
}
@Composable
private fun Loading() {
Column(Modifier.fillMaxWidth().padding(30.dp), horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(color = Primary600)
}
}
@Composable
private fun ErrorPanel(message: String, retry: () -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(message, color = Accent700)
Button(onClick = retry, colors = ButtonDefaults.buttonColors(containerColor = Primary600)) { Text("Erneut laden") }
}
}

View File

@@ -0,0 +1,36 @@
package de.harheimertc.ui.screens.training
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import de.harheimertc.data.ConfigResponse
import de.harheimertc.repositories.TrainingRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
data class TrainingUiState(
val loading: Boolean = true,
val error: String? = null,
val config: ConfigResponse? = null,
)
@HiltViewModel
class TrainingViewModel @Inject constructor(private val repository: TrainingRepository) : ViewModel() {
private val _state = MutableStateFlow(TrainingUiState())
val state: StateFlow<TrainingUiState> = _state
init {
load()
}
fun load() {
viewModelScope.launch {
_state.value = TrainingUiState(loading = true)
repository.fetchConfig()
.onSuccess { _state.value = TrainingUiState(loading = false, config = it) }
.onFailure { _state.value = TrainingUiState(loading = false, error = "Trainingsinformationen konnten nicht geladen werden.") }
}
}
}

View File

@@ -1,34 +1,40 @@
package de.harheimertc.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
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))
// Placeholder typography mapping. Replace with bundled Inter / Montserrat fonts when added.
val AppTypography = Typography(
displayLarge = androidx.compose.ui.text.TextStyle(
fontFamily = FontFamily.Default,
displayLarge = TextStyle(
fontFamily = MontserratFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 30.sp
),
titleLarge = androidx.compose.ui.text.TextStyle(
fontFamily = FontFamily.Default,
titleLarge = TextStyle(
fontFamily = MontserratFamily,
fontWeight = FontWeight.Medium,
fontSize = 20.sp
),
bodyLarge = androidx.compose.ui.text.TextStyle(
fontFamily = FontFamily.SansSerif,
bodyLarge = TextStyle(
fontFamily = InterFamily,
fontWeight = FontWeight.Normal,
fontSize = 16.sp
),
bodyMedium = androidx.compose.ui.text.TextStyle(
fontFamily = FontFamily.SansSerif,
bodyMedium = TextStyle(
fontFamily = InterFamily,
fontWeight = FontWeight.Normal,
fontSize = 14.sp
),
labelSmall = androidx.compose.ui.text.TextStyle(
fontFamily = FontFamily.SansSerif,
labelSmall = TextStyle(
fontFamily = InterFamily,
fontWeight = FontWeight.Medium,
fontSize = 12.sp
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

View File

@@ -0,0 +1,37 @@
Fonts für die Android-App
Dieses Verzeichnis soll die gebündelten TTF-Dateien enthalten, damit die App die gleichen Schriften wie die Web-UI verwendet.
Automatischer Download (empfohlen):
1. Ausführbar machen:
```bash
chmod +x android-app/scripts/download-fonts.sh
```
2. Script ausführen:
```bash
./android-app/scripts/download-fonts.sh
```
Das Script lädt die variable Font-Dateien `Inter[opsz,wght].ttf` und `Montserrat[wght].ttf` in `app/src/main/res/font/` und benennt sie in `inter_variable.ttf` und `montserrat_variable.ttf`.
Manuelle Alternative:
- Lade `Inter` und `Montserrat` vom Google Fonts Repo herunter und lege die TTFs in dieses Verzeichnis.
- Benenne die Dateien wie oben.
Compose-Nutzung (Beispiel in Kotlin):
```kotlin
val Inter = FontFamily(
Font(R.font.inter_variable)
)
val Montserrat = FontFamily(
Font(R.font.montserrat_variable)
)
```
Hinweis: Das Herunterladen der Fonts erfolgt von GitHub (Raw URLs). Prüfe die Lizenzen (Google Fonts sind in der Regel OFL-lizenziert).

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.HarheimerTC" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:statusBarColor">#18181B</item>
<item name="android:navigationBarColor">#18181B</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="membership_documents" path="membership/" />
</paths>