feat(Tournament): update tournament participation UI and localization
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s

- Changed the icon for tournament participations in the navigation to better represent the feature.
- Updated localization keys for tournament participations across multiple languages, enhancing clarity and user understanding.
- Introduced a new tab for official tournament participations in the mobile app, improving navigation and access to tournament details.
- Enhanced the TournamentsScreen to include options for creating and managing official tournaments, streamlining user interactions.
This commit is contained in:
Torsten Schulz (local)
2026-05-14 17:51:19 +02:00
parent 3d1dfe9a4c
commit 2f3f4fb275
24 changed files with 5472 additions and 58 deletions

View File

@@ -28,6 +28,7 @@ import de.tt_tagebuch.shared.api.MembersApi
import de.tt_tagebuch.shared.api.MyTischtennisApi
import de.tt_tagebuch.shared.api.OfficialTournamentsApi
import de.tt_tagebuch.shared.api.PermissionsApi
import de.tt_tagebuch.shared.api.SeasonsApi
import de.tt_tagebuch.shared.api.SessionApi
import de.tt_tagebuch.shared.api.TrainingGroupsApi
import de.tt_tagebuch.shared.api.TrainingStatsApi
@@ -103,8 +104,11 @@ class AppDependencies(context: Context) {
val apiLogsManager = ApiLogsManager(ApiLogsApi(client))
val tournamentsApi = TournamentsApi(client)
val matchesApi = MatchesApi(client)
val clubTeamsApi = ClubTeamsApi(client)
val seasonsApi = SeasonsApi(client)
val clubInternalTournamentsManager = ClubInternalTournamentsManager(tournamentsApi)
val officialTournamentsReadManager = OfficialTournamentsReadManager(OfficialTournamentsApi(client))
val officialTournamentsApi = OfficialTournamentsApi(client)
val officialTournamentsReadManager = OfficialTournamentsReadManager(officialTournamentsApi)
val memberTransferConfigApi = MemberTransferConfigApi(client)
val myTischtennisApi = MyTischtennisApi(client)
val clickTtAccountApi = ClickTtAccountApi(client)
@@ -133,7 +137,7 @@ class AppDependencies(context: Context) {
)
val trainingStatsManager = TrainingStatsManager(trainingStatsApi)
val scheduleManager = ScheduleManager(
ClubTeamsApi(client),
clubTeamsApi,
matchesApi,
)
val languageManager = LanguageManager(AndroidLanguageStorage(context.applicationContext))

View File

@@ -167,7 +167,7 @@ private fun visibleMainTabs(perms: UserClubPermissions?): List<MainTab> =
MainTab.Calendar ->
perms == null ||
perms.canReadDiary() || perms.canReadSchedule() || perms.canReadTournaments()
MainTab.Tournaments -> perms == null || perms.canReadTournaments()
MainTab.Tournaments, MainTab.OfficialParticipations -> perms == null || perms.canReadTournaments()
MainTab.Stats -> perms == null || perms.canReadStatistics()
}
}
@@ -186,6 +186,7 @@ private enum class MainTab {
Calendar,
Stats,
Tournaments,
OfficialParticipations,
Settings,
}
@@ -413,9 +414,13 @@ private fun MainTabContent(
onOpenDiaryTab = { onNavigateTab(MainTab.Diary) },
onOpenSchedule = { onNavigateTab(MainTab.Schedule) },
onOpenTournaments = { onNavigateTab(MainTab.Tournaments) },
onOpenOfficialParticipations = { onNavigateTab(MainTab.Tournaments) },
onOpenOfficialParticipations = { onNavigateTab(MainTab.OfficialParticipations) },
)
MainTab.Tournaments -> TournamentsScreen(dependencies)
MainTab.OfficialParticipations -> OfficialTournamentsMenuRoute(
dependencies = dependencies,
onNavigateBack = { onNavigateTab(MainTab.Tournaments) },
)
MainTab.Stats -> TrainingStatsScreen(dependencies)
MainTab.Settings -> SettingsScreen(
dependencies = dependencies,
@@ -599,6 +604,12 @@ private fun MainNavigationRail(
selected = selectedTab == MainTab.Tournaments,
onClick = { onTabSelected(MainTab.Tournaments) },
)
NavRailLeafItem(
emoji = "📋",
label = tr("navigation.tournamentParticipations", "Turnierteilnahmen"),
selected = selectedTab == MainTab.OfficialParticipations,
onClick = { onTabSelected(MainTab.OfficialParticipations) },
)
}
if (p.canReadSchedule()) {
NavRailLeafItem(
@@ -807,6 +818,7 @@ private fun mainTabEmoji(tab: MainTab): String = when (tab) {
MainTab.Calendar -> "📆"
MainTab.Stats -> "📊"
MainTab.Tournaments -> "🏆"
MainTab.OfficialParticipations -> "📋"
MainTab.Schedule -> "📅"
MainTab.Settings -> "⚙️"
}
@@ -6259,6 +6271,7 @@ private fun tabTitle(tab: MainTab): String = when (tab) {
MainTab.Schedule -> tr("navigation.schedule", "Terminplan")
MainTab.Calendar -> tr("navigation.calendar", "Kalender")
MainTab.Tournaments -> tr("navigation.clubTournaments", "Turniere")
MainTab.OfficialParticipations -> tr("navigation.tournamentParticipations", "Turnierteilnahmen")
MainTab.Stats -> tr("navigation.statistics", "Statistik")
MainTab.Settings -> tr("mobile.more", "Mehr")
}

View File

@@ -0,0 +1,716 @@
package de.tt_tagebuch.app.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
import androidx.compose.material.Divider
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.tt_tagebuch.app.AppDependencies
import de.tt_tagebuch.shared.api.TournamentsApi
import de.tt_tagebuch.shared.api.models.AddTournamentClassBody
import de.tt_tagebuch.shared.api.models.CreateTournamentPairingBody
import de.tt_tagebuch.shared.api.models.TournamentAddInternalParticipantBody
import de.tt_tagebuch.shared.api.models.TournamentAddMatchResultBody
import de.tt_tagebuch.shared.api.models.TournamentClassDto
import de.tt_tagebuch.shared.api.models.TournamentDeleteKnockoutBody
import de.tt_tagebuch.shared.api.models.TournamentExternalParticipantRowDto
import de.tt_tagebuch.shared.api.models.TournamentFinishMatchBody
import de.tt_tagebuch.shared.api.models.TournamentMatchDto
import de.tt_tagebuch.shared.api.models.TournamentParticipantRowDto
import de.tt_tagebuch.shared.api.models.TournamentRemoveInternalParticipantBody
import de.tt_tagebuch.shared.api.models.UpdateParticipantClassBody
import de.tt_tagebuch.shared.api.models.UpdateTournamentClassBody
import de.tt_tagebuch.shared.api.models.AddExternalTournamentParticipantBody
import de.tt_tagebuch.shared.api.models.RemoveExternalTournamentParticipantBody
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@Composable
internal fun TournamentEditorClassesTab(
clubId: Int,
tournamentId: Int,
classes: List<TournamentClassDto>,
tr: (String, String) -> String,
api: TournamentsApi,
scope: CoroutineScope,
onReload: () -> Unit,
onError: (String?) -> Unit,
) {
var showAdd by remember { mutableStateOf(false) }
var newName by remember { mutableStateOf("") }
var newDoubles by remember { mutableStateOf(false) }
if (showAdd) {
AlertDialog(
onDismissRequest = { showAdd = false },
title = { Text(tr("tournaments.addClass", "Klasse anlegen")) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(value = newName, onValueChange = { newName = it }, label = { Text(tr("tournaments.className", "Klassenname")) })
Row {
Text(tr("mobile.doublesTournament", "Doppel"))
androidx.compose.material.Switch(checked = newDoubles, onCheckedChange = { newDoubles = it })
}
}
},
confirmButton = {
TextButton(
onClick = {
if (newName.isBlank()) return@TextButton
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.addTournamentClass(
clubId,
tournamentId,
AddTournamentClassBody(name = newName.trim(), isDoubles = newDoubles),
)
}
}.onFailure { onError(it.message) }
showAdd = false
newName = ""
newDoubles = false
onReload()
}
},
) { Text(tr("mobile.add", "Hinzufügen")) }
},
dismissButton = {
TextButton(onClick = { showAdd = false }) { Text(tr("common.cancel", "Abbrechen")) }
},
)
}
Column(Modifier.fillMaxSize().padding(16.dp)) {
Button(onClick = { showAdd = true }, modifier = Modifier.fillMaxWidth()) {
Text(tr("tournaments.addClass", "Klasse anlegen"))
}
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 12.dp)) {
items(classes, key = { it.id }) { c ->
ClassRowEditor(
clubId = clubId,
tournamentId = tournamentId,
c = c,
tr = tr,
api = api,
scope = scope,
onReload = onReload,
onError = onError,
)
}
}
}
}
@Composable
private fun ClassRowEditor(
clubId: Int,
tournamentId: Int,
c: TournamentClassDto,
tr: (String, String) -> String,
api: TournamentsApi,
scope: CoroutineScope,
onReload: () -> Unit,
onError: (String?) -> Unit,
) {
var name by remember(c.id) { mutableStateOf(c.name) }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
) {
OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), label = { Text(c.name) })
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 4.dp)) {
TextButton(
onClick = {
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.updateTournamentClass(
clubId,
tournamentId,
c.id,
UpdateTournamentClassBody(name = name.ifBlank { null }),
)
}
}.onFailure { onError(it.message) }
onReload()
}
},
) { Text(tr("mobile.save", "Speichern")) }
TextButton(
onClick = {
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.deleteTournamentClass(clubId, tournamentId, c.id)
}
}.onFailure { onError(it.message) }
onReload()
}
},
) { Text(tr("mobile.delete", "Löschen")) }
}
Divider()
}
}
@Composable
internal fun TournamentEditorParticipantsTab(
clubId: Int,
tournamentId: Int,
classes: List<TournamentClassDto>,
participants: List<TournamentParticipantRowDto>,
externalParticipants: List<TournamentExternalParticipantRowDto>,
dependencies: AppDependencies,
tr: (String, String) -> String,
onReload: () -> Unit,
onError: (String?) -> Unit,
) {
val api = dependencies.tournamentsApi
val scope = dependencies.applicationScope
val membersState by dependencies.membersManager.state.collectAsState()
val scroll = rememberScrollState()
var addMenu by remember { mutableStateOf(false) }
var extDialog by remember { mutableStateOf(false) }
var extFirst by remember { mutableStateOf("") }
var extLast by remember { mutableStateOf("") }
LaunchedEffect(clubId) {
dependencies.membersManager.loadMembers(clubId)
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scroll)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(tr("tournaments.internalParticipants", "Vereinsmitglieder"), fontWeight = FontWeight.SemiBold)
Row {
OutlinedButton(onClick = { addMenu = true }) { Text(tr("tournaments.addParticipant", "Teilnehmer hinzufügen")) }
DropdownMenu(expanded = addMenu, onDismissRequest = { addMenu = false }) {
val existing = participants.mapNotNull { it.clubMemberId }.toSet()
membersState.members
.filter { it.id !in existing }
.take(200)
.forEach { m ->
DropdownMenuItem(
onClick = {
addMenu = false
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.addInternalParticipant(
TournamentAddInternalParticipantBody(
clubId = clubId,
classId = null,
participant = m.id,
tournamentId = tournamentId,
),
)
}
}.onFailure { onError(it.message) }
onReload()
}
},
) {
Text("${m.firstName} ${m.lastName}".trim())
}
}
}
}
participants.forEach { p ->
val label = p.member?.let { "${it.firstName.orEmpty()} ${it.lastName.orEmpty()}".trim() }.orEmpty().ifBlank { "#${p.id}" }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(Modifier.weight(1f)) {
Text(label, fontWeight = FontWeight.Medium)
ClassPickerChipRow(
classes = classes,
selectedClassId = p.classId,
tr = tr,
onPick = { classId ->
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.updateParticipantClass(
clubId,
tournamentId,
p.id,
UpdateParticipantClassBody(classId = classId, isExternal = false),
)
}
}.onFailure { onError(it.message) }
onReload()
}
},
)
}
TextButton(
onClick = {
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.removeInternalParticipant(
TournamentRemoveInternalParticipantBody(clubId, tournamentId, p.id),
)
}
}.onFailure { onError(it.message) }
onReload()
}
},
) { Text(tr("mobile.remove", "Entfernen")) }
}
Divider()
}
Text(tr("tournaments.externalParticipants", "Externe Teilnehmer"), fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 12.dp))
OutlinedButton(onClick = { extDialog = true }) { Text(tr("tournaments.addExternalParticipant", "Extern hinzufügen")) }
if (extDialog) {
AlertDialog(
onDismissRequest = { extDialog = false },
title = { Text(tr("tournaments.addExternalParticipant", "Extern hinzufügen")) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(extFirst, { extFirst = it }, label = { Text(tr("members.firstName", "Vorname")) })
OutlinedTextField(extLast, { extLast = it }, label = { Text(tr("members.lastName", "Nachname")) })
}
},
confirmButton = {
TextButton(
onClick = {
if (extFirst.isBlank() || extLast.isBlank()) return@TextButton
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.addExternalParticipant(
AddExternalTournamentParticipantBody(
clubId = clubId,
tournamentId = tournamentId,
classId = null,
firstName = extFirst.trim(),
lastName = extLast.trim(),
),
)
}
}.onFailure { onError(it.message) }
extDialog = false
extFirst = ""
extLast = ""
onReload()
}
},
) { Text(tr("mobile.add", "Hinzufügen")) }
},
dismissButton = { TextButton(onClick = { extDialog = false }) { Text(tr("common.cancel", "Abbrechen")) } },
)
}
externalParticipants.forEach { e ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text("${e.firstName.orEmpty()} ${e.lastName.orEmpty()} (${tr("tournaments.externalShort", "ext.")})".trim())
TextButton(
onClick = {
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.removeExternalParticipant(
RemoveExternalTournamentParticipantBody(clubId, tournamentId, e.id),
)
}
}.onFailure { onError(it.message) }
onReload()
}
},
) { Text(tr("mobile.remove", "Entfernen")) }
}
Divider()
}
}
}
@Composable
private fun ClassPickerChipRow(
classes: List<TournamentClassDto>,
selectedClassId: Int?,
tr: (String, String) -> String,
onPick: (Int?) -> Unit,
) {
var open by remember { mutableStateOf(false) }
val label = classes.find { it.id == selectedClassId }?.name ?: tr("tournaments.noClassSelected", "Keine Klasse")
TextButton(onClick = { open = true }) { Text("${tr("tournaments.classLabel", "Klasse")}: $label") }
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
DropdownMenuItem(onClick = { open = false; onPick(null) }) { Text("") }
classes.forEach { c ->
DropdownMenuItem(onClick = { open = false; onPick(c.id) }) { Text(c.name) }
}
}
}
@Composable
internal fun TournamentEditorMatchesTab(
clubId: Int,
tournamentId: Int,
matches: List<TournamentMatchDto>,
winningSets: Int,
tr: (String, String) -> String,
api: TournamentsApi,
scope: CoroutineScope,
onReload: () -> Unit,
onError: (String?) -> Unit,
) {
val scroll = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scroll)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(
onClick = {
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.deleteKnockoutMatches(TournamentDeleteKnockoutBody(clubId, tournamentId, classId = null))
}
}.onFailure { onError(it.message) }
onReload()
}
},
) { Text(tr("tournaments.resetKnockoutShort", "KO löschen")) }
}
val grouped = matches.groupBy { it.round }
grouped.entries.sortedBy { it.key }.forEach { (round, list) ->
Text("$round (${list.size})", fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.subtitle1)
list.forEach { m ->
MatchResultRow(
clubId = clubId,
tournamentId = tournamentId,
m = m,
winningSets = winningSets,
tr = tr,
api = api,
scope = scope,
onReload = onReload,
onError = onError,
)
}
}
}
}
@Composable
private fun MatchResultRow(
clubId: Int,
tournamentId: Int,
m: TournamentMatchDto,
@Suppress("UNUSED_PARAMETER") winningSets: Int,
tr: (String, String) -> String,
api: TournamentsApi,
scope: CoroutineScope,
onReload: () -> Unit,
onError: (String?) -> Unit,
) {
var resultInput by remember(m.id, m.tournamentResults) { mutableStateOf("") }
val p1 = displayNameFromPlayerJson(m.player1)
val p2 = displayNameFromPlayerJson(m.player2)
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Text("$p1 vs $p2", style = MaterialTheme.typography.body2)
Text(
"${tr("tournaments.result", "Ergebnis")}: ${m.result ?: "—"} ${if (m.isFinished == true) "✓" else ""}",
style = MaterialTheme.typography.caption,
)
if (m.isFinished != true) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 4.dp)) {
OutlinedTextField(
value = resultInput,
onValueChange = { resultInput = it },
modifier = Modifier.weight(1f),
placeholder = { Text("11:9") },
singleLine = true,
)
TextButton(
onClick = {
val r = normalizeResult(resultInput) ?: return@TextButton
val nextSet = (m.tournamentResults?.size ?: 0) + 1
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.addMatchResult(
TournamentAddMatchResultBody(
clubId = clubId,
tournamentId = tournamentId,
matchId = m.id,
set = nextSet,
result = r,
),
)
}
}.onFailure { onError(it.message) }
resultInput = ""
onReload()
}
},
) { Text(tr("tournaments.addSetShort", "Satz")) }
TextButton(
onClick = {
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.finishMatch(
TournamentFinishMatchBody(clubId, tournamentId, m.id),
)
}
}.onFailure { onError(it.message) }
onReload()
}
},
) { Text(tr("tournaments.finishMatchShort", "Fertig")) }
}
}
Divider()
}
}
private fun normalizeResult(s: String): String? {
val t = s.trim()
if (!t.contains(':')) return null
val parts = t.split(':')
if (parts.size != 2) return null
val a = parts[0].trim().toIntOrNull() ?: return null
val b = parts[1].trim().toIntOrNull() ?: return null
if (a < 0 || b < 0) return null
return "$a:$b"
}
private fun displayNameFromPlayerJson(el: JsonElement?): String {
if (el == null || el is JsonNull) return ""
val o = el as? JsonObject ?: return "?"
val member = o["member"] as? JsonObject
if (member != null) {
val fn = member["firstName"]?.jsonPrimitive?.contentOrNull.orEmpty()
val ln = member["lastName"]?.jsonPrimitive?.contentOrNull.orEmpty()
return "$fn $ln".trim().ifEmpty { "?" }
}
val fn = o["firstName"]?.jsonPrimitive?.contentOrNull.orEmpty()
val ln = o["lastName"]?.jsonPrimitive?.contentOrNull.orEmpty()
return "$fn $ln".trim().ifEmpty { "?" }
}
@Composable
internal fun TournamentEditorPairingsTab(
clubId: Int,
tournamentId: Int,
classes: List<TournamentClassDto>,
participants: List<TournamentParticipantRowDto>,
externalParticipants: List<TournamentExternalParticipantRowDto>,
tr: (String, String) -> String,
api: TournamentsApi,
scope: CoroutineScope,
onReload: () -> Unit,
onError: (String?) -> Unit,
) {
val doublesClasses = classes.filter { it.isDoubles == true }
var classId by remember(doublesClasses) {
mutableIntStateOf(doublesClasses.firstOrNull()?.id ?: 0)
}
var pairingJson by remember { mutableStateOf<JsonElement?>(null) }
var p1Menu by remember { mutableStateOf(false) }
var p2Menu by remember { mutableStateOf(false) }
var sel1 by remember { mutableStateOf<Pair<String, Int>?>(null) }
var sel2 by remember { mutableStateOf<Pair<String, Int>?>(null) }
LaunchedEffect(clubId, tournamentId, classId) {
if (classId == 0) {
pairingJson = null
return@LaunchedEffect
}
pairingJson = runCatching {
withContext(Dispatchers.IO) { api.listPairings(clubId, tournamentId, classId) }
}.getOrElse { null }
}
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) {
if (doublesClasses.isEmpty()) {
Text(tr("tournaments.noDoublesClasses", "Keine Doppel-Klassen."))
return@Column
}
Text(tr("tournaments.selectClass", "Klasse"), fontWeight = FontWeight.SemiBold)
doublesClasses.forEach { c ->
TextButton(onClick = { classId = c.id }) {
Text(if (classId == c.id) "${c.name}" else c.name)
}
}
Text(tr("tournaments.newPairing", "Neue Paarung"), fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(top = 12.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { p1Menu = true }) {
Text(sel1?.let { "${it.first} #${it.second}" } ?: tr("tournaments.player1", "Spieler 1"))
}
DropdownMenu(expanded = p1Menu, onDismissRequest = { p1Menu = false }) {
participants.forEach { p ->
val label = p.member?.let { "${it.firstName.orEmpty()} ${it.lastName.orEmpty()}".trim() }.orEmpty().ifBlank { "ID ${p.id}" }
DropdownMenuItem(
onClick = {
p1Menu = false
sel1 = "member" to p.id
},
) { Text("$label (${tr("tournaments.internalShort", "int.")})") }
}
externalParticipants.forEach { ex ->
DropdownMenuItem(
onClick = {
p1Menu = false
sel1 = "external" to ex.id
},
) { Text("${ex.firstName.orEmpty()} ${ex.lastName.orEmpty()} (${tr("tournaments.externalShort", "ext.")})") }
}
}
OutlinedButton(onClick = { p2Menu = true }) {
Text(sel2?.let { "${it.first} #${it.second}" } ?: tr("tournaments.player2", "Spieler 2"))
}
DropdownMenu(expanded = p2Menu, onDismissRequest = { p2Menu = false }) {
participants.forEach { p ->
val label = p.member?.let { "${it.firstName.orEmpty()} ${it.lastName.orEmpty()}".trim() }.orEmpty().ifBlank { "ID ${p.id}" }
DropdownMenuItem(
onClick = {
p2Menu = false
sel2 = "member" to p.id
},
) { Text("$label (${tr("tournaments.internalShort", "int.")})") }
}
externalParticipants.forEach { ex ->
DropdownMenuItem(
onClick = {
p2Menu = false
sel2 = "external" to ex.id
},
) { Text("${ex.firstName.orEmpty()} ${ex.lastName.orEmpty()} (${tr("tournaments.externalShort", "ext.")})") }
}
}
}
Button(
onClick = {
val a = sel1 ?: return@Button
val b = sel2 ?: return@Button
if (a.second == b.second && a.first == b.first) return@Button
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.createPairing(
clubId,
tournamentId,
classId,
CreateTournamentPairingBody(
player1Type = a.first,
player1Id = a.second,
player2Type = b.first,
player2Id = b.second,
seeded = false,
),
)
}
}.onFailure { onError(it.message) }
sel1 = null
sel2 = null
onReload()
}
},
modifier = Modifier.padding(top = 8.dp),
) { Text(tr("mobile.add", "Hinzufügen")) }
Divider(modifier = Modifier.padding(vertical = 12.dp))
Text(tr("tournaments.pairings", "Paarungen"), fontWeight = FontWeight.SemiBold)
when (val pj = pairingJson) {
is JsonArray -> {
pj.forEach { elem ->
val id = (elem as? JsonObject)?.get("id")?.jsonPrimitive?.intOrNull ?: return@forEach
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(pairingSummary(elem), style = MaterialTheme.typography.body2)
TextButton(
onClick = {
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.deletePairing(clubId, tournamentId, id)
}
}.onFailure { onError(it.message) }
onReload()
}
},
) { Text(tr("mobile.delete", "Löschen")) }
}
}
}
else -> Text("", style = MaterialTheme.typography.caption)
}
}
}
private fun pairingSummary(elem: JsonElement): String {
val o = elem as? JsonObject ?: return elem.toString()
val id = o["id"]?.jsonPrimitive?.intOrNull
val m1 = o["member1"] as? JsonObject
val m2 = o["member2"] as? JsonObject
val n1 = m1?.let { me ->
val mm = me["member"] as? JsonObject
if (mm != null) {
"${mm["firstName"]?.jsonPrimitive?.contentOrNull.orEmpty()} ${mm["lastName"]?.jsonPrimitive?.contentOrNull.orEmpty()}".trim()
} else {
"${me["firstName"]?.jsonPrimitive?.contentOrNull.orEmpty()} ${me["lastName"]?.jsonPrimitive?.contentOrNull.orEmpty()}".trim()
}
}.orEmpty().ifBlank { "?" }
val n2 = m2?.let { me ->
val mm = me["member"] as? JsonObject
if (mm != null) {
"${mm["firstName"]?.jsonPrimitive?.contentOrNull.orEmpty()} ${mm["lastName"]?.jsonPrimitive?.contentOrNull.orEmpty()}".trim()
} else {
"${me["firstName"]?.jsonPrimitive?.contentOrNull.orEmpty()} ${me["lastName"]?.jsonPrimitive?.contentOrNull.orEmpty()}".trim()
}
}.orEmpty().ifBlank { "?" }
return "#$id $n1 / $n2"
}

View File

@@ -0,0 +1,525 @@
package de.tt_tagebuch.app.ui
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Divider
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Scaffold
import androidx.compose.material.Switch
import androidx.compose.material.Tab
import androidx.compose.material.TabRow
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.tt_tagebuch.app.AppDependencies
import de.tt_tagebuch.shared.api.models.InternalTournamentDetailDto
import de.tt_tagebuch.shared.api.models.SetTournamentModusBody
import de.tt_tagebuch.shared.api.models.TournamentClassDto
import de.tt_tagebuch.shared.api.models.TournamentClubTournamentBody
import de.tt_tagebuch.shared.api.models.TournamentCreateGroupMatchesBody
import de.tt_tagebuch.shared.api.models.TournamentCreateGroupsBody
import de.tt_tagebuch.shared.api.models.TournamentExternalParticipantRowDto
import de.tt_tagebuch.shared.api.models.TournamentGetExternalParticipantsBody
import de.tt_tagebuch.shared.api.models.TournamentGetParticipantsBody
import de.tt_tagebuch.shared.api.models.TournamentMatchDto
import de.tt_tagebuch.shared.api.models.TournamentParticipantRowDto
import de.tt_tagebuch.shared.api.models.UpdateTournamentMetaBody
import de.tt_tagebuch.shared.i18n.MobileStrings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonElement
/**
* Vollständiger Turnier-Workspace (analog Web [TournamentTab]): Stammdaten, Ablauf/Gruppen,
* Klassen, Teilnehmer, Spiele, Doppel-Paarungen. Lädt und speichert über [de.tt_tagebuch.shared.api.TournamentsApi].
*/
@Composable
internal fun InternalTournamentEditorScreen(
clubId: Int,
tournamentId: Int,
dependencies: AppDependencies,
onClose: () -> Unit,
) {
val languageCode = LocalLanguageCode.current
fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb)
val api = dependencies.tournamentsApi
val scope = dependencies.applicationScope
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var detail by remember { mutableStateOf<InternalTournamentDetailDto?>(null) }
var classes by remember { mutableStateOf<List<TournamentClassDto>>(emptyList()) }
var participants by remember { mutableStateOf<List<TournamentParticipantRowDto>>(emptyList()) }
var externalParticipants by remember { mutableStateOf<List<TournamentExternalParticipantRowDto>>(emptyList()) }
var matches by remember { mutableStateOf<List<TournamentMatchDto>>(emptyList()) }
var groupsJson by remember { mutableStateOf<JsonElement?>(null) }
var tabIndex by remember { mutableIntStateOf(0) }
val showPairingsTab = detail?.isDoublesTournament == true
val tabTitles = remember(showPairingsTab, languageCode) {
buildList {
add(tr("mobile.tournamentEditorTabMeta", "Stammdaten"))
add(tr("mobile.tournamentEditorTabFlow", "Ablauf & Gruppen"))
add(tr("mobile.tournamentEditorTabClasses", "Klassen"))
add(tr("mobile.tournamentEditorTabParticipants", "Teilnehmer"))
add(tr("mobile.tournamentEditorTabMatches", "Spiele"))
if (showPairingsTab) add(tr("tournaments.pairings", "Doppel-Paarungen"))
}
}
fun reloadAll() {
scope.launch {
loading = true
error = null
runCatching {
withContext(Dispatchers.IO) {
val d = api.getTournament(clubId, tournamentId)
val c = api.listTournamentClasses(clubId, tournamentId)
val p = api.listInternalParticipants(
TournamentGetParticipantsBody(clubId, tournamentId, classId = null),
)
val e = api.listExternalParticipants(
TournamentGetExternalParticipantsBody(clubId, tournamentId, classId = null),
)
val m = api.listMatches(clubId, tournamentId)
val g = api.getGroups(clubId, tournamentId)
TournamentEditorLoadResult(d, c, p, e, m, g)
}
}.fold(
onSuccess = { r ->
detail = r.detail
classes = r.classes
participants = r.participants
externalParticipants = r.external
matches = r.matches
groupsJson = r.groups
},
onFailure = { t ->
error = t.message ?: tr("mobile.loadingError", "Fehler beim Laden")
},
)
loading = false
}
}
LaunchedEffect(clubId, tournamentId) {
reloadAll()
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
detail?.name ?: tr("mobile.tournamentEditorTitle", "Turnier bearbeiten"),
maxLines = 1,
)
},
navigationIcon = {
IconButton(onClick = onClose) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = tr("mobile.back", "Zurück"))
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding),
) {
if (error != null) {
Text(error!!, color = MaterialTheme.colors.error, modifier = Modifier.padding(16.dp))
TextButton(onClick = { reloadAll() }) {
Text(tr("mobile.retry", "Erneut versuchen"))
}
}
if (loading && detail == null) {
CircularProgressIndicator(modifier = Modifier.padding(24.dp).align(Alignment.CenterHorizontally))
return@Column
}
val d = detail ?: return@Column
TabRow(
selectedTabIndex = tabIndex.coerceAtMost(tabTitles.lastIndex),
modifier = Modifier.fillMaxWidth(),
) {
tabTitles.forEachIndexed { i, title ->
Tab(
selected = tabIndex == i,
onClick = { tabIndex = i },
text = { Text(title, maxLines = 1) },
)
}
}
Divider()
when (tabIndex) {
0 -> TournamentEditorMetaTab(
detail = d,
tr = ::tr,
onSave = { body ->
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
api.updateTournament(clubId, tournamentId, body)
}
}.fold(
onSuccess = { detail = it },
onFailure = { error = it.message },
)
reloadAll()
}
},
)
1 -> TournamentEditorFlowTab(
clubId = clubId,
tournamentId = tournamentId,
detail = d,
groupsJson = groupsJson,
tr = ::tr,
api = api,
scope = scope,
onReload = { reloadAll() },
onError = { error = it },
)
2 -> TournamentEditorClassesTab(
clubId = clubId,
tournamentId = tournamentId,
classes = classes,
tr = ::tr,
api = api,
scope = scope,
onReload = { reloadAll() },
onError = { error = it },
)
3 -> TournamentEditorParticipantsTab(
clubId = clubId,
tournamentId = tournamentId,
classes = classes,
participants = participants,
externalParticipants = externalParticipants,
dependencies = dependencies,
tr = ::tr,
onReload = { reloadAll() },
onError = { error = it },
)
4 -> TournamentEditorMatchesTab(
clubId = clubId,
tournamentId = tournamentId,
matches = matches,
winningSets = d.winningSets ?: 3,
tr = ::tr,
api = api,
scope = scope,
onReload = { reloadAll() },
onError = { error = it },
)
5 -> if (showPairingsTab) {
TournamentEditorPairingsTab(
clubId = clubId,
tournamentId = tournamentId,
classes = classes,
participants = participants,
externalParticipants = externalParticipants,
tr = ::tr,
api = api,
scope = scope,
onReload = { reloadAll() },
onError = { error = it },
)
} else {
Spacer(Modifier.height(16.dp))
}
}
}
}
}
private data class TournamentEditorLoadResult(
val detail: InternalTournamentDetailDto,
val classes: List<TournamentClassDto>,
val participants: List<TournamentParticipantRowDto>,
val external: List<TournamentExternalParticipantRowDto>,
val matches: List<TournamentMatchDto>,
val groups: JsonElement,
)
@Composable
private fun TournamentEditorMetaTab(
detail: InternalTournamentDetailDto,
tr: (String, String) -> String,
onSave: (UpdateTournamentMetaBody) -> Unit,
) {
var name by remember(detail.id) { mutableStateOf(detail.name.orEmpty()) }
var date by remember(detail.id) { mutableStateOf(detail.date.orEmpty()) }
var winningSets by remember(detail.id) { mutableStateOf((detail.winningSets ?: 3).toString()) }
var tables by remember(detail.id) { mutableStateOf(detail.numberOfTables?.toString().orEmpty()) }
var doubles by remember(detail.id) { mutableStateOf(detail.isDoublesTournament == true) }
val scroll = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scroll)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(tr("tournaments.tournamentName", "Turniername"), fontWeight = FontWeight.SemiBold)
OutlinedTextField(value = name, onValueChange = { name = it }, modifier = Modifier.fillMaxWidth(), singleLine = true)
OutlinedTextField(
value = date,
onValueChange = { date = it },
modifier = Modifier.fillMaxWidth(),
label = { Text(tr("tournaments.date", "Datum")) },
singleLine = true,
)
OutlinedTextField(
value = winningSets,
onValueChange = { winningSets = it.filter { ch -> ch.isDigit() }.take(2) },
modifier = Modifier.fillMaxWidth(),
label = { Text(tr("tournaments.winningSets", "Gewinnsätze")) },
singleLine = true,
)
OutlinedTextField(
value = tables,
onValueChange = { tables = it.filter { ch -> ch.isDigit() } },
modifier = Modifier.fillMaxWidth(),
label = { Text(tr("mobile.tables", "Tische")) },
singleLine = true,
)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(tr("mobile.doublesTournament", "Doppel-Turnier"))
Switch(checked = doubles, onCheckedChange = { doubles = it })
}
Button(
onClick = {
val ws = winningSets.toIntOrNull()?.coerceAtLeast(1) ?: 3
val nt = tables.toIntOrNull()
onSave(
UpdateTournamentMetaBody(
name = name.ifBlank { null },
date = date.ifBlank { null },
winningSets = ws,
numberOfTables = nt,
isDoublesTournament = doubles,
),
)
},
modifier = Modifier.fillMaxWidth(),
) {
Text(tr("mobile.save", "Speichern"))
}
}
}
@Composable
private fun TournamentEditorFlowTab(
clubId: Int,
tournamentId: Int,
detail: InternalTournamentDetailDto,
groupsJson: JsonElement?,
tr: (String, String) -> String,
api: de.tt_tagebuch.shared.api.TournamentsApi,
scope: kotlinx.coroutines.CoroutineScope,
onReload: () -> Unit,
onError: (String?) -> Unit,
) {
val isGroups = detail.type == "groups"
var isGroupMode by remember(detail.id, detail.type) { mutableStateOf(isGroups) }
var numGroups by remember(detail.id) { mutableStateOf((detail.numberOfGroups?.takeIf { it > 0 } ?: 1).toString()) }
var adv by remember(detail.id) { mutableStateOf((detail.advancingPerGroup ?: 1).toString()) }
var createGroupsInput by remember { mutableStateOf(numGroups) }
val scroll = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scroll)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(tr("tournaments.tournamentMode", "Turniermodus"), fontWeight = FontWeight.SemiBold)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
Text(tr("tournaments.groupPhase", "Gruppenphase"))
Switch(
checked = isGroupMode,
onCheckedChange = { isGroupMode = it },
)
}
OutlinedTextField(
value = numGroups,
onValueChange = { numGroups = it.filter { ch -> ch.isDigit() }.take(2) },
label = { Text(tr("mobile.groups", "Gruppen")) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = adv,
onValueChange = { adv = it.filter { ch -> ch.isDigit() }.take(2) },
label = { Text(tr("tournaments.advancingPerGroup", "Weiterkommende pro Gruppe")) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Button(
onClick = {
val n = numGroups.toIntOrNull()?.coerceAtLeast(1) ?: 1
val a = adv.toIntOrNull()?.coerceAtLeast(1) ?: 1
scope.launch {
runCatching {
kotlinx.coroutines.withContext(Dispatchers.IO) {
api.setModus(
SetTournamentModusBody(
clubId = clubId,
tournamentId = tournamentId,
type = if (isGroupMode) "groups" else "knockout",
numberOfGroups = n,
advancingPerGroup = a,
),
)
}
}.onFailure { onError(it.message) }
onReload()
}
},
modifier = Modifier.fillMaxWidth(),
) {
Text(tr("mobile.apply", "Übernehmen"))
}
Divider(modifier = Modifier.padding(vertical = 8.dp))
Text(tr("mobile.tournamentGroupActions", "Gruppen-Aktionen"), fontWeight = FontWeight.SemiBold)
OutlinedTextField(
value = createGroupsInput,
onValueChange = { createGroupsInput = it.filter { ch -> ch.isDigit() }.take(2) },
label = { Text(tr("tournaments.numberOfGroupsShort", "Anzahl Gruppen")) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(
onClick = {
val n = createGroupsInput.toIntOrNull()
scope.launch {
runCatching {
kotlinx.coroutines.withContext(Dispatchers.IO) {
api.createEmptyGroups(
TournamentCreateGroupsBody(clubId, tournamentId, n),
)
}
}.onFailure { onError(it.message) }
onReload()
}
},
) { Text(tr("tournaments.createEmptyGroups", "Leere Gruppen")) }
TextButton(
onClick = {
scope.launch {
runCatching {
kotlinx.coroutines.withContext(Dispatchers.IO) {
api.fillGroups(TournamentClubTournamentBody(clubId, tournamentId))
}
}.onFailure { onError(it.message) }
onReload()
}
},
) { Text(tr("tournaments.fillGroups", "Aufteilen")) }
}
TextButton(
onClick = {
scope.launch {
runCatching {
kotlinx.coroutines.withContext(Dispatchers.IO) {
api.createGroupMatches(
TournamentCreateGroupMatchesBody(clubId, tournamentId, classId = null),
)
}
}.onFailure { onError(it.message) }
onReload()
}
},
modifier = Modifier.fillMaxWidth(),
) { Text(tr("tournaments.createGroupMatches", "Gruppenspiele erstellen")) }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(
onClick = {
scope.launch {
runCatching {
kotlinx.coroutines.withContext(Dispatchers.IO) {
api.resetGroups(TournamentClubTournamentBody(clubId, tournamentId))
}
}.onFailure { onError(it.message) }
onReload()
}
},
) { Text(tr("tournaments.resetGroups", "Gruppen zurücksetzen")) }
TextButton(
onClick = {
scope.launch {
runCatching {
kotlinx.coroutines.withContext(Dispatchers.IO) {
api.resetMatches(TournamentClubTournamentBody(clubId, tournamentId))
}
}.onFailure { onError(it.message) }
onReload()
}
},
) { Text(tr("tournaments.resetMatches", "Spiele zurücksetzen")) }
}
Divider(modifier = Modifier.padding(vertical = 8.dp))
Text(tr("mobile.tournamentKnockout", "K.-o.-Runde"), fontWeight = FontWeight.SemiBold)
Button(
onClick = {
scope.launch {
runCatching {
kotlinx.coroutines.withContext(Dispatchers.IO) {
api.startKnockout(
de.tt_tagebuch.shared.api.models.TournamentStartKnockoutBody(clubId, tournamentId),
)
}
}.onFailure { onError(it.message) }
onReload()
}
},
modifier = Modifier.fillMaxWidth(),
) {
Text(tr("tournaments.startKnockout", "K.-o. starten"))
}
Text(
tr("mobile.tournamentGroupsPreview", "Gruppen-Daten (Überblick)") + ": " +
when (groupsJson) {
is kotlinx.serialization.json.JsonArray -> "${groupsJson.size} " + tr("mobile.entries", "Einträge")
null -> ""
else -> tr("mobile.dataLoaded", "geladen")
},
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.75f),
)
}
}

View File

@@ -0,0 +1,656 @@
package de.tt_tagebuch.app.ui
import de.tt_tagebuch.app.util.OfficialTournamentEligibility
import de.tt_tagebuch.shared.api.models.Member
import de.tt_tagebuch.shared.api.models.OfficialCompetitionMemberStateDto
import de.tt_tagebuch.shared.api.models.OfficialParsedCompetitionDto
import de.tt_tagebuch.shared.api.models.OfficialParsedDataDto
import de.tt_tagebuch.shared.api.models.OfficialParsedTournamentEnvelopeDto
import de.tt_tagebuch.shared.api.models.OfficialParticipationBucketDto
import de.tt_tagebuch.shared.api.models.OfficialParticipationEntryDto
import de.tt_tagebuch.shared.api.models.OfficialTournamentListRowDto
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.contentOrNull
import java.text.Collator
import java.util.Calendar
import java.util.Locale
internal enum class OfficialParticipationRange(val vueKey: String) {
M3("3m"),
M6("6m"),
M12("12m"),
Y2("2y"),
PREV("prev"),
ALL("all"),
}
internal data class ParticipationState(
val wants: Boolean,
val registered: Boolean,
val participated: Boolean,
val placement: String?,
)
internal data class ClubHistoryRow(
val key: String,
val memberName: String?,
val tournamentName: String?,
val competitionName: String?,
val date: String?,
val placement: String?,
)
internal data class ParticipantTableRow(
val key: String,
val memberId: Int,
val memberName: String,
val competitionName: String,
val start: String,
val wants: Boolean,
val registered: Boolean,
val participated: Boolean,
val placement: String?,
)
internal data class ParticipantGroup(
val memberName: String,
val memberId: Int,
val items: List<ParticipantTableRow>,
)
internal data class ActiveCompetitionMemberRow(
val key: String,
val memberName: String,
val wants: Boolean,
val registered: Boolean,
val participated: Boolean,
val placement: String?,
val statusClass: String,
val statusText: String,
)
internal data class CompetitionSummaryCounts(
val interested: Int,
val registered: Int,
val participated: Int,
)
internal data class TournamentListStatus(
val label: String,
val className: String,
)
internal data class RegistrationDeadlineInfo(
val label: String,
val statusClass: String,
)
internal data class WorkflowNotice(
val label: String,
val statusClass: String,
)
internal data class CompetitionForMemberRow(
val key: String,
val name: String,
val date: String,
val time: String,
val entryFee: String,
val raw: OfficialParsedCompetitionDto,
)
private val collator: Collator = Collator.getInstance(Locale.GERMAN).apply { strength = Collator.PRIMARY }
internal fun participationKey(competitionId: Int, memberId: Int): String = "$competitionId-$memberId"
internal fun buildParticipationMapFromApi(entries: List<OfficialCompetitionMemberStateDto>): Map<String, ParticipationState> {
val out = LinkedHashMap<String, ParticipationState>()
for (e in entries) {
val key = participationKey(e.competitionId, e.memberId)
out[key] = ParticipationState(
wants = e.wants,
registered = e.registered,
participated = e.participated,
placement = e.placement,
)
}
return out
}
internal fun getParticipation(
map: Map<String, ParticipationState>,
competitionId: Any,
memberId: Any,
): ParticipationState {
val key = "${competitionId}-${memberId}"
return map[key] ?: ParticipationState(false, false, false, null)
}
internal fun tournamentListRawDate(t: OfficialTournamentListRowDto): String =
(t.eventDate ?: "").trim()
internal fun tournamentDateRange(t: OfficialTournamentListRowDto): Pair<Calendar?, Calendar?> {
val raw = tournamentListRawDate(t)
if (raw.isEmpty()) return Pair(null, null)
val matches = Regex("(\\d{1,2}\\.\\d{1,2}\\.\\d{4}|\\d{4}-\\d{2}-\\d{2})").findAll(raw).mapNotNull { m ->
OfficialTournamentEligibility.parseDateFlexible(m.value)
}.sortedBy { it.timeInMillis }.toList()
if (matches.isEmpty()) return Pair(null, null)
return Pair(matches.first(), matches.last())
}
internal fun tournamentListStatus(selectedId: Int?, t: OfficialTournamentListRowDto): TournamentListStatus {
if (selectedId != null && selectedId == t.id) {
return TournamentListStatus("Aktiv", "is-active")
}
val range = tournamentDateRange(t)
val date = range.second ?: range.first
if (date == null) return TournamentListStatus("Ohne Datum", "is-neutral")
val today = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
return if (date.timeInMillis < today.timeInMillis) {
TournamentListStatus("Vergangen", "is-past")
} else {
TournamentListStatus("Kommend", "is-upcoming")
}
}
internal fun filterTournamentList(
list: List<OfficialTournamentListRowDto>,
tournamentSearch: String,
showOlderTournaments: Boolean,
selectedTournamentId: Int?,
): List<OfficialTournamentListRowDto> {
val search = tournamentSearch.trim().lowercase(Locale.ROOT)
val searchDate = if (search.isNotEmpty()) OfficialTournamentEligibility.parseDateFlexible(search) else null
val today = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
val cutoff = Calendar.getInstance().apply {
timeInMillis = today.timeInMillis
add(Calendar.DAY_OF_MONTH, -14)
}
val base = list.filter { tournament ->
val isSelected = selectedTournamentId != null && selectedTournamentId == tournament.id
val range = tournamentDateRange(tournament)
val end = range.second ?: range.first
if (!showOlderTournaments && end != null && end.timeInMillis < cutoff.timeInMillis && !isSelected) {
return@filter false
}
if (search.isEmpty()) return@filter true
if (searchDate != null) {
val start = range.first ?: return@filter false
val endDate = range.second ?: range.first
return@filter !(searchDate.timeInMillis < start.timeInMillis || searchDate.timeInMillis > endDate!!.timeInMillis)
}
val haystack = listOfNotNull(tournament.title, tournament.eventDate).joinToString(" ").lowercase(Locale.ROOT)
return@filter haystack.contains(search)
}
return base.sortedWith { a, b ->
val ra = tournamentDateRange(a)
val rb = tournamentDateRange(b)
val ta = (ra.second ?: ra.first)?.timeInMillis ?: 0L
val tb = (rb.second ?: rb.first)?.timeInMillis ?: 0L
compareValues(tb, ta)
}
}
internal fun jsonElementToStringList(el: JsonElement?): List<String> {
if (el == null || el is JsonNull) return emptyList()
if (el is JsonArray) {
return el.mapNotNull { item ->
when (item) {
is JsonPrimitive -> item.contentOrNull
else -> item.toString()
}
}
}
if (el is JsonPrimitive && el.isString) return listOf(el.content)
return emptyList()
}
internal fun tournamentLocationLabel(parsed: OfficialParsedDataDto?): String {
val places = jsonElementToStringList(parsed?.austragungsorte)
val line = places.firstOrNull { Regex("^PLZ\\s*/\\s*Ort:", RegexOption.IGNORE_CASE).containsMatchIn(it) }
?: return ""
return line.replaceFirst(Regex("^PLZ\\s*/\\s*Ort:", RegexOption.IGNORE_CASE), "").trim()
}
internal fun parseDateTimeFlexible(s: String?): Calendar? {
if (s.isNullOrBlank()) return null
val t = s.trim()
val m = Regex("^(\\d{1,2})\\.(\\d{1,2})\\.(\\d{4})(?:\\s+(\\d{1,2}):(\\d{2}))?$").find(t)
if (m != null) {
val day = m.groupValues[1].toInt()
val month = m.groupValues[2].toInt()
val year = m.groupValues[3].toInt()
val hh = m.groupValues[4].ifEmpty { "0" }.toInt()
val mm = m.groupValues[5].ifEmpty { "0" }.toInt()
return Calendar.getInstance().apply {
set(year, month - 1, day, hh, mm, 0)
set(Calendar.MILLISECOND, 0)
}
}
return OfficialTournamentEligibility.parseDateFlexible(t)
}
internal fun registrationDeadlineAt(parsed: OfficialParsedDataDto?): Calendar? {
if (parsed == null) return null
val deadlines = mutableListOf<String>()
deadlines.addAll(jsonElementToStringList(parsed.meldeschluesse))
for (c in parsed.competitions) {
listOfNotNull(
c.registrationDeadlineDate,
c.registrationDeadlineOnline,
c.meldeschlussDatum,
c.meldeschlussOnline,
).forEach { if (!it.isNullOrBlank()) deadlines.add(it) }
}
var earliest: Calendar? = null
for (entry in deadlines) {
val p = parseDateTimeFlexible(entry) ?: continue
if (earliest == null || p.timeInMillis < earliest.timeInMillis) earliest = p
}
return earliest
}
internal fun registrationDeadlineInfo(parsed: OfficialParsedDataDto?): RegistrationDeadlineInfo {
val at = registrationDeadlineAt(parsed)
if (at == null) {
return RegistrationDeadlineInfo("Meldeschluss unbekannt", "is-neutral")
}
var earliestText = ""
val deadlines = mutableListOf<String>()
parsed?.let { p ->
deadlines.addAll(jsonElementToStringList(p.meldeschluesse))
for (c in p.competitions) {
listOfNotNull(
c.registrationDeadlineDate,
c.registrationDeadlineOnline,
c.meldeschlussDatum,
c.meldeschlussOnline,
).forEach { if (!it.isNullOrBlank()) deadlines.add(it) }
}
}
for (entry in deadlines) {
val p = parseDateTimeFlexible(entry) ?: continue
if (p.timeInMillis == at.timeInMillis) {
earliestText = entry
break
}
}
val now = System.currentTimeMillis()
return if (at.timeInMillis < now) {
RegistrationDeadlineInfo("Meldeschluss abgelaufen: $earliestText", "is-danger")
} else {
RegistrationDeadlineInfo("Meldeschluss offen: $earliestText", "is-success")
}
}
internal fun registrationSummary(map: Map<String, ParticipationState>): Pair<Int, Int> {
val registered = map.values.count { it.registered && !it.participated }
val participated = map.values.count { it.participated }
return Pair(registered, participated)
}
internal fun pendingAutoRegistrationCount(map: Map<String, ParticipationState>): Int =
map.values.count { it.wants && !it.registered && !it.participated }
internal fun canAutoRegister(selectedId: Int?, map: Map<String, ParticipationState>, parsed: OfficialParsedDataDto?): Boolean {
if (selectedId == null || pendingAutoRegistrationCount(map) == 0) return false
val at = registrationDeadlineAt(parsed) ?: return true
return at.timeInMillis >= System.currentTimeMillis()
}
internal fun workflowNotices(map: Map<String, ParticipationState>): List<WorkflowNotice> {
val notices = mutableListOf<WorkflowNotice>()
val pending = pendingAutoRegistrationCount(map)
val (reg, _) = registrationSummary(map)
if (pending > 0) {
notices.add(WorkflowNotice("$pending zur Anmeldung bereit", "is-warning"))
}
if (reg > 0) {
notices.add(WorkflowNotice("$reg Ergebnisse offen", "is-info"))
}
if (pending == 0 && reg == 0) {
notices.add(WorkflowNotice("Keine offenen Aufgaben", "is-neutral"))
}
return notices
}
internal fun primaryHeaderAction(map: Map<String, ParticipationState>): String {
if (pendingAutoRegistrationCount(map) > 0) return "auto-register"
val (reg, _) = registrationSummary(map)
if (reg > 0) return "results"
return "members"
}
internal fun competitionStatusSummary(
c: OfficialParsedCompetitionDto,
parsed: OfficialParsedTournamentEnvelopeDto?,
members: List<Member>,
map: Map<String, ParticipationState>,
): CompetitionSummaryCounts {
var interested = 0
var registered = 0
var participated = 0
for (member in OfficialTournamentEligibility.eligibleActiveMembers(parsed, c, members)) {
val p = getParticipation(map, c.id, member.id)
when {
p.participated -> participated++
p.registered -> registered++
p.wants -> interested++
}
}
return CompetitionSummaryCounts(interested, registered, participated)
}
internal fun activeCompetitionMemberRows(
c: OfficialParsedCompetitionDto,
parsed: OfficialParsedTournamentEnvelopeDto?,
members: List<Member>,
map: Map<String, ParticipationState>,
memberNameById: (Int) -> String,
): List<ActiveCompetitionMemberRow> {
val rows = mutableListOf<ActiveCompetitionMemberRow>()
for (member in OfficialTournamentEligibility.eligibleActiveMembers(parsed, c, members)) {
val p = getParticipation(map, c.id, member.id)
if (!p.wants && !p.registered && !p.participated) continue
val (statusClass, statusText) = when {
p.participated -> "status-played" to "Hat gespielt"
p.registered -> "status-registered" to "Angemeldet"
p.wants -> "status-wants" to "Möchte teilnehmen"
else -> "status-none" to "Nicht interessiert"
}
rows.add(
ActiveCompetitionMemberRow(
key = participationKey(c.id, member.id),
memberName = memberNameById(member.id),
wants = p.wants,
registered = p.registered,
participated = p.participated,
placement = p.placement,
statusClass = statusClass,
statusText = statusText,
),
)
}
rows.sortWith { a, b -> collator.compare(a.memberName, b.memberName) }
return rows
}
internal fun participantsRows(
parsed: OfficialParsedTournamentEnvelopeDto?,
map: Map<String, ParticipationState>,
participantsFilter: String,
memberNameById: (Int) -> String,
): List<ParticipantTableRow> {
if (parsed?.parsedData == null) return emptyList()
val comps = parsed.parsedData!!.competitions
val compById = comps.associateBy { it.id.toString() }
val seen = mutableSetOf<String>()
val merged = mutableListOf<Pair<String, String>>()
for (e in parsed.participation) {
val cid = e.competitionId.toString()
val mid = e.memberId.toString()
val key = "$cid-$mid"
if (seen.add(key)) merged.add(cid to mid)
}
for (k in map.keys) {
if (seen.contains(k)) continue
val midPart = k.substringAfterLast('-')
val cidPart = k.substringBeforeLast('-')
if (cidPart.isEmpty() || midPart.isEmpty()) continue
merged.add(cidPart to midPart)
}
val rows = mutableListOf<ParticipantTableRow>()
for ((competitionId, memberId) in merged) {
val c = compById[competitionId] ?: continue
val current = getParticipation(map, competitionId, memberId)
val midInt = memberId.toIntOrNull() ?: continue
val cidInt = competitionId.toIntOrNull() ?: continue
val start = (c.startTime ?: c.startzeit ?: "").toString()
val base = ParticipantTableRow(
key = "$competitionId-$memberId",
memberId = midInt,
memberName = memberNameById(midInt),
competitionName = (c.ageClassCompetition ?: c.altersklasseWettbewerb ?: "").toString(),
start = start,
registered = current.registered,
participated = current.participated,
wants = current.wants,
placement = current.placement,
)
when (participantsFilter) {
"all" -> rows.add(base)
"open" -> if (!base.registered && !base.participated) rows.add(base)
"registered" -> if (base.registered && !base.participated) rows.add(base)
"participated" -> if (base.participated) rows.add(base)
}
}
rows.sortWith { a, b ->
val c1 = collator.compare(a.memberName, b.memberName)
if (c1 != 0) c1 else collator.compare(a.competitionName, b.competitionName)
}
return rows
}
internal fun participantsGroups(rows: List<ParticipantTableRow>): List<ParticipantGroup> {
val byMember = LinkedHashMap<String, MutableList<ParticipantTableRow>>()
for (row in rows) {
byMember.getOrPut(row.memberName) { mutableListOf() }.add(row)
}
val groups = mutableListOf<ParticipantGroup>()
for ((memberName, items) in byMember) {
items.sortWith { a, b -> collator.compare(a.competitionName, b.competitionName) }
val mid = items.firstOrNull()?.memberId ?: 0
groups.add(ParticipantGroup(memberName = memberName, memberId = mid, items = items.toList()))
}
groups.sortWith { a, b -> collator.compare(a.memberName, b.memberName) }
return groups
}
internal fun resultsRows(
parsed: OfficialParsedTournamentEnvelopeDto?,
map: Map<String, ParticipationState>,
memberNameById: (Int) -> String,
): List<ParticipantTableRow> {
if (parsed?.parsedData == null) return emptyList()
val comps = parsed.parsedData!!.competitions
val compById = comps.associateBy { it.id.toString() }
val rows = mutableListOf<ParticipantTableRow>()
for ((key, p) in map) {
if (!p.participated) continue
val parts = key.split("-")
if (parts.size < 2) continue
val competitionId = parts[0]
val memberId = parts[1]
val c = compById[competitionId] ?: continue
val midInt = memberId.toIntOrNull() ?: continue
val cidInt = competitionId.toIntOrNull() ?: continue
rows.add(
ParticipantTableRow(
key = key,
memberId = midInt,
memberName = memberNameById(midInt),
competitionName = (c.ageClassCompetition ?: c.altersklasseWettbewerb ?: "").toString(),
start = (c.startTime ?: c.startzeit ?: "").toString(),
wants = p.wants,
registered = p.registered,
participated = p.participated,
placement = p.placement,
),
)
}
rows.sortWith { a, b ->
val c1 = collator.compare(a.memberName, b.memberName)
if (c1 != 0) c1 else collator.compare(a.competitionName, b.competitionName)
}
return rows
}
internal fun resultsGroups(rows: List<ParticipantTableRow>): List<ParticipantGroup> =
participantsGroups(rows)
internal fun splitDateTime(str: String): Pair<String, String> {
val s = str.trim()
val dm = Regex("(\\d{1,2}\\.\\d{1,2}\\.\\d{4})").find(s)
val tm = Regex("(\\d{1,2}:\\d{2})").find(s)
return (dm?.value ?: "") to (tm?.value ?: "")
}
internal fun entryFeeForCompetition(parsed: OfficialParsedDataDto?, c: OfficialParsedCompetitionDto): String {
val title = (c.ageClassCompetition ?: c.altersklasseWettbewerb ?: "").trim()
val ageClassMatch = Regex("(U\\d+|AK\\s*\\d+)", RegexOption.IGNORE_CASE).find(title) ?: return entryFeeFromStartgeld(c)
val ageClass = ageClassMatch.value.uppercase(Locale.ROOT).replace("\\s+".toRegex(), "")
val feesEl = parsed?.entryFees
if (feesEl is JsonObject) {
val feeObj = feesEl[ageClass] as? JsonObject ?: feesEl.entries.firstOrNull { it.key.uppercase(Locale.ROOT).replace(" ", "") == ageClass }?.value as? JsonObject
if (feeObj != null) {
val amount = feeObj["amount"]?.toString()?.trim('"') ?: return entryFeeFromStartgeld(c)
val currency = feeObj["currency"]?.toString()?.trim('"') ?: ""
return "$amount$currency"
}
}
return entryFeeFromStartgeld(c)
}
private fun entryFeeFromStartgeld(c: OfficialParsedCompetitionDto): String {
val sg = c.startgeld ?: c.entryFee ?: return ""
val m = Regex("(\\d+(?:[,.]\\d+)?)\\s*(?:€|Euro|EUR)?").find(sg)
return if (m != null) "${m.groupValues[1]}" else ""
}
internal fun competitionsForMember(
parsed: OfficialParsedTournamentEnvelopeDto?,
member: Member,
): List<CompetitionForMemberRow> {
val comps = parsed?.parsedData?.competitions ?: return emptyList()
val rows = mutableListOf<CompetitionForMemberRow>()
comps.forEachIndexed { idx, c ->
if (!OfficialTournamentEligibility.isEligibleForCompetition(member, c, parsed)) return@forEachIndexed
val title = (c.ageClassCompetition ?: c.altersklasseWettbewerb ?: "").trim()
val st = splitDateTime(c.startTime ?: c.startzeit ?: "")
val fee = entryFeeForCompetition(parsed.parsedData, c)
rows.add(
CompetitionForMemberRow(
key = idx.toString(),
name = title,
date = st.first,
time = st.second,
entryFee = fee,
raw = c,
),
)
}
return rows
}
internal fun computeClubHistoryRows(
buckets: List<OfficialParticipationBucketDto>,
range: OfficialParticipationRange,
): List<ClubHistoryRow> {
val now = java.time.LocalDate.now(java.time.ZoneId.systemDefault())
val (from, to) = participationRangeBoundsLocal(now, range)
val rows = mutableListOf<ClubHistoryRow>()
for (t in buckets) {
for (e in t.entries) {
if (!participationEntryInRange(e, t, from, to)) continue
rows.add(
ClubHistoryRow(
key = "club-${t.tournamentId}-${e.competitionId}-${e.memberId}",
memberName = e.memberName,
tournamentName = t.title,
competitionName = e.competitionName,
date = e.date ?: t.startDate,
placement = e.placement,
),
)
}
}
rows.sortWith { a, b ->
val c1 = collator.compare(a.memberName ?: "", b.memberName ?: "")
if (c1 != 0) c1 else collator.compare(a.competitionName ?: "", b.competitionName ?: "")
}
return rows
}
private fun participationRangeBoundsLocal(
now: java.time.LocalDate,
range: OfficialParticipationRange,
): Pair<java.time.LocalDate?, java.time.LocalDate?> {
return when (range) {
OfficialParticipationRange.ALL -> Pair(null, null)
OfficialParticipationRange.M3 -> Pair(now.minusMonths(3), null)
OfficialParticipationRange.M6 -> Pair(now.minusMonths(6), null)
OfficialParticipationRange.M12 -> Pair(now.minusMonths(12), null)
OfficialParticipationRange.Y2 -> Pair(now.minusYears(2), null)
OfficialParticipationRange.PREV -> {
val y = if (now.monthValue >= 7) now.year else now.year - 1
Pair(java.time.LocalDate.of(y - 1, 7, 1), java.time.LocalDate.of(y, 6, 30))
}
}
}
private fun participationEntryInRange(
e: OfficialParticipationEntryDto,
b: OfficialParticipationBucketDto,
from: java.time.LocalDate?,
to: java.time.LocalDate?,
): Boolean {
if (from == null && to == null) return true
val d = dateFromDmy(e.date) ?: dateFromDmy(b.startDate) ?: return true
if (from != null && d.isBefore(from)) return false
if (to != null && d.isAfter(to)) return false
return true
}
private fun dateFromDmy(s: String?): java.time.LocalDate? {
if (s.isNullOrBlank()) return null
val cal = OfficialTournamentEligibility.parseDateFlexible(s) ?: return null
return java.time.LocalDate.of(
cal.get(Calendar.YEAR),
cal.get(Calendar.MONTH) + 1,
cal.get(Calendar.DAY_OF_MONTH),
)
}
internal fun historySummaryText(
loading: Boolean,
rows: List<ClubHistoryRow>,
range: OfficialParticipationRange,
): String {
if (loading) return "Turnierbeteiligungen werden geladen"
if (rows.isEmpty()) return "Keine Turnierbeteiligungen im gewählten Zeitraum"
val uniqueMembers = rows.mapNotNull { it.memberName }.distinct().size
return "${rows.size} Einträge von $uniqueMembers Mitgliedern im Zeitraum ${range.vueKey}"
}
internal fun isAutoRegisterSuccess(response: JsonElement?): Boolean {
if (response !is JsonObject) return false
return response["success"]?.let { el ->
when (el) {
is JsonPrimitive -> el.booleanOrNull == true
else -> false
}
} == true
}
internal fun autoRegisterMessage(response: JsonElement?): String? {
if (response !is JsonObject) return null
return response["message"]?.let { (it as? JsonPrimitive)?.contentOrNull }
}

View File

@@ -0,0 +1,486 @@
package de.tt_tagebuch.app.ui
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.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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
import androidx.compose.material.Button
import androidx.compose.material.Card
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
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.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.tt_tagebuch.app.AppDependencies
import de.tt_tagebuch.shared.api.ScheduleLogic
import de.tt_tagebuch.shared.api.models.ClubLeagueOptionDto
import de.tt_tagebuch.shared.api.models.ClubTeamCreateBody
import de.tt_tagebuch.shared.api.models.ClubTeamDto
import de.tt_tagebuch.shared.api.models.ClubTeamUpdateBody
import de.tt_tagebuch.shared.api.models.SeasonDto
import de.tt_tagebuch.shared.api.models.canReadTeams
import de.tt_tagebuch.shared.api.models.canWriteTeams
import de.tt_tagebuch.shared.i18n.MobileStrings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private val TeamPad = 20.dp
private val TeamAgeOptions = listOf("adult", "J19", "J17", "J15", "J13", "J11")
private val TeamGenderOptions = listOf("open", "female")
@Composable
internal fun TeamManagementScreen(
dependencies: AppDependencies,
onBack: () -> Unit,
) {
val languageCode = LocalLanguageCode.current
fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb)
val clubState by dependencies.clubManager.state.collectAsState()
val clubId = clubState.currentClubId ?: return
val perms = clubState.currentPermissions
val canRead = perms?.canReadTeams() == true
val canWrite = perms?.canWriteTeams() == true
val scope = rememberCoroutineScope()
var seasons by remember { mutableStateOf<List<SeasonDto>>(emptyList()) }
var selectedSeasonId by remember { mutableStateOf<Int?>(null) }
var teams by remember { mutableStateOf<List<ClubTeamDto>>(emptyList()) }
var leagues by remember { mutableStateOf<List<ClubLeagueOptionDto>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var search by remember { mutableStateOf("") }
var showForm by remember { mutableStateOf(false) }
var formIsNew by remember { mutableStateOf(true) }
var formTeamId by remember { mutableStateOf<Int?>(null) }
var formName by remember { mutableStateOf("") }
var formLeagueId by remember { mutableStateOf<Int?>(null) }
var formPlannedLeague by remember { mutableStateOf("") }
var formGender by remember { mutableStateOf("open") }
var formAge by remember { mutableStateOf("adult") }
var formBusy by remember { mutableStateOf(false) }
var seasonMenu by remember { mutableStateOf(false) }
var leagueMenu by remember { mutableStateOf(false) }
var genderMenu by remember { mutableStateOf(false) }
var ageMenu by remember { mutableStateOf(false) }
var deleteTarget by remember { mutableStateOf<ClubTeamDto?>(null) }
var infoMessage by remember { mutableStateOf<String?>(null) }
fun resetFormForNew() {
formIsNew = true
formTeamId = null
formName = ""
formLeagueId = null
formPlannedLeague = ""
formGender = "open"
formAge = "adult"
}
fun openEdit(team: ClubTeamDto) {
formIsNew = false
formTeamId = team.id
formName = team.name
formLeagueId = team.leagueId
formPlannedLeague = team.plannedLeagueName.orEmpty()
formGender = team.teamGender?.takeIf { it.isNotBlank() } ?: "open"
formAge = team.teamAgeGroup?.takeIf { it.isNotBlank() } ?: "adult"
showForm = true
}
suspend fun reloadData() {
loading = true
error = null
runCatching {
val s = withContext(Dispatchers.IO) { dependencies.seasonsApi.listSeasons() }
seasons = s.sortedByDescending { it.id }
if (selectedSeasonId == null) {
val cur = withContext(Dispatchers.IO) { dependencies.seasonsApi.getCurrentSeason() }
selectedSeasonId = cur.id
}
val sid = selectedSeasonId
val t = withContext(Dispatchers.IO) { dependencies.clubTeamsApi.listClubTeams(clubId, sid) }
val lg = withContext(Dispatchers.IO) { dependencies.clubTeamsApi.listLeagues(clubId, sid) }
teams = ScheduleLogic.sortClubTeams(t)
leagues = lg.sortedBy { it.name.lowercase() }
}.onFailure { error = it.message ?: tr("mobile.teamLoadError", "Daten konnten nicht geladen werden.") }
loading = false
}
LaunchedEffect(clubId) {
selectedSeasonId = null
seasons = emptyList()
reloadData()
}
LaunchedEffect(clubId, selectedSeasonId) {
if (selectedSeasonId == null) return@LaunchedEffect
loading = true
error = null
runCatching {
val sid = selectedSeasonId
val t = withContext(Dispatchers.IO) { dependencies.clubTeamsApi.listClubTeams(clubId, sid) }
val lg = withContext(Dispatchers.IO) { dependencies.clubTeamsApi.listLeagues(clubId, sid) }
teams = ScheduleLogic.sortClubTeams(t)
leagues = lg.sortedBy { it.name.lowercase() }
}.onFailure { error = it.message ?: tr("mobile.teamLoadError", "Daten konnten nicht geladen werden.") }
loading = false
}
val filtered = remember(teams, search) {
val q = search.trim().lowercase()
if (q.isEmpty()) teams
else teams.filter { t ->
listOf(t.name, t.league?.name, t.season?.season, t.plannedLeagueName)
.filterNotNull()
.any { it.lowercase().contains(q) }
}
}
Column(Modifier.fillMaxSize().padding(TeamPad)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
Text(
tr("navigation.teamManagement", "Team-Verwaltung"),
style = MaterialTheme.typography.h6,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
if (canWrite) {
Button(
onClick = {
resetFormForNew()
showForm = true
},
enabled = !loading && selectedSeasonId != null,
) {
Text(tr("mobile.teamNew", "Neue Mannschaft"))
}
}
}
if (!canRead) {
Text(tr("mobile.noTeamAccess", "Keine Berechtigung für die Team-Verwaltung."))
return@Column
}
Text(
tr("mobile.teamsIntro", "Mannschaften pro Saison verwalten (Name, Liga, geplante Liga, Geschlechtsklasse, Altersklasse)."),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.72f),
)
Spacer(Modifier.height(8.dp))
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(tr("mobile.season", "Saison") + ":", style = MaterialTheme.typography.body2)
Box {
OutlinedButton(onClick = { seasonMenu = true }, enabled = seasons.isNotEmpty()) {
Text(
seasons.find { it.id == selectedSeasonId }?.season
?: tr("mobile.seasonPick", "Saison wählen"),
)
}
DropdownMenu(expanded = seasonMenu, onDismissRequest = { seasonMenu = false }) {
seasons.forEach { s ->
DropdownMenuItem(
onClick = {
selectedSeasonId = s.id
seasonMenu = false
},
) { Text(s.season) }
}
}
}
}
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = search,
onValueChange = { search = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(tr("mobile.teamSearch", "Mannschaft suchen")) },
)
error?.let {
Spacer(Modifier.height(8.dp))
Text(it, color = MaterialTheme.colors.error)
}
if (loading && teams.isEmpty()) {
CircularProgressIndicator(Modifier.padding(top = 24.dp).align(Alignment.CenterHorizontally))
return@Column
}
if (!loading && teams.isEmpty()) {
Spacer(Modifier.height(16.dp))
Text(tr("mobile.noTeams", "Keine Mannschaften für diese Saison."))
return@Column
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(filtered, key = { it.id }) { team ->
Card(elevation = 1.dp, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(team.name.ifBlank { "#${team.id}" }, fontWeight = FontWeight.SemiBold)
val lg = team.league?.name?.takeIf { it.isNotBlank() }
?: tr("mobile.noLeague", "Keine Liga")
Text(lg, style = MaterialTheme.typography.caption)
team.season?.season?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.caption)
}
team.plannedLeagueName?.takeIf { it.isNotBlank() }?.let { pl ->
Text(
tr("mobile.plannedLeagueShort", "Geplant: ") + pl,
style = MaterialTheme.typography.caption,
)
}
val g = team.teamGender ?: "open"
val a = team.teamAgeGroup ?: "adult"
Text(
"${tr("mobile.teamGenderShort", "Geschlecht")}: ${labelGender(tr, g)} · ${tr("mobile.teamAgeShort", "AK")}: ${labelAge(tr, a)}",
style = MaterialTheme.typography.caption,
)
team.myTischtennisTeamId?.takeIf { it.isNotBlank() }?.let { mid ->
Text("myTischtennis: $mid", style = MaterialTheme.typography.caption)
}
if (canWrite) {
Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = { openEdit(team) }) {
Text(tr("common.edit", "Bearbeiten"))
}
TextButton(onClick = { deleteTarget = team }) {
Text(tr("common.delete", "Löschen"), color = MaterialTheme.colors.error)
}
}
}
}
}
}
}
}
if (showForm && selectedSeasonId != null) {
val sid = selectedSeasonId!!
AlertDialog(
onDismissRequest = { if (!formBusy) showForm = false },
title = {
Text(
if (formIsNew) tr("mobile.teamNew", "Neue Mannschaft")
else tr("mobile.teamEdit", "Mannschaft bearbeiten"),
)
},
text = {
Column(
Modifier
.heightIn(max = 420.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = formName,
onValueChange = { formName = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(tr("mobile.teamName", "Name")) },
)
Text(tr("mobile.league", "Liga"), style = MaterialTheme.typography.caption)
Box {
OutlinedButton(onClick = { leagueMenu = true }, modifier = Modifier.fillMaxWidth()) {
Text(
formLeagueId?.let { id -> leagues.find { it.id == id }?.name }
?: tr("mobile.noLeague", "Keine Liga"),
)
}
DropdownMenu(expanded = leagueMenu, onDismissRequest = { leagueMenu = false }) {
DropdownMenuItem(
onClick = {
formLeagueId = null
leagueMenu = false
},
) { Text(tr("mobile.noLeague", "Keine Liga")) }
leagues.forEach { lg ->
DropdownMenuItem(
onClick = {
formLeagueId = lg.id
leagueMenu = false
},
) { Text(lg.name) }
}
}
}
OutlinedTextField(
value = formPlannedLeague,
onValueChange = { formPlannedLeague = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(tr("mobile.plannedLeague", "Geplante Liga")) },
)
Text(tr("mobile.teamGenderShort", "Geschlecht"), style = MaterialTheme.typography.caption)
Box {
OutlinedButton(onClick = { genderMenu = true }, modifier = Modifier.fillMaxWidth()) {
Text(labelGender(tr, formGender))
}
DropdownMenu(expanded = genderMenu, onDismissRequest = { genderMenu = false }) {
TeamGenderOptions.forEach { g ->
DropdownMenuItem(
onClick = {
formGender = g
genderMenu = false
},
) { Text(labelGender(tr, g)) }
}
}
}
Text(tr("mobile.teamAgeShort", "Altersklasse"), style = MaterialTheme.typography.caption)
Box {
OutlinedButton(onClick = { ageMenu = true }, modifier = Modifier.fillMaxWidth()) {
Text(labelAge(tr, formAge))
}
DropdownMenu(expanded = ageMenu, onDismissRequest = { ageMenu = false }) {
TeamAgeOptions.forEach { a ->
DropdownMenuItem(
onClick = {
formAge = a
ageMenu = false
},
) { Text(labelAge(tr, a)) }
}
}
}
}
},
confirmButton = {
TextButton(
enabled = !formBusy && formName.isNotBlank(),
onClick = {
scope.launch {
formBusy = true
runCatching {
if (formIsNew) {
withContext(Dispatchers.IO) {
dependencies.clubTeamsApi.createClubTeam(
clubId,
ClubTeamCreateBody(
name = formName.trim(),
leagueId = formLeagueId,
seasonId = sid,
teamGender = formGender,
teamAgeGroup = formAge,
plannedLeagueName = formPlannedLeague.trim().ifBlank { null },
),
)
}
} else {
val id = formTeamId ?: return@launch
withContext(Dispatchers.IO) {
dependencies.clubTeamsApi.updateClubTeam(
id,
ClubTeamUpdateBody(
name = formName.trim(),
leagueId = formLeagueId,
seasonId = sid,
teamGender = formGender,
teamAgeGroup = formAge,
plannedLeagueName = formPlannedLeague.trim().ifBlank { null },
),
)
}
}
reloadData()
dependencies.scheduleManager.loadClubTeams(clubId)
showForm = false
}.onFailure {
infoMessage = it.message ?: tr("common.error", "Fehler")
}
formBusy = false
}
},
) { Text(tr("common.save", "Speichern")) }
},
dismissButton = {
TextButton(onClick = { if (!formBusy) showForm = false }) {
Text(tr("common.cancel", "Abbrechen"))
}
},
)
}
deleteTarget?.let { target ->
AlertDialog(
onDismissRequest = { deleteTarget = null },
title = { Text(tr("mobile.teamDeleteTitle", "Mannschaft löschen?")) },
text = { Text(target.name.ifBlank { "#${target.id}" }) },
confirmButton = {
TextButton(
onClick = {
val id = target.id
deleteTarget = null
scope.launch {
runCatching {
withContext(Dispatchers.IO) { dependencies.clubTeamsApi.deleteClubTeam(id) }
reloadData()
dependencies.scheduleManager.loadClubTeams(clubId)
}.onFailure { infoMessage = it.message }
}
},
) { Text(tr("common.delete", "Löschen")) }
},
dismissButton = { TextButton(onClick = { deleteTarget = null }) { Text(tr("common.cancel", "Abbrechen")) } },
)
}
infoMessage?.let { msg ->
AlertDialog(
onDismissRequest = { infoMessage = null },
title = { Text(tr("common.error", "Fehler")) },
text = { Text(msg) },
confirmButton = { TextButton(onClick = { infoMessage = null }) { Text(tr("common.ok", "OK")) } },
)
}
}
private fun labelGender(tr: (String, String) -> String, code: String): String =
when (code) {
"female" -> tr("mobile.teamGenderFemale", "Weiblich")
else -> tr("mobile.teamGenderOpen", "Offen")
}
private fun labelAge(tr: (String, String) -> String, code: String): String =
if (code == "adult") tr("mobile.teamAgeAdult", "Erwachsene") else code

View File

@@ -4,6 +4,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -17,9 +18,11 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Divider
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Surface
import androidx.compose.material.Text
@@ -38,11 +41,18 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import de.tt_tagebuch.app.AppDependencies
import de.tt_tagebuch.shared.api.models.AddMiniChampionshipBody
import de.tt_tagebuch.shared.api.models.AddStandardTournamentBody
import de.tt_tagebuch.shared.api.models.InternalTournamentSummaryDto
import de.tt_tagebuch.shared.api.models.OfficialParticipationEntryDto
import de.tt_tagebuch.shared.api.models.canReadTournaments
import de.tt_tagebuch.shared.api.models.canWriteTournaments
import de.tt_tagebuch.shared.i18n.MobileStrings
import de.tt_tagebuch.shared.state.ClubTournamentDisplayFilter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Calendar
private data class ParticipationFlatRow(
val tournamentId: String?,
@@ -53,6 +63,14 @@ private data class ParticipationFlatRow(
private val TournamentsPad = 20.dp
private val TournamentsTouchMin = 48.dp
private sealed class TournamentsMainRoute {
data object Browse : TournamentsMainRoute()
data class Editor(val tournamentId: Int) : TournamentsMainRoute()
data object CreateStandard : TournamentsMainRoute()
data object CreateMini : TournamentsMainRoute()
data object OfficialWorkspace : TournamentsMainRoute()
}
@Composable
internal fun TournamentsScreen(dependencies: AppDependencies) {
val languageCode = LocalLanguageCode.current
@@ -64,6 +82,7 @@ internal fun TournamentsScreen(dependencies: AppDependencies) {
val officialState by dependencies.officialTournamentsReadManager.state.collectAsState()
var searchQuery by rememberSaveable { mutableStateOf("") }
var showInternalStats by remember { mutableStateOf(false) }
var mainRoute by remember { mutableStateOf<TournamentsMainRoute>(TournamentsMainRoute.Browse) }
if (perms?.canReadTournaments() != true) {
Column(
@@ -80,11 +99,6 @@ internal fun TournamentsScreen(dependencies: AppDependencies) {
dependencies.clubInternalTournamentsManager.loadList(clubId)
}
LaunchedEffect(clubId, internalState.selectedId) {
val id = internalState.selectedId ?: return@LaunchedEffect
dependencies.clubInternalTournamentsManager.loadDetail(clubId, id)
}
LaunchedEffect(clubId) {
dependencies.officialTournamentsReadManager.load(clubId)
}
@@ -133,7 +147,9 @@ internal fun TournamentsScreen(dependencies: AppDependencies) {
)
}
LazyColumn(
when (val route = mainRoute) {
TournamentsMainRoute.Browse -> {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = TournamentsPad, vertical = 16.dp),
@@ -153,6 +169,15 @@ internal fun TournamentsScreen(dependencies: AppDependencies) {
)
}
item {
OutlinedButton(
onClick = { mainRoute = TournamentsMainRoute.OfficialWorkspace },
modifier = Modifier.fillMaxWidth(),
) {
Text(tr("officialTournaments.openWorkspace", "Offizielle Turniere & Teilnahmen …"))
}
}
item {
Row(
modifier = Modifier
@@ -225,6 +250,20 @@ internal fun TournamentsScreen(dependencies: AppDependencies) {
)
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
if (internalState.filter == ClubTournamentDisplayFilter.Mini) {
OutlinedButton(onClick = { mainRoute = TournamentsMainRoute.CreateMini }) {
Text(tr("tournaments.miniChampionships", "Minimeisterschaft") + " +")
}
} else {
OutlinedButton(onClick = { mainRoute = TournamentsMainRoute.CreateStandard }) {
Text(tr("tournaments.newTournament", "Neues Turnier"))
}
}
}
}
if (internalState.isLoadingList) {
item { CircularProgressIndicator(modifier = Modifier.padding(vertical = 16.dp)) }
} else {
@@ -261,52 +300,16 @@ internal fun TournamentsScreen(dependencies: AppDependencies) {
filteredInternal,
key = { _, t -> t.id.toString() },
) { _, t ->
val selected = internalState.selectedId == t.id
InternalTournamentListCard(
tournament = t,
selected = selected,
selected = false,
unknownDateLabel = tr("tournaments.unknownDate", "Datum unbekannt"),
onClick = {
dependencies.clubInternalTournamentsManager.selectTournament(
if (selected) null else t.id,
)
},
onClick = { mainRoute = TournamentsMainRoute.Editor(t.id) },
)
}
}
}
if (internalState.selectedId != null) {
item {
Divider(modifier = Modifier.padding(vertical = 8.dp))
Text(tr("mobile.tournamentDetails", "Details"), fontWeight = FontWeight.SemiBold)
when {
internalState.isLoadingDetail -> CircularProgressIndicator(modifier = Modifier.padding(8.dp))
internalState.detail != null -> {
val d = internalState.detail!!
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
d.name?.let { Text(it, fontWeight = FontWeight.Medium) }
d.date?.let { Text("${tr("tournaments.date", "Datum")}: $it") }
d.type?.takeIf { it.isNotBlank() }?.let { Text("${tr("mobile.mode", "Modus")}: $it") }
d.winningSets?.let { Text("${tr("tournaments.winningSets", "Gewinnsätze")}: $it") }
d.numberOfGroups?.let { Text("${tr("mobile.groups", "Gruppen")}: $it") }
d.numberOfTables?.let { Text("${tr("mobile.tables", "Tische")}: $it") }
if (d.miniChampionshipYear != null) {
Text("${tr("tournaments.miniChampionshipYear", "Minimeisterschaft-Jahr")}: ${d.miniChampionshipYear}")
}
if (d.allowsExternal == true) {
Text(tr("tournaments.openTournaments", "Offenes Turnier"))
}
if (d.isDoublesTournament == true) {
Text(tr("mobile.doublesTournament", "Doppel-Turnier"))
}
}
}
else -> Text(tr("mobile.tournamentDetailPending", "Details werden geladen …"))
}
}
}
item {
Spacer(modifier = Modifier.height(16.dp))
Text(tr("officialTournaments.savedEvents", "Offizielle Turniere (Import)"), fontWeight = FontWeight.SemiBold)
@@ -365,11 +368,242 @@ internal fun TournamentsScreen(dependencies: AppDependencies) {
item {
Spacer(modifier = Modifier.height(20.dp))
Text(
tr("mobile.tournamentsWebHintFooter", "Turniere anlegen, bearbeiten oder Meldelisten verwalten optional im Webbrowser."),
tr("mobile.tournamentsWebHintFooter", "Offizielle Meldelisten und Teilnahmen unten; Vereins-Turniere oben in der App bearbeitbar."),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.62f),
)
}
}
}
is TournamentsMainRoute.Editor -> InternalTournamentEditorScreen(
clubId = clubId,
tournamentId = route.tournamentId,
dependencies = dependencies,
onClose = {
mainRoute = TournamentsMainRoute.Browse
dependencies.applicationScope.launch {
dependencies.clubInternalTournamentsManager.loadList(clubId)
}
},
)
TournamentsMainRoute.CreateStandard -> TournamentCreateStandardScreen(
clubId = clubId,
allowsExternal = internalState.filter == ClubTournamentDisplayFilter.External,
dependencies = dependencies,
tr = ::tr,
onBack = { mainRoute = TournamentsMainRoute.Browse },
onCreated = { newId: Int ->
mainRoute = TournamentsMainRoute.Editor(newId)
dependencies.applicationScope.launch {
dependencies.clubInternalTournamentsManager.loadList(clubId)
}
},
)
TournamentsMainRoute.CreateMini -> TournamentCreateMiniScreen(
clubId = clubId,
dependencies = dependencies,
tr = ::tr,
onBack = { mainRoute = TournamentsMainRoute.Browse },
onCreated = { newId: Int ->
mainRoute = TournamentsMainRoute.Editor(newId)
dependencies.applicationScope.launch {
dependencies.clubInternalTournamentsManager.loadList(clubId)
}
},
)
TournamentsMainRoute.OfficialWorkspace -> OfficialTournamentsWorkspaceScreen(
clubId = clubId,
canWrite = perms?.canWriteTournaments() == true,
dependencies = dependencies,
tr = ::tr,
onClose = {
mainRoute = TournamentsMainRoute.Browse
dependencies.applicationScope.launch {
dependencies.officialTournamentsReadManager.load(clubId)
}
},
)
}
}
@Composable
private fun TournamentCreateStandardScreen(
clubId: Int,
allowsExternal: Boolean,
dependencies: AppDependencies,
tr: (String, String) -> String,
onBack: () -> Unit,
onCreated: (Int) -> Unit,
) {
var name by remember { mutableStateOf("") }
var date by rememberSaveable { mutableStateOf("") }
var ws by remember { mutableStateOf("3") }
var error by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
val scroll = rememberScrollState()
Column(
Modifier
.fillMaxSize()
.verticalScroll(scroll)
.padding(horizontal = TournamentsPad, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
TextButton(onClick = onBack) { Text(tr("mobile.back", "Zurück")) }
Text(
tr("tournaments.newTournament", "Neues Turnier"),
style = MaterialTheme.typography.h6,
fontWeight = FontWeight.SemiBold,
)
error?.let { Text(it, color = MaterialTheme.colors.error) }
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(tr("tournaments.tournamentName", "Turniername")) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = date,
onValueChange = { date = it },
label = { Text(tr("tournaments.date", "Datum (YYYY-MM-DD)")) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = ws,
onValueChange = { ws = it.filter { ch -> ch.isDigit() }.take(2) },
label = { Text(tr("tournaments.winningSets", "Gewinnsätze")) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Button(
onClick = {
if (date.isBlank()) {
error = tr("tournaments.pleaseEnterDate", "Bitte Datum eingeben")
return@Button
}
busy = true
error = null
dependencies.applicationScope.launch {
runCatching {
val res = withContext(Dispatchers.IO) {
dependencies.tournamentsApi.addStandardTournament(
AddStandardTournamentBody(
clubId = clubId,
tournamentName = name.trim().ifBlank { date.trim() },
date = date.trim(),
winningSets = ws.toIntOrNull() ?: 3,
allowsExternal = allowsExternal,
isDoublesTournament = false,
),
)
}
onCreated(res.id)
}.onFailure { error = it.message }
busy = false
}
},
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (busy) "" else tr("common.create", "Erstellen"))
}
}
}
@Composable
private fun TournamentCreateMiniScreen(
clubId: Int,
dependencies: AppDependencies,
tr: (String, String) -> String,
onBack: () -> Unit,
onCreated: (Int) -> Unit,
) {
var ort by remember { mutableStateOf("") }
var date by rememberSaveable { mutableStateOf("") }
var year by remember { mutableStateOf(Calendar.getInstance().get(Calendar.YEAR).toString()) }
var ws by remember { mutableStateOf("1") }
var error by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
val scroll = rememberScrollState()
Column(
Modifier
.fillMaxSize()
.verticalScroll(scroll)
.padding(horizontal = TournamentsPad, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
TextButton(onClick = onBack) { Text(tr("mobile.back", "Zurück")) }
Text(
tr("tournaments.miniChampionships", "Minimeisterschaft"),
style = MaterialTheme.typography.h6,
fontWeight = FontWeight.SemiBold,
)
error?.let { Text(it, color = MaterialTheme.colors.error) }
OutlinedTextField(
value = ort,
onValueChange = { ort = it },
label = { Text(tr("tournaments.miniChampionshipLocation", "Ort")) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = date,
onValueChange = { date = it },
label = { Text(tr("tournaments.date", "Datum")) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = year,
onValueChange = { year = it.filter { ch -> ch.isDigit() }.take(4) },
label = { Text(tr("tournaments.miniChampionshipYear", "Jahr")) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = ws,
onValueChange = { ws = it.filter { ch -> ch.isDigit() }.take(1) },
label = { Text(tr("tournaments.winningSets", "Gewinnsätze")) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Button(
onClick = {
if (ort.isBlank() || date.isBlank()) {
error = tr("tournaments.enterMiniLocation", "Ort und Datum eingeben")
return@Button
}
val y = year.toIntOrNull() ?: 0
if (y < 2000 || y > 2100) {
error = tr("tournaments.miniChampionshipYearHint", "Jahr ungültig")
return@Button
}
busy = true
error = null
dependencies.applicationScope.launch {
runCatching {
val res = withContext(Dispatchers.IO) {
dependencies.tournamentsApi.addMiniChampionship(
AddMiniChampionshipBody(
clubId = clubId,
ort = ort.trim(),
date = date.trim(),
year = y,
winningSets = ws.toIntOrNull() ?: 1,
),
)
}
onCreated(res.id)
}.onFailure { error = it.message }
busy = false
}
},
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (busy) "" else tr("common.create", "Erstellen"))
}
}
}

View File

@@ -0,0 +1,160 @@
package de.tt_tagebuch.app.util
import de.tt_tagebuch.shared.api.models.Member
import de.tt_tagebuch.shared.api.models.OfficialParsedCompetitionDto
import de.tt_tagebuch.shared.api.models.OfficialParsedTournamentEnvelopeDto
import java.text.Collator
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.GregorianCalendar
import java.util.Locale
/**
* Entspricht der Logik in [frontend/src/views/OfficialTournaments.vue] (isEligibleForCompetition).
*/
object OfficialTournamentEligibility {
private val collator: Collator = Collator.getInstance(Locale.GERMAN).apply { strength = Collator.PRIMARY }
fun compareMembers(a: Member, b: Member): Int {
val c = collator.compare(a.firstName, b.firstName)
if (c != 0) return c
return collator.compare(a.lastName, b.lastName)
}
fun eligibleActiveMembers(
parsed: OfficialParsedTournamentEnvelopeDto?,
competition: OfficialParsedCompetitionDto,
members: List<Member>,
): List<Member> {
return members
.filter { it.active }
.filter { isEligibleForCompetition(it, competition, parsed) }
.sortedWith(::compareMembers)
}
fun isEligibleForCompetition(
member: Member,
c: OfficialParsedCompetitionDto,
parsed: OfficialParsedTournamentEnvelopeDto?,
): Boolean {
val rule = getGenderRule(c)
val g = (member.gender ?: "unknown").lowercase(Locale.ROOT)
if (rule == "female" && g != "female") return false
if (rule == "male" && g != "male") return false
if (!isEligibleByAge(member, c, parsed)) return false
return true
}
private fun getGenderRule(c: OfficialParsedCompetitionDto): String {
val txt = "${c.ageClassCompetition ?: c.altersklasseWettbewerb ?: ""} ${c.openTo ?: c.offenFuer ?: ""}"
.lowercase(Locale.ROOT)
if (Regex("(mädchen|weiblich|\\bw\\b)").containsMatchIn(txt)) return "female"
if (Regex("(jungen|männlich|\\bm\\b)").containsMatchIn(txt)) return "male"
if ("jugend" in txt) return "both"
return "both"
}
/** Entspricht [OfficialTournaments.vue] `isEligibleByAge` (Reihenfolge: Jahrgang → Stichtag → U/Jugend/AK). */
private fun isEligibleByAge(member: Member, c: OfficialParsedCompetitionDto, parsed: OfficialParsedTournamentEnvelopeDto?): Boolean {
val bd = parseDateFlexible(member.birthDate ?: "") ?: return false
val birthYearRule = getBirthYearFromPerformanceClass(c)
if (birthYearRule != null) {
val memberYear = bd.get(Calendar.YEAR)
if (birthYearRule.exact != null) return memberYear == birthYearRule.exact
if (birthYearRule.minYear != null) return memberYear >= birthYearRule.minYear
}
val cutoff = getCutoffDate(c)
if (cutoff != null) {
if (bd.timeInMillis <= cutoff.timeInMillis) return false
}
val ageLimit = getAgeLimitFromText("${c.ageClassCompetition ?: c.altersklasseWettbewerb ?: ""}")
if (ageLimit != null) {
val ref = getReferenceDate(c, parsed) ?: return false
val age = calculateAgeOnDate(bd, ref) ?: return false
return if (ageLimit.exclusive) age < ageLimit.limit else age <= ageLimit.limit
}
return true
}
data class AgeLimit(val limit: Int, val exclusive: Boolean)
data class BirthYearRule(val exact: Int? = null, val minYear: Int? = null)
private fun getAgeLimitFromText(text: String?): AgeLimit? {
if (text.isNullOrBlank()) return null
val t = text
Regex("(?:jugend|mädchen|maedchen)\\s+(\\d{1,2})\\b", RegexOption.IGNORE_CASE).find(t)?.let {
return AgeLimit(it.groupValues[1].toInt(), exclusive = false)
}
Regex("\\bU\\s*(\\d{1,2})\\b", RegexOption.IGNORE_CASE).find(t)?.let {
return AgeLimit(it.groupValues[1].toInt(), exclusive = true)
}
Regex("\\bAK\\s*(\\d{1,2})\\b", RegexOption.IGNORE_CASE).find(t)?.let { m ->
if (!Regex("\\(\\s*AK\\s*\\d+\\s*\\)", RegexOption.IGNORE_CASE).containsMatchIn(t)) {
return AgeLimit(m.groupValues[1].toInt(), exclusive = true)
}
}
return null
}
private fun getBirthYearFromPerformanceClass(c: OfficialParsedCompetitionDto): BirthYearRule? {
val pc = "${c.performanceClass ?: c.leistungsklasse ?: ""}"
Regex("jahrgang\\s+(\\d{4})\\s+und\\s+jünger", RegexOption.IGNORE_CASE).find(pc)?.let {
return BirthYearRule(minYear = it.groupValues[1].toInt())
}
Regex("jahrgang\\s+(\\d{4})\\b", RegexOption.IGNORE_CASE).find(pc)?.let {
return BirthYearRule(exact = it.groupValues[1].toInt())
}
return null
}
private fun getCutoffDate(c: OfficialParsedCompetitionDto): Calendar? {
var valStr = (c.cutoffDate ?: c.stichtag ?: "").trim()
if (valStr.isEmpty()) {
val pc = "${c.performanceClass ?: c.leistungsklasse ?: ""}"
Regex("stichtag\\s*:?\\s*([0-3]?\\d\\.[01]?\\d\\.\\d{4})", RegexOption.IGNORE_CASE).find(pc)?.let {
valStr = it.groupValues[1].trim()
}
}
return parseDateFlexible(valStr)
}
fun getReferenceDate(c: OfficialParsedCompetitionDto, parsed: OfficialParsedTournamentEnvelopeDto?): Calendar? {
val fromStart = Regex("(\\d{1,2}\\.\\d{1,2}\\.\\d{4})").find("${c.startTime ?: c.startzeit ?: ""}")
if (fromStart != null) return parseDateFlexible(fromStart.value)
return parseDateFlexible(parsed?.parsedData?.termin ?: "")
}
private fun calculateAgeOnDate(birth: Calendar, ref: Calendar): Int? {
var age = ref.get(Calendar.YEAR) - birth.get(Calendar.YEAR)
val m = ref.get(Calendar.MONTH) - birth.get(Calendar.MONTH)
if (m < 0 || (m == 0 && ref.get(Calendar.DAY_OF_MONTH) < birth.get(Calendar.DAY_OF_MONTH))) age--
return age
}
fun parseDateFlexible(s: String?): Calendar? {
if (s.isNullOrBlank()) return null
val t = s.trim()
Regex("^(\\d{1,2})\\.(\\d{1,2})\\.(\\d{4})$").find(t)?.let {
val d = it.groupValues[1].toInt()
val m = it.groupValues[2].toInt()
val y = it.groupValues[3].toInt()
return GregorianCalendar(y, m - 1, d)
}
Regex("^(\\d{4})-(\\d{2})-(\\d{2})$").find(t)?.let {
val y = it.groupValues[1].toInt()
val m = it.groupValues[2].toInt()
val d = it.groupValues[3].toInt()
return GregorianCalendar(y, m - 1, d)
}
return runCatching {
val ms = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.GERMAN).parse(t)?.time
?: SimpleDateFormat("yyyy-MM-dd", Locale.GERMAN).parse(t)?.time
if (ms == null) null else GregorianCalendar.getInstance().apply { timeInMillis = ms }
}.getOrNull()
}
}