Android-Umsetzung der Homepage
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.harheimertc
|
||||
|
||||
import android.app.Application
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class HarheimerApplication : Application()
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
269
android-app/app/src/main/java/de/harheimertc/data/ApiService.kt
Normal file
269
android-app/app/src/main/java/de/harheimertc/data/ApiService.kt
Normal 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>
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.harheimertc.repositories
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface AuthRepository {
|
||||
fun getToken(): String?
|
||||
fun setToken(token: String?)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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("&", "&")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ")
|
||||
.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/"),
|
||||
)),
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
@@ -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.") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 |
BIN
android-app/app/src/main/res/font/inter_variable.ttf
Normal file
BIN
android-app/app/src/main/res/font/inter_variable.ttf
Normal file
Binary file not shown.
BIN
android-app/app/src/main/res/font/montserrat_variable.ttf
Normal file
BIN
android-app/app/src/main/res/font/montserrat_variable.ttf
Normal file
Binary file not shown.
37
android-app/app/src/main/res/font_README.md
Normal file
37
android-app/app/src/main/res/font_README.md
Normal 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).
|
||||
8
android-app/app/src/main/res/values/themes.xml
Normal file
8
android-app/app/src/main/res/values/themes.xml
Normal 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>
|
||||
4
android-app/app/src/main/res/xml/file_paths.xml
Normal file
4
android-app/app/src/main/res/xml/file_paths.xml
Normal 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>
|
||||
Reference in New Issue
Block a user