feat(MatchService): enhance match filtering for own teams and update mobile app settings
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 43s

- Added logic in MatchService to filter matches based on the user's own teams, ensuring only relevant matches are displayed.
- Updated the mobile app's TODO list to reflect progress on ClubSettings and Predefined Activities features.
- Enhanced the AppRoot and ClubStammdatenScreens to support new settings and permissions for club management.
- Introduced new API methods for creating and updating training groups and times, improving the training management capabilities.
- Refactored MembersManager to include methods for managing training groups and times, streamlining the member management process.
This commit is contained in:
Torsten Schulz (local)
2026-05-13 00:01:25 +02:00
parent 57468f1efb
commit 54d9b9fc86
9 changed files with 1239 additions and 251 deletions

View File

@@ -85,6 +85,8 @@ import de.tt_tagebuch.shared.api.toAbsoluteUrl
import de.tt_tagebuch.shared.api.models.MemberGroupPhotoDto
import de.tt_tagebuch.shared.api.models.canReadApprovals
import de.tt_tagebuch.shared.api.models.canReadClubPermissions
import de.tt_tagebuch.shared.api.models.canReadClubSettings
import de.tt_tagebuch.shared.api.models.canReadPredefinedActivities
import de.tt_tagebuch.shared.api.models.canReadDiary
import de.tt_tagebuch.shared.api.models.canReadTeams
import de.tt_tagebuch.shared.api.models.canReadMembers
@@ -3969,6 +3971,7 @@ private fun RowSwitch(label: String, checked: Boolean, onChecked: (Boolean) -> U
@Composable
private fun SettingsScreen(dependencies: AppDependencies) {
var clubAdminSection by remember { mutableStateOf<ClubAdminSettingsSection?>(null) }
var stammdatenSection by remember { mutableStateOf<ClubStammdatenSection?>(null) }
if (clubAdminSection != null) {
ClubAdminFlowScreen(
destination = clubAdminSection!!,
@@ -3977,6 +3980,14 @@ private fun SettingsScreen(dependencies: AppDependencies) {
)
return
}
if (stammdatenSection != null) {
ClubStammdatenFlowScreen(
destination = stammdatenSection!!,
dependencies = dependencies,
onBack = { stammdatenSection = null },
)
return
}
val authState by dependencies.authManager.state.collectAsState()
val clubState by dependencies.clubManager.state.collectAsState()
var sessionStatus by rememberSaveable { mutableStateOf<String?>(null) }
@@ -4026,6 +4037,27 @@ private fun SettingsScreen(dependencies: AppDependencies) {
modifier = Modifier.fillMaxWidth(),
) { Text(tr("mobile.teamManagementWeb", "Team-Verwaltung (Web)")) }
}
if (perms.canReadClubSettings() || perms.canReadPredefinedActivities() || perms.canReadMembers()) {
SectionTitle(tr("mobile.clubStammdaten", "Verein & Stammdaten"))
if (perms.canReadClubSettings()) {
TextButton(
onClick = { stammdatenSection = ClubStammdatenSection.ClubSettings },
modifier = Modifier.fillMaxWidth(),
) { Text(tr("clubSettings.title", "Vereinseinstellungen")) }
}
if (perms.canReadPredefinedActivities()) {
TextButton(
onClick = { stammdatenSection = ClubStammdatenSection.PredefinedActivities },
modifier = Modifier.fillMaxWidth(),
) { Text(tr("mobile.predefinedActivities", "Standard-Aktivitäten")) }
}
if (perms.canReadMembers()) {
TextButton(
onClick = { stammdatenSection = ClubStammdatenSection.MemberTransfer },
modifier = Modifier.fillMaxWidth(),
) { Text(tr("mobile.memberTransfer", "Mitgliedstransfer")) }
}
}
}
SectionTitle(tr("mobile.language", "Sprache"))
MobileStrings.supportedLanguages.forEach { language ->

View File

@@ -5,7 +5,6 @@ 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
@@ -46,16 +45,18 @@ 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.PredefinedActivitiesApi
import de.tt_tagebuch.shared.api.models.MemberTransferConfigEnvelope
import de.tt_tagebuch.shared.api.models.MemberTransferConfigSaveBody
import de.tt_tagebuch.shared.api.models.PredefinedActivityDto
import de.tt_tagebuch.shared.api.models.PredefinedActivityUpsertBody
import de.tt_tagebuch.shared.api.models.TrainingGroupDto
import de.tt_tagebuch.shared.api.models.TrainingTimeDto
import de.tt_tagebuch.shared.api.models.UpdateClubSettingsBody
import de.tt_tagebuch.shared.api.models.UpdateTrainingTimeBody
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
@@ -67,27 +68,6 @@ 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,
@@ -122,227 +102,6 @@ private fun StammdatenTopBar(title: String, onBack: () -> Unit) {
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
@@ -431,7 +190,6 @@ private fun MobilePredefinedActivitiesScreen(dependencies: AppDependencies, onBa
}
}
@Composable
@Composable
private fun PredefinedActivityEditorDialog(
initial: PredefinedActivityDto?,
@@ -441,7 +199,7 @@ private fun PredefinedActivityEditorDialog(
onSaved: () -> Unit,
resolve: (String, String) -> String,
scope: kotlinx.coroutines.CoroutineScope,
api: de.tt_tagebuch.shared.api.PredefinedActivitiesApi,
api: PredefinedActivitiesApi,
) {
var name by remember(initial?.id, isNew) { mutableStateOf(initial?.name.orEmpty()) }
var code by remember(initial?.id, isNew) { mutableStateOf(initial?.code.orEmpty()) }
@@ -707,3 +465,17 @@ private fun MobileMemberTransferScreen(dependencies: AppDependencies, onBack: ()
}
}
}
@Composable
private fun RowSwitch(label: String, checked: Boolean, onChecked: (Boolean) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(label, modifier = Modifier.weight(1f))
Switch(checked = checked, onCheckedChange = onChecked)
}
}