feat(Tournament): add official tournament participation feature
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s

- Introduced functionality to load and display official tournament participation events in the CalendarView.
- Updated the API client to fetch official tournament data, enhancing the event management capabilities.
- Added new UI elements to represent official tournaments, including visual indicators and event details.
- Enhanced the ClubManager to support fetching and managing official tournament data.
- Updated the mobile app's TODO list to reflect progress on tournament-related features.
This commit is contained in:
Torsten Schulz (local)
2026-05-12 23:52:54 +02:00
parent bea5facb7d
commit 57468f1efb
15 changed files with 931 additions and 11 deletions

View File

@@ -20,6 +20,7 @@ import de.tt_tagebuch.shared.api.PredefinedActivitiesApi
import de.tt_tagebuch.shared.api.MatchesApi
import de.tt_tagebuch.shared.api.MemberActivitiesApi
import de.tt_tagebuch.shared.api.MemberGroupPhotosApi
import de.tt_tagebuch.shared.api.MemberTransferConfigApi
import de.tt_tagebuch.shared.api.MembersApi
import de.tt_tagebuch.shared.api.OfficialTournamentsApi
import de.tt_tagebuch.shared.api.PermissionsApi
@@ -45,7 +46,6 @@ import de.tt_tagebuch.shared.state.MutableTokenProvider
import de.tt_tagebuch.shared.state.OfficialTournamentsReadManager
import de.tt_tagebuch.shared.state.PendingApprovalsManager
import de.tt_tagebuch.shared.state.PermissionsAdminManager
import de.tt_tagebuch.shared.state.MutableTokenProvider
import de.tt_tagebuch.shared.state.ScheduleManager
import de.tt_tagebuch.shared.state.TrainingStatsManager
import kotlinx.coroutines.CoroutineScope
@@ -87,6 +87,7 @@ class AppDependencies(context: Context) {
)
private val permissionsApi = PermissionsApi(client)
val predefinedActivitiesApi = PredefinedActivitiesApi(client)
val clubManager = ClubManager(
clubStorage = AndroidClubStorage(context.applicationContext),
@@ -99,6 +100,7 @@ class AppDependencies(context: Context) {
val apiLogsManager = ApiLogsManager(ApiLogsApi(client))
val clubInternalTournamentsManager = ClubInternalTournamentsManager(TournamentsApi(client))
val officialTournamentsReadManager = OfficialTournamentsReadManager(OfficialTournamentsApi(client))
val memberTransferConfigApi = MemberTransferConfigApi(client)
val diaryManager = DiaryManager(
DiaryApi(client),
@@ -106,7 +108,7 @@ class AppDependencies(context: Context) {
GroupApi(client),
DiaryMemberActivitiesApi(client),
DiaryMemberApi(client),
PredefinedActivitiesApi(client),
predefinedActivitiesApi,
AccidentApi(client),
MemberGroupPhotosApi(client),
)

View File

@@ -211,8 +211,9 @@ private fun MainTabs(dependencies: AppDependencies) {
val clubState by dependencies.clubManager.state.collectAsState()
val visibleTabs = visibleMainTabs(clubState.currentPermissions)
LaunchedEffect(visibleTabs, selectedTab) {
if (!visibleTabs.contains(selectedTab)) {
LaunchedEffect(clubState.currentPermissions, selectedTab) {
val tabs = visibleMainTabs(clubState.currentPermissions)
if (!tabs.contains(selectedTab)) {
selectedTab = MainTab.Home
}
}
@@ -380,6 +381,13 @@ private fun HomeScreen(
onClick = { onOpenTab(MainTab.Schedule) },
)
}
if (p.canReadTournaments()) {
HomeHubTile(
title = tr("navigation.clubTournaments", "Turniere"),
subtitle = tr("home.tileTournaments", "Vereins-Turniere, Teilnahmen"),
onClick = { onOpenTab(MainTab.Tournaments) },
)
}
}
HomeHubTile(
title = tr("navigation.statistics", "Statistik"),
@@ -4079,6 +4087,8 @@ private fun SettingsScreen(dependencies: AppDependencies) {
dependencies.pendingApprovalsManager.clear()
dependencies.permissionsAdminManager.clear()
dependencies.apiLogsManager.clear()
dependencies.clubInternalTournamentsManager.clear()
dependencies.officialTournamentsReadManager.clear()
}
},
modifier = Modifier

View File

@@ -0,0 +1,709 @@
package de.tt_tagebuch.app.ui
import androidx.activity.compose.BackHandler
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.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.Divider
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.Switch
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.models.Club
import de.tt_tagebuch.shared.api.models.MemberDataQualityRequirements
import de.tt_tagebuch.shared.api.models.MemberTransferConfigEnvelope
import de.tt_tagebuch.shared.api.models.PredefinedActivityDto
import de.tt_tagebuch.shared.api.models.PredefinedActivityUpsertBody
import de.tt_tagebuch.shared.api.models.UpdateClubSettingsBody
import de.tt_tagebuch.shared.api.models.canReadClubSettings
import de.tt_tagebuch.shared.api.models.canReadMembers
import de.tt_tagebuch.shared.api.models.canReadPredefinedActivities
import de.tt_tagebuch.shared.api.models.canWriteClubSettings
import de.tt_tagebuch.shared.api.models.canWriteMembers
import de.tt_tagebuch.shared.api.models.canWritePredefinedActivities
import de.tt_tagebuch.shared.api.models.displayLabel
import de.tt_tagebuch.shared.i18n.MobileStrings
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
private val StammdatenPad = 20.dp
private val StammdatenTouchMin = 48.dp
private data class GermanStateOption(val code: String, val name: String)
private val germanStates: List<GermanStateOption> = listOf(
GermanStateOption("DE-BW", "Baden-Württemberg"),
GermanStateOption("DE-BY", "Bayern"),
GermanStateOption("DE-BE", "Berlin"),
GermanStateOption("DE-BB", "Brandenburg"),
GermanStateOption("DE-HB", "Bremen"),
GermanStateOption("DE-HH", "Hamburg"),
GermanStateOption("DE-HE", "Hessen"),
GermanStateOption("DE-MV", "Mecklenburg-Vorpommern"),
GermanStateOption("DE-NI", "Niedersachsen"),
GermanStateOption("DE-NW", "Nordrhein-Westfalen"),
GermanStateOption("DE-RP", "Rheinland-Pfalz"),
GermanStateOption("DE-SL", "Saarland"),
GermanStateOption("DE-SN", "Sachsen"),
GermanStateOption("DE-ST", "Sachsen-Anhalt"),
GermanStateOption("DE-SH", "Schleswig-Holstein"),
GermanStateOption("DE-TH", "Thüringen"),
)
internal enum class ClubStammdatenSection {
ClubSettings,
PredefinedActivities,
MemberTransfer,
}
@Composable
internal fun ClubStammdatenFlowScreen(
destination: ClubStammdatenSection,
dependencies: AppDependencies,
onBack: () -> Unit,
) {
BackHandler(onBack = onBack)
when (destination) {
ClubStammdatenSection.ClubSettings -> MobileClubSettingsScreen(dependencies, onBack)
ClubStammdatenSection.PredefinedActivities -> MobilePredefinedActivitiesScreen(dependencies, onBack)
ClubStammdatenSection.MemberTransfer -> MobileMemberTransferScreen(dependencies, onBack)
}
}
@Composable
private fun StammdatenTopBar(title: String, onBack: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
Text(title, style = MaterialTheme.typography.h6, fontWeight = FontWeight.SemiBold)
}
Spacer(modifier = Modifier.height(8.dp))
}
private fun defaultQuality(): MemberDataQualityRequirements = MemberDataQualityRequirements()
private fun normalizeQuality(m: MemberDataQualityRequirements?): MemberDataQualityRequirements {
val d = defaultQuality()
if (m == null) return d
return MemberDataQualityRequirements(
requireStreet = m.requireStreet,
requirePostalCode = m.requirePostalCode,
requireCity = m.requireCity,
requirePhone = m.requirePhone,
requireEmail = m.requireEmail,
)
}
@Composable
private fun MobileClubSettingsScreen(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 scope = rememberCoroutineScope()
var club by remember { mutableStateOf<Club?>(null) }
var loadError by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(true) }
var saving by remember { mutableStateOf(false) }
var savedHint by remember { mutableStateOf(false) }
var greeting by remember { mutableStateOf("") }
var associationNumber by remember { mutableStateOf("") }
var countryCode by remember { mutableStateOf("DE") }
var stateCode by remember { mutableStateOf("") }
var stateMenu by remember { mutableStateOf(false) }
var myTtNickname by remember { mutableStateOf("") }
var autoFetch by remember { mutableStateOf(false) }
var quality by remember { mutableStateOf(defaultQuality()) }
LaunchedEffect(clubId) {
loading = true
loadError = null
club = runCatching { dependencies.clubManager.fetchClubDetail(clubId) }
.onFailure { loadError = it.message }
.getOrNull()
val c = club
if (c != null) {
greeting = c.greetingText.orEmpty()
associationNumber = c.associationMemberNumber.orEmpty()
countryCode = c.countryCode?.ifBlank { "DE" } ?: "DE"
stateCode = c.stateCode.orEmpty()
myTtNickname = c.myTischtennisFedNickname.orEmpty()
autoFetch = c.autoFetchRankings == true
quality = normalizeQuality(c.memberDataQualityRequirements)
}
loading = false
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(StammdatenPad),
) {
StammdatenTopBar(tr("clubSettings.title", "Vereinseinstellungen"), onBack)
if (perms?.canReadClubSettings() != true) {
Text(tr("mobile.noAccess", "Keine Berechtigung."))
return@Column
}
when {
loading -> CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp))
loadError != null -> Text(loadError!!, color = MaterialTheme.colors.error)
club == null -> Text(tr("clubSettings.loadFailed", "Laden fehlgeschlagen"))
else -> {
Text(tr("clubSettings.greetingText", "Begrüßung"), fontWeight = FontWeight.SemiBold)
OutlinedTextField(
value = greeting,
onValueChange = { greeting = it },
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
minLines = 4,
label = { Text(tr("clubSettings.greetingPlaceholder", "Text")) },
)
Text(
tr("clubSettings.greetingHint", "Platzhalter: {home}, {guest}, …"),
style = MaterialTheme.typography.caption,
modifier = Modifier.padding(top = 4.dp),
)
Spacer(modifier = Modifier.height(16.dp))
Text(tr("clubSettings.associationMemberNumber", "Verbands-Nr."), fontWeight = FontWeight.SemiBold)
OutlinedTextField(
value = associationNumber,
onValueChange = { associationNumber = it },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
singleLine = true,
)
Spacer(modifier = Modifier.height(16.dp))
Text("Kalenderregion", fontWeight = FontWeight.SemiBold)
Text("Land", style = MaterialTheme.typography.caption)
OutlinedTextField(
value = countryCode,
onValueChange = { countryCode = it.uppercase().take(2) },
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
singleLine = true,
enabled = false,
)
Text("Bundesland", style = MaterialTheme.typography.caption, modifier = Modifier.padding(top = 8.dp))
Box {
TextButton(onClick = { stateMenu = true }, modifier = Modifier.fillMaxWidth()) {
Text(
germanStates.find { it.code == stateCode }?.name
?: tr("mobile.stateNotSet", "Nicht gesetzt"),
)
}
DropdownMenu(expanded = stateMenu, onDismissRequest = { stateMenu = false }) {
DropdownMenuItem(
onClick = {
stateCode = ""
stateMenu = false
},
) { Text(tr("mobile.stateNotSet", "Nicht gesetzt")) }
germanStates.forEach { s ->
DropdownMenuItem(
onClick = {
stateCode = s.code
stateMenu = false
},
) { Text(s.name) }
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(tr("clubSettings.myTischtennisRankings", "MyTischtennis-Rankings"), fontWeight = FontWeight.SemiBold)
RowSwitch(tr("clubSettings.autoFetchRankings", "Automatisch abrufen"), autoFetch) { autoFetch = it }
if (autoFetch) {
OutlinedTextField(
value = myTtNickname,
onValueChange = { myTtNickname = it },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
label = { Text(tr("clubSettings.myTischtennisFedNickname", "Verbands-Kurzname")) },
singleLine = true,
)
}
Spacer(modifier = Modifier.height(16.dp))
Text(tr("clubSettings.memberDataQuality", "Pflichtfelder Mitglieder"), fontWeight = FontWeight.SemiBold)
RowSwitch(tr("clubSettings.requireStreet", "Straße"), quality.requireStreet) {
quality = quality.copy(requireStreet = it)
}
RowSwitch(tr("clubSettings.requirePostalCode", "PLZ"), quality.requirePostalCode) {
quality = quality.copy(requirePostalCode = it)
}
RowSwitch(tr("clubSettings.requireCity", "Ort"), quality.requireCity) {
quality = quality.copy(requireCity = it)
}
RowSwitch(tr("clubSettings.requirePhone", "Telefon"), quality.requirePhone) {
quality = quality.copy(requirePhone = it)
}
RowSwitch(tr("clubSettings.requireEmail", "E-Mail"), quality.requireEmail) {
quality = quality.copy(requireEmail = it)
}
TextButton(
onClick = { dependencies.openBackendPath("/club-settings") },
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp)
.heightIn(min = StammdatenTouchMin),
) {
Text(tr("mobile.openTrainingGroupsWeb", "Trainingsgruppen & Zeiten im Browser"))
}
if (savedHint) {
Text(
tr("clubSettings.saved", "Gespeichert"),
color = MaterialTheme.colors.primary,
modifier = Modifier.padding(top = 8.dp),
)
}
Button(
onClick = {
if (perms.canWriteClubSettings()) {
scope.launch {
saving = true
savedHint = false
runCatching {
dependencies.clubManager.updateClubSettings(
clubId,
UpdateClubSettingsBody(
greetingText = greeting,
associationMemberNumber = associationNumber,
countryCode = countryCode,
stateCode = stateCode.ifBlank { null },
myTischtennisFedNickname = myTtNickname.ifBlank { null },
autoFetchRankings = autoFetch,
memberDataQualityRequirements = normalizeQuality(quality),
),
)
club = dependencies.clubManager.fetchClubDetail(clubId)
savedHint = true
}.onFailure { loadError = it.message }
saving = false
}
}
},
enabled = !saving && perms.canWriteClubSettings(),
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp)
.heightIn(min = StammdatenTouchMin),
) {
Text(tr("clubSettings.save", "Speichern"))
}
}
}
}
}
@Composable
private fun MobilePredefinedActivitiesScreen(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 scope = rememberCoroutineScope()
var list by remember { mutableStateOf<List<PredefinedActivityDto>>(emptyList()) }
var loading by remember { mutableStateOf(true) }
var error by remember { mutableStateOf<String?>(null) }
var refresh by remember { mutableStateOf(0) }
var editorTarget by remember { mutableStateOf<PredefinedActivityDto?>(null) }
var creatingNew by remember { mutableStateOf(false) }
LaunchedEffect(clubId, refresh) {
loading = true
error = null
list = runCatching { dependencies.predefinedActivitiesApi.list(scope = null) }
.onFailure { error = it.message }
.getOrDefault(emptyList())
loading = false
}
Column(modifier = Modifier.fillMaxSize().padding(StammdatenPad)) {
StammdatenTopBar(tr("mobile.predefinedActivities", "Standard-Aktivitäten"), onBack)
if (perms?.canReadPredefinedActivities() != true) {
Text(tr("mobile.noAccess", "Keine Berechtigung."))
return@Column
}
TextButton(
onClick = { dependencies.openBackendPath("/predefined-activities") },
modifier = Modifier.fillMaxWidth().heightIn(min = StammdatenTouchMin),
) { Text(tr("mobile.openPredefinedWeb", "Volle Verwaltung (Web)")) }
if (perms.canWritePredefinedActivities()) {
Button(
onClick = { creatingNew = true },
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
.heightIn(min = StammdatenTouchMin),
) { Text(tr("mobile.newPredefined", "Neue Aktivität")) }
}
when {
loading -> CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp))
error != null -> Text(error!!, color = MaterialTheme.colors.error)
else -> {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(top = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(list, key = { it.id }) { a ->
Card(modifier = Modifier.fillMaxWidth(), elevation = 1.dp) {
TextButton(
onClick = { editorTarget = a },
enabled = perms.canWritePredefinedActivities(),
modifier = Modifier.fillMaxWidth().heightIn(min = StammdatenTouchMin),
) {
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.Start) {
Text(a.displayLabel(), fontWeight = FontWeight.SemiBold)
a.code?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.caption) }
}
}
}
}
}
}
}
}
if (editorTarget != null || creatingNew) {
PredefinedActivityEditorDialog(
initial = editorTarget,
isNew = creatingNew,
canSave = perms?.canWritePredefinedActivities() == true,
onDismiss = { editorTarget = null; creatingNew = false },
onSaved = {
editorTarget = null
creatingNew = false
refresh++
},
resolve = { k, fb -> tr(k, fb) },
scope = scope,
api = dependencies.predefinedActivitiesApi,
)
}
}
@Composable
@Composable
private fun PredefinedActivityEditorDialog(
initial: PredefinedActivityDto?,
isNew: Boolean,
canSave: Boolean,
onDismiss: () -> Unit,
onSaved: () -> Unit,
resolve: (String, String) -> String,
scope: kotlinx.coroutines.CoroutineScope,
api: de.tt_tagebuch.shared.api.PredefinedActivitiesApi,
) {
var name by remember(initial?.id, isNew) { mutableStateOf(initial?.name.orEmpty()) }
var code by remember(initial?.id, isNew) { mutableStateOf(initial?.code.orEmpty()) }
var description by remember(initial?.id, isNew) { mutableStateOf(initial?.description.orEmpty()) }
var durationText by remember(initial?.id, isNew) { mutableStateOf(initial?.durationText.orEmpty()) }
var duration by remember(initial?.id, isNew) { mutableStateOf(initial?.duration?.toString().orEmpty()) }
var exclude by remember(initial?.id, isNew) { mutableStateOf(initial?.excludeFromStats == true) }
var err by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if (isNew) resolve("mobile.newPredefined", "Neue Aktivität") else resolve("mobile.editPredefined", "Bearbeiten")) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
err?.let { Text(it, color = MaterialTheme.colors.error) }
OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text(resolve("tournaments.name", "Name")) })
OutlinedTextField(value = code, onValueChange = { code = it }, label = { Text("Code") })
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text(resolve("mobile.description", "Beschreibung")) },
minLines = 2,
)
OutlinedTextField(value = durationText, onValueChange = { durationText = it }, label = { Text("Dauer (Text)") })
OutlinedTextField(value = duration, onValueChange = { duration = it.filter { ch -> ch.isDigit() } }, label = { Text("Dauer (Min)") })
RowSwitch(resolve("mobile.excludeFromStats", "Von Statistik ausschließen"), exclude) { exclude = it }
}
},
confirmButton = {
TextButton(
enabled = canSave && !busy && name.isNotBlank(),
onClick = {
scope.launch {
busy = true
err = null
val d = duration.toIntOrNull()
val body = PredefinedActivityUpsertBody(
name = name.trim(),
code = code.trim().ifBlank { null },
description = description.trim().ifBlank { null },
durationText = durationText.trim().ifBlank { null },
duration = d,
excludeFromStats = exclude,
)
runCatching {
if (isNew) {
api.create(body)
} else {
api.update(initial!!.id, body)
}
onSaved()
}.onFailure { err = it.message }
busy = false
}
},
) { Text(resolve("common.save", "Speichern")) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(resolve("common.cancel", "Abbrechen")) }
},
)
}
@Composable
private fun MobileMemberTransferScreen(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 scope = rememberCoroutineScope()
var loading by remember { mutableStateOf(true) }
var server by remember { mutableStateOf("") }
var transferEndpoint by remember { mutableStateOf("") }
var transferMethod by remember { mutableStateOf("POST") }
var transferTemplate by remember { mutableStateOf("") }
var useBulk by remember { mutableStateOf(false) }
var bulkWrap by remember { mutableStateOf("") }
var loginUser by remember { mutableStateOf("") }
var loginPass by remember { mutableStateOf("") }
var hasConfig by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var busy by remember { mutableStateOf(false) }
fun applyFromApi(env: MemberTransferConfigEnvelope?) {
val cfg = env?.config
if (cfg != null) {
hasConfig = true
server = cfg.server.orEmpty()
transferEndpoint = cfg.transferEndpoint.orEmpty()
transferMethod = cfg.transferMethod ?: "POST"
transferTemplate = cfg.transferTemplate.orEmpty()
useBulk = cfg.useBulkMode == true
bulkWrap = cfg.bulkWrapperTemplate.orEmpty()
loginUser = cfg.loginCredentials?.username.orEmpty()
loginPass = ""
} else {
hasConfig = false
server = ""
transferEndpoint = ""
transferMethod = "POST"
transferTemplate = ""
useBulk = false
bulkWrap = ""
loginUser = ""
loginPass = ""
}
}
LaunchedEffect(clubId) {
loading = true
error = null
val env = runCatching { dependencies.memberTransferConfigApi.get(clubId) }.getOrNull()
if (env != null && !env.success) {
applyFromApi(null)
} else {
applyFromApi(env)
}
loading = false
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(StammdatenPad),
) {
StammdatenTopBar(tr("mobile.memberTransfer", "Mitgliedstransfer"), onBack)
if (perms?.canReadMembers() != true) {
Text(tr("mobile.noAccess", "Keine Berechtigung."))
return@Column
}
TextButton(
onClick = { dependencies.openBackendPath("/member-transfer-settings") },
modifier = Modifier.fillMaxWidth().heightIn(min = StammdatenTouchMin),
) { Text(tr("mobile.openMemberTransferWeb", "Erweiterte Einstellungen (Web)")) }
if (loading) {
CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp))
return@Column
}
Text(
if (hasConfig) tr("mobile.memberTransferConfigured", "Konfiguration vorhanden") else tr("mobile.memberTransferNotConfigured", "Nicht konfiguriert"),
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(top = 8.dp),
)
error?.let { Text(it, color = MaterialTheme.colors.error, modifier = Modifier.padding(top = 8.dp)) }
if (perms.canWriteMembers()) {
OutlinedTextField(
value = server,
onValueChange = { server = it },
label = { Text("Server-URL") },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
singleLine = true,
)
OutlinedTextField(
value = transferEndpoint,
onValueChange = { transferEndpoint = it },
label = { Text("Transfer-Endpoint") },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
singleLine = true,
)
OutlinedTextField(
value = transferMethod,
onValueChange = { transferMethod = it },
label = { Text("HTTP-Methode") },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
singleLine = true,
)
OutlinedTextField(
value = transferTemplate,
onValueChange = { transferTemplate = it },
label = { Text("Transfer-Template") },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
minLines = 4,
)
RowSwitch("Bulk-Modus", useBulk) { useBulk = it }
if (useBulk) {
OutlinedTextField(
value = bulkWrap,
onValueChange = { bulkWrap = it },
label = { Text("Bulk-Wrapper") },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
minLines = 2,
)
}
OutlinedTextField(
value = loginUser,
onValueChange = { loginUser = it },
label = { Text("Login Benutzername") },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
singleLine = true,
)
OutlinedTextField(
value = loginPass,
onValueChange = { loginPass = it },
label = { Text("Login Passwort (nur bei Änderung)") },
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
singleLine = true,
)
Row(
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = {
scope.launch {
busy = true
error = null
val cred = if (loginUser.isNotBlank() || loginPass.isNotBlank()) {
buildJsonObject {
if (loginUser.isNotBlank()) put("username", JsonPrimitive(loginUser))
if (loginPass.isNotBlank()) put("password", JsonPrimitive(loginPass))
}
} else {
null
}
runCatching {
val body = MemberTransferConfigSaveBody(
server = server.trim(),
loginEndpoint = null,
transferEndpoint = transferEndpoint.trim(),
transferMethod = transferMethod.trim().ifBlank { "POST" },
transferTemplate = transferTemplate,
useBulkMode = useBulk,
bulkWrapperTemplate = bulkWrap.ifBlank { null },
loginCredentials = cred,
)
val r = dependencies.memberTransferConfigApi.save(clubId, body)
if (!r.success) error = r.error ?: r.message ?: "Fehler"
else {
loginPass = ""
applyFromApi(r)
}
}.onFailure { error = it.message }
busy = false
}
},
enabled = !busy && server.isNotBlank() && transferEndpoint.isNotBlank() && transferTemplate.isNotBlank(),
modifier = Modifier.weight(1f).heightIn(min = StammdatenTouchMin),
) { Text(tr("common.save", "Speichern")) }
if (hasConfig) {
OutlinedButton(
onClick = {
scope.launch {
busy = true
error = null
runCatching {
val r = dependencies.memberTransferConfigApi.delete(clubId)
if (r.success) applyFromApi(null)
else error = r.error ?: r.message
}.onFailure { error = it.message }
busy = false
}
},
enabled = !busy,
modifier = Modifier.heightIn(min = StammdatenTouchMin),
) { Text(tr("common.delete", "Löschen")) }
}
}
}
}
}

View File

@@ -260,7 +260,6 @@ internal fun TournamentsScreen(dependencies: AppDependencies) {
}
}
@Composable
@Composable
private fun ModeFilterChip(label: String, selected: Boolean, onClick: () -> Unit) {
TextButton(