feat(TeamManagement): enhance team management features and introduce planning phase for Android
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s

- Updated TODO.md to outline the new planning phase for Android, aligning it with existing web functionalities for team management.
- Refactored AppDependencies to include TeamDocumentsApi, improving API integration for team-related documents.
- Replaced MobileTeamsScreen with TeamManagementScreen in ClubStammdatenScreens for better navigation.
- Enhanced TeamManagementScreen with improved state management and UI updates for team editing and data loading.
- Added new API methods in ClubTeamsApi for managing team lineups, supporting better team planning and organization.
- Introduced new methods in MatchesApi and MyTischtennisApi to enhance match and team data handling.
This commit is contained in:
Torsten Schulz (local)
2026-05-14 18:31:15 +02:00
parent 2f3f4fb275
commit e0196a6617
12 changed files with 1960 additions and 328 deletions

View File

@@ -30,6 +30,7 @@ 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.TeamDocumentsApi
import de.tt_tagebuch.shared.api.TrainingGroupsApi
import de.tt_tagebuch.shared.api.TrainingStatsApi
import de.tt_tagebuch.shared.api.TrainingTimesApi
@@ -101,11 +102,13 @@ class AppDependencies(context: Context) {
val pendingApprovalsManager = PendingApprovalsManager(ClubApprovalsApi(client))
val permissionsAdminManager = PermissionsAdminManager(permissionsApi)
val apiLogsManager = ApiLogsManager(ApiLogsApi(client))
val apiLogsApi = ApiLogsApi(client)
val apiLogsManager = ApiLogsManager(apiLogsApi)
val tournamentsApi = TournamentsApi(client)
val matchesApi = MatchesApi(client)
val clubTeamsApi = ClubTeamsApi(client)
val seasonsApi = SeasonsApi(client)
val teamDocumentsApi = TeamDocumentsApi(client)
val clubInternalTournamentsManager = ClubInternalTournamentsManager(tournamentsApi)
val officialTournamentsApi = OfficialTournamentsApi(client)
val officialTournamentsReadManager = OfficialTournamentsReadManager(officialTournamentsApi)
@@ -128,8 +131,9 @@ class AppDependencies(context: Context) {
MemberGroupPhotosApi(client),
)
private val trainingStatsApi = TrainingStatsApi(client)
val membersApi = MembersApi(client)
val membersManager = MembersManager(
MembersApi(client),
membersApi,
TrainingGroupsApi(client),
MemberActivitiesApi(client),
TrainingTimesApi(client),

View File

@@ -86,7 +86,7 @@ internal fun ClubStammdatenFlowScreen(
ClubStammdatenSection.ClubSettings -> MobileClubSettingsScreen(dependencies, onBack)
ClubStammdatenSection.PredefinedActivities -> MobilePredefinedActivitiesScreen(dependencies, onBack)
ClubStammdatenSection.MemberTransfer -> MobileMemberTransferScreen(dependencies, onBack)
ClubStammdatenSection.Teams -> MobileTeamsScreen(dependencies, onBack)
ClubStammdatenSection.Teams -> TeamManagementScreen(dependencies, onBack)
}
}
@@ -460,59 +460,6 @@ private fun MobileMemberTransferScreen(dependencies: AppDependencies, onBack: ()
}
}
@Composable
private fun MobileTeamsScreen(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 scheduleState by dependencies.scheduleManager.state.collectAsState()
LaunchedEffect(clubId) {
dependencies.scheduleManager.loadClubTeams(clubId)
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(StammdatenPad),
) {
StammdatenTopBar(tr("mobile.teamManagement", "Team-Verwaltung"), onBack)
if (perms?.canReadMembers() != true) {
Text(tr("mobile.noAccess", "Keine Berechtigung."))
return@Column
}
scheduleState.error?.let { Text(it, color = MaterialTheme.colors.error) }
if (scheduleState.isLoading) {
CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp))
return@Column
}
if (scheduleState.teams.isEmpty()) {
Text(tr("mobile.noTeams", "Keine Mannschaften gefunden."))
return@Column
}
Text(
tr("mobile.teamsHint", "Mannschaften werden aus Click-TT/nuLiga importiert und im Terminplan genutzt."),
style = MaterialTheme.typography.caption,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f),
)
Spacer(modifier = Modifier.height(8.dp))
scheduleState.teams.forEach { t ->
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), elevation = 1.dp) {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
Text(t.name ?: tr("mobile.team", "Mannschaft"), fontWeight = FontWeight.SemiBold)
val league = t.league?.name ?: ""
if (league.isNotBlank()) {
Text(league, style = MaterialTheme.typography.caption)
}
}
}
}
}
}
@Composable
private fun RowSwitch(label: String, checked: Boolean, onChecked: (Boolean) -> Unit) {
Row(

View File

@@ -0,0 +1,223 @@
package de.tt_tagebuch.app.ui
import de.tt_tagebuch.shared.api.models.Member
import java.util.Calendar
import java.util.GregorianCalendar
/**
* Entspricht der Aufstellungs-/Meldungslogik in `TeamManagementView.vue`.
*/
internal object TeamEditorLineupLogic {
fun seasonReferenceYearFromLabel(seasonLabel: String): Int {
val s = seasonLabel.trim()
Regex("(\\d{4})\\s*/\\s*(\\d{4})").find(s)?.let {
return it.groupValues[2].toInt()
}
Regex("(\\d{4})\\s*/\\s*(\\d{2})").find(s)?.let {
val startYear = it.groupValues[1].toInt()
val endTwo = it.groupValues[2].toInt()
val century = (startYear / 100) * 100
return century + endTwo
}
Regex("(\\d{4})").find(s)?.let { return it.groupValues[1].toInt() }
return GregorianCalendar().get(Calendar.YEAR)
}
fun parseBirthCalendar(value: String?): Calendar? {
if (value.isNullOrBlank()) return null
val trimmed = value.trim()
Regex("^(\\d{1,2})\\.(\\d{1,2})\\.(\\d{4})$").find(trimmed)?.let {
return GregorianCalendar(
it.groupValues[3].toInt(),
it.groupValues[2].toInt() - 1,
it.groupValues[1].toInt(),
)
}
Regex("^(\\d{4})-(\\d{2})-(\\d{2})$").find(trimmed)?.let {
return GregorianCalendar(
it.groupValues[1].toInt(),
it.groupValues[2].toInt() - 1,
it.groupValues[3].toInt(),
)
}
return null
}
fun memberActualAge(member: Member, today: Calendar = GregorianCalendar()): Int? {
val birth = parseBirthCalendar(member.birthDate) ?: return null
var age = today.get(Calendar.YEAR) - birth.get(Calendar.YEAR)
val hadBirthday =
today.get(Calendar.MONTH) > birth.get(Calendar.MONTH) ||
(today.get(Calendar.MONTH) == birth.get(Calendar.MONTH) &&
today.get(Calendar.DAY_OF_MONTH) >= birth.get(Calendar.DAY_OF_MONTH))
if (!hadBirthday) age -= 1
return age
}
fun memberSeasonAge(member: Member, seasonLabel: String): Int? {
val birth = parseBirthCalendar(member.birthDate) ?: return null
val refYear = seasonReferenceYearFromLabel(seasonLabel)
return refYear - birth.get(Calendar.YEAR)
}
fun memberAgeGroupCode(member: Member, seasonLabel: String): String {
val seasonAge = memberSeasonAge(member, seasonLabel) ?: return "unknown"
return when {
seasonAge <= 11 -> "J11"
seasonAge <= 13 -> "J13"
seasonAge <= 15 -> "J15"
seasonAge <= 17 -> "J17"
seasonAge <= 19 -> "J19"
else -> "adult"
}
}
fun parseLeagueAgeGroupCode(leagueName: String?): String {
val source = leagueName.orEmpty()
Regex("([JM])(\\d{1,2})", RegexOption.IGNORE_CASE).find(source)?.let {
return "J${it.groupValues[2]}"
}
Regex("(?:jugend|mädchen)\\s*(\\d{1,2})", RegexOption.IGNORE_CASE).find(source)?.let {
return "J${it.groupValues[1]}"
}
return "adult"
}
fun parseLeagueGenderCode(leagueName: String?): String {
val s = leagueName.orEmpty().lowercase()
return if (Regex("(frauen|damen|mädchen|girls|women)").containsMatchIn(s)) "female" else "open"
}
fun configuredTeamAgeGroup(teamAgeGroup: String?, leagueName: String?): String =
teamAgeGroup?.takeIf { it.isNotBlank() } ?: parseLeagueAgeGroupCode(leagueName)
fun configuredTeamGender(teamGender: String?, leagueName: String?): String =
teamGender?.takeIf { it.isNotBlank() } ?: parseLeagueGenderCode(leagueName)
fun isFemaleEligible(member: Member, teamGender: String): Boolean {
if (teamGender != "female") return true
return member.gender == "female"
}
fun isEligibleForTeam(member: Member, teamAgeGroup: String, teamGender: String, seasonLabel: String): Boolean {
if (!member.active || !isFemaleEligible(member, teamGender)) return false
val memberAgeGroup = memberAgeGroupCode(member, seasonLabel)
if (memberAgeGroup == "unknown") return false
if (teamAgeGroup == "adult") {
val actual = memberActualAge(member)
if (actual != null && actual >= 18) return true
return member.adultReleaseApproved == true || member.adultReserveApproved == true
}
val order = listOf("J11", "J13", "J15", "J17", "J19")
val mi = order.indexOf(memberAgeGroup)
val ti = order.indexOf(teamAgeGroup)
if (mi < 0 || ti < 0) return false
return mi <= ti
}
fun lineupRatingValue(member: Member): Double {
val q = member.qttr ?: return Double.NEGATIVE_INFINITY
if (q <= 0) return Double.NEGATIVE_INFINITY
return q.toDouble()
}
fun lineupRatingLabel(member: Member): String {
val q = member.qttr ?: return ""
if (q <= 0) return ""
return q.toString()
}
fun eligibilityLabel(member: Member, teamAgeGroup: String, seasonLabel: String): String {
val mag = memberAgeGroupCode(member, seasonLabel)
if (teamAgeGroup != "adult" || mag == "adult") return "OK"
if (member.adultReleaseApproved == true && member.adultReserveApproved == true) {
return "Erw.+Reserve"
}
if (member.adultReleaseApproved == true) return "Erw.Freigabe"
if (member.adultReserveApproved == true) return "Reserve"
return "OK"
}
data class LineupGroup(
val code: String,
val label: String,
val members: List<MemberDisplay>,
)
data class MemberDisplay(
val member: Member,
val lineupRatingLabel: String,
val memberAgeGroupLabel: String,
val eligibilityLabel: String,
)
fun buildLineupGroups(
members: List<Member>,
teamAgeGroup: String,
teamGender: String,
seasonLabel: String,
labelAdults: String,
labelUnknown: String,
): List<LineupGroup> {
val eligible = members.filter { isEligibleForTeam(it, teamAgeGroup, teamGender, seasonLabel) }
if (eligible.isEmpty()) return emptyList()
val preferred = teamAgeGroup
val defaultOrder =
if (teamAgeGroup == "adult") {
listOf("adult", "J19", "J17", "J15", "J13", "J11", "unknown")
} else {
listOf(teamAgeGroup) + listOf("J19", "J17", "J15", "J13", "J11").filter { it != teamAgeGroup } + "unknown"
}
val groupOrder =
if (defaultOrder.contains(preferred)) {
listOf(preferred) + defaultOrder.filter { it != preferred }
} else {
defaultOrder
}
fun ageLabel(code: String) = when (code) {
"adult" -> labelAdults
"unknown" -> labelUnknown
else -> code
}
val byCode = linkedMapOf<String, MutableList<MemberDisplay>>()
for (m in eligible) {
val code = memberAgeGroupCode(m, seasonLabel)
val row = MemberDisplay(
member = m,
lineupRatingLabel = lineupRatingLabel(m),
memberAgeGroupLabel = ageLabel(code),
eligibilityLabel = eligibilityLabel(m, teamAgeGroup, seasonLabel),
)
byCode.getOrPut(code) { mutableListOf() }.add(row)
}
for (list in byCode.values) {
list.sortWith(
compareByDescending<MemberDisplay> { lineupRatingValue(it.member) }
.thenBy { it.member.lastName.lowercase() }
.thenBy { it.member.firstName.lowercase() },
)
}
return groupOrder.mapNotNull { code ->
val rows = byCode[code] ?: return@mapNotNull null
if (rows.isEmpty()) return@mapNotNull null
LineupGroup(code = code, label = ageLabel(code), members = rows)
}
}
fun lineupGapViolationMessage(selectedOrdered: List<Member>, template: (String, String) -> String): String? {
for (i in 0 until selectedOrdered.size - 1) {
val a = selectedOrdered[i]
val b = selectedOrdered[i + 1]
val ra = lineupRatingValue(a)
val rb = lineupRatingValue(b)
if (!ra.isFinite() || !rb.isFinite()) continue
if (rb > ra + 30) {
return template("${b.firstName} ${b.lastName}".trim(), "${a.firstName} ${a.lastName}".trim())
}
}
return null
}
}

View File

@@ -1,18 +1,16 @@
package de.tt_tagebuch.app.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.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
@@ -43,20 +41,17 @@ 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.CancellationException
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(
@@ -64,7 +59,9 @@ internal fun TeamManagementScreen(
onBack: () -> Unit,
) {
val languageCode = LocalLanguageCode.current
fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb)
val getMobileString: (String, String) -> String = remember(languageCode) {
{ k: String, fb: String -> MobileStrings.get(languageCode, k, fb) }
}
val clubState by dependencies.clubManager.state.collectAsState()
val clubId = clubState.currentClubId ?: return
val perms = clubState.currentPermissions
@@ -72,90 +69,73 @@ internal fun TeamManagementScreen(
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 seasons by remember(clubId) { mutableStateOf<List<SeasonDto>>(emptyList()) }
var selectedSeasonId by remember(clubId) { mutableStateOf<Int?>(null) }
var teams by remember(clubId) { mutableStateOf<List<ClubTeamDto>>(emptyList()) }
var leagues by remember(clubId) { mutableStateOf<List<ClubLeagueOptionDto>>(emptyList()) }
var loading by remember(clubId) { mutableStateOf(true) }
var error by remember(clubId) { mutableStateOf<String?>(null) }
var search by remember(clubId) { mutableStateOf("") }
var loadTick by remember(clubId) { mutableStateOf(0) }
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 editorOpen by remember { mutableStateOf(false) }
var editorTeamId by remember { mutableStateOf<Int?>(null) }
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 openEditor(team: ClubTeamDto) {
editorTeamId = team.id
editorOpen = true
}
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
fun openNewTeamEditor() {
editorTeamId = null
editorOpen = true
}
// Saisons + Default-Saison ohne selectedSeasonId in denselben Keys wie den Team-Load zu mischen:
// Sonst setzt der Effekt selectedSeasonId, wird abgebrochen, runCatching zeigt die Cancellation-Meldung.
LaunchedEffect(clubId) {
selectedSeasonId = null
seasons = emptyList()
reloadData()
try {
val allSeasons = withContext(Dispatchers.IO) { dependencies.seasonsApi.listSeasons() }
.sortedByDescending { it.id }
seasons = allSeasons
if (allSeasons.isEmpty()) {
selectedSeasonId = null
teams = emptyList()
leagues = emptyList()
loading = false
return@LaunchedEffect
}
if (selectedSeasonId == null) {
selectedSeasonId = withContext(Dispatchers.IO) { dependencies.seasonsApi.getCurrentSeason() }.id
}
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
error = e.message ?: getMobileString("mobile.teamLoadError", "Daten konnten nicht geladen werden.")
}
}
LaunchedEffect(clubId, selectedSeasonId) {
if (selectedSeasonId == null) return@LaunchedEffect
LaunchedEffect(clubId, selectedSeasonId, loadTick) {
val sid = selectedSeasonId ?: return@LaunchedEffect
loading = true
error = null
runCatching {
val sid = selectedSeasonId
try {
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
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
error = e.message ?: getMobileString("mobile.teamLoadError", "Daten konnten nicht geladen werden.")
} finally {
loading = false
}
}
val filtered = remember(teams, search) {
@@ -168,49 +148,59 @@ internal fun TeamManagementScreen(
}
}
Column(Modifier.fillMaxSize().padding(TeamPad)) {
Box(Modifier.fillMaxSize()) {
val editorSeasonId = selectedSeasonId
if (editorOpen && editorSeasonId != null) {
TeamEditorScreen(
dependencies = dependencies,
clubId = clubId,
seasonId = editorSeasonId,
seasonLabel = seasons.find { it.id == editorSeasonId }?.season.orEmpty(),
initialTeamId = editorTeamId,
onBack = { editorOpen = false },
onSaved = { loadTick++ },
)
} else {
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"),
getMobileString("navigation.teamManagement", "Team-Verwaltung"),
style = MaterialTheme.typography.h6,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
if (canWrite) {
Button(
onClick = {
resetFormForNew()
showForm = true
},
onClick = { openNewTeamEditor() },
enabled = !loading && selectedSeasonId != null,
) {
Text(tr("mobile.teamNew", "Neue Mannschaft"))
Text(getMobileString("mobile.teamNew", "Neue Mannschaft"))
}
}
}
if (!canRead) {
Text(tr("mobile.noTeamAccess", "Keine Berechtigung für die Team-Verwaltung."))
Text(getMobileString("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)."),
getMobileString("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)
Text(getMobileString("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"),
?: getMobileString("mobile.seasonPick", "Saison wählen"),
)
}
DropdownMenu(expanded = seasonMenu, onDismissRequest = { seasonMenu = false }) {
@@ -232,7 +222,7 @@ internal fun TeamManagementScreen(
onValueChange = { search = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(tr("mobile.teamSearch", "Mannschaft suchen")) },
label = { Text(getMobileString("mobile.teamSearch", "Mannschaft suchen")) },
)
error?.let {
@@ -241,18 +231,22 @@ internal fun TeamManagementScreen(
}
if (loading && teams.isEmpty()) {
CircularProgressIndicator(Modifier.padding(top = 24.dp).align(Alignment.CenterHorizontally))
Box(Modifier.fillMaxWidth().padding(top = 24.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return@Column
}
if (!loading && teams.isEmpty()) {
Spacer(Modifier.height(16.dp))
Text(tr("mobile.noTeams", "Keine Mannschaften für diese Saison."))
Text(getMobileString("mobile.noTeams", "Keine Mannschaften für diese Saison."))
return@Column
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(filtered, key = { it.id }) { team ->
@@ -260,21 +254,23 @@ internal fun TeamManagementScreen(
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")
?: getMobileString("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,
getMobileString("mobile.plannedLeagueShort", "Geplant: ") + pl,
style = MaterialTheme.typography.caption,
)
}
val g = team.teamGender ?: "open"
val a = team.teamAgeGroup ?: "adult"
val genShort = getMobileString("mobile.teamGenderShort", "Geschlecht")
val ageShort = getMobileString("mobile.teamAgeShort", "AK")
Text(
"${tr("mobile.teamGenderShort", "Geschlecht")}: ${labelGender(tr, g)} · ${tr("mobile.teamAgeShort", "AK")}: ${labelAge(tr, a)}",
"$genShort: ${labelGender(getMobileString, g)} · $ageShort: ${labelAge(getMobileString, a)}",
style = MaterialTheme.typography.caption,
)
team.myTischtennisTeamId?.takeIf { it.isNotBlank() }?.let { mid ->
@@ -282,11 +278,11 @@ internal fun TeamManagementScreen(
}
if (canWrite) {
Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = { openEdit(team) }) {
Text(tr("common.edit", "Bearbeiten"))
TextButton(onClick = { openEditor(team) }) {
Text(getMobileString("common.edit", "Bearbeiten"))
}
TextButton(onClick = { deleteTarget = team }) {
Text(tr("common.delete", "Löschen"), color = MaterialTheme.colors.error)
Text(getMobileString("common.delete", "Löschen"), color = MaterialTheme.colors.error)
}
}
}
@@ -294,193 +290,59 @@ internal fun TeamManagementScreen(
}
}
}
}
}
}
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) {
deleteTarget?.let { target ->
AlertDialog(
onDismissRequest = { deleteTarget = null },
title = { Text(getMobileString("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) }
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 },
),
)
dependencies.scheduleManager.loadClubTeams(clubId)
}
loadTick++
}.onFailure { ex ->
if (ex is CancellationException) throw ex
infoMessage = ex.message
}
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"))
}
},
)
}
},
) { Text(getMobileString("common.delete", "Löschen")) }
},
dismissButton = {
TextButton(onClick = { deleteTarget = null }) {
Text(getMobileString("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")) } },
)
infoMessage?.let { msg ->
AlertDialog(
onDismissRequest = { infoMessage = null },
title = { Text(getMobileString("common.error", "Fehler")) },
text = { Text(msg) },
confirmButton = { TextButton(onClick = { infoMessage = null }) { Text(getMobileString("common.ok", "OK")) } },
)
}
}
}
private fun labelGender(tr: (String, String) -> String, code: String): String =
private fun labelGender(t: (String, String) -> String, code: String): String =
when (code) {
"female" -> tr("mobile.teamGenderFemale", "Weiblich")
else -> tr("mobile.teamGenderOpen", "Offen")
"male" -> t("mobile.teamGenderMale", "Männlich")
"female" -> t("mobile.teamGenderFemale", "Weiblich")
else -> t("mobile.teamGenderOpen", "Offen")
}
private fun labelAge(tr: (String, String) -> String, code: String): String =
if (code == "adult") tr("mobile.teamAgeAdult", "Erwachsene") else code
private fun labelAge(t: (String, String) -> String, code: String): String =
if (code == "adult") t("mobile.teamAgeAdult", "Erwachsene") else code