feat: add number of tables to tournament updates and enhance related UI components
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 47s

This commit is contained in:
Torsten Schulz (local)
2026-05-16 00:18:59 +02:00
parent 40bd5e0745
commit f8f1c797e7
13 changed files with 436 additions and 30 deletions

View File

@@ -22,12 +22,17 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
@@ -235,19 +240,37 @@ fun AppRoot(dependencies: AppDependencies) {
}
CompositionLocalProvider(LocalLanguageCode provides languageState.currentLanguageCode) {
var openMemberPortraitCropRequested by remember { mutableStateOf(false) }
var openMemberGalleryRequested by remember { mutableStateOf(false) }
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
when {
authState.isHydrating -> LoadingScreen(tr("mobile.appLoading", "App wird geladen"))
!authState.isLoggedIn -> AuthFlowHost(dependencies = dependencies)
clubState.currentClubId == null -> ClubSelectScreen(dependencies)
else -> MainTabs(dependencies)
else -> MainTabs(
dependencies,
openMemberPortraitCropRequested = openMemberPortraitCropRequested,
onConsumeOpenMemberPortraitCrop = { openMemberPortraitCropRequested = false },
onRequestOpenMemberPortraitCrop = { openMemberPortraitCropRequested = true },
openMemberGalleryRequested = openMemberGalleryRequested,
onConsumeOpenMemberGallery = { openMemberGalleryRequested = false },
onRequestOpenMemberGallery = { openMemberGalleryRequested = true },
)
}
}
}
}
@Composable
private fun MainTabs(dependencies: AppDependencies) {
private fun MainTabs(
dependencies: AppDependencies,
openMemberPortraitCropRequested: Boolean,
onConsumeOpenMemberPortraitCrop: () -> Unit,
onRequestOpenMemberPortraitCrop: () -> Unit,
openMemberGalleryRequested: Boolean,
onConsumeOpenMemberGallery: () -> Unit,
onRequestOpenMemberGallery: () -> Unit,
) {
var selectedTab by rememberSaveable { mutableStateOf(MainTab.Home) }
var diarySelectedEntryId by remember { mutableStateOf<Int?>(null) }
var membersNestedOpen by remember { mutableStateOf(false) }
@@ -321,6 +344,15 @@ private fun MainTabs(dependencies: AppDependencies) {
selectedTab = tab
}
/** Wenn jemand die MitgliederGalerie anfordert, wechsle zu Members und öffne die NestedAnsicht. */
LaunchedEffect(openMemberGalleryRequested) {
if (openMemberGalleryRequested) {
selectMainTab(MainTab.Members)
membersNestedOpen = true
onConsumeOpenMemberGallery()
}
}
if (useWideMainNav) {
Row(modifier = Modifier.fillMaxSize()) {
MainNavigationRail(
@@ -358,13 +390,19 @@ private fun MainTabs(dependencies: AppDependencies) {
modifier = Modifier.fillMaxHeight().width(1.dp),
)
Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
MainTabContent(
MainTabContent(
selectedTab = selectedTab,
dependencies = dependencies,
onNavigateTab = { selectMainTab(it) },
diarySelectedEntryId = diarySelectedEntryId,
onDiarySelectedEntryId = { diarySelectedEntryId = it },
onMembersNestedOpenChange = { membersNestedOpen = it },
onMembersNestedOpenChange = { membersNestedOpen = it },
onOpenMemberPortraitCrop = onRequestOpenMemberPortraitCrop,
onOpenMembersGallery = onRequestOpenMemberGallery,
openMemberPortraitCropRequested = openMemberPortraitCropRequested,
onConsumeOpenMemberPortraitCrop = onConsumeOpenMemberPortraitCrop,
openMemberGalleryRequested = openMemberGalleryRequested,
onConsumeOpenMemberGallery = onConsumeOpenMemberGallery,
billingOrdersSection = billingOrdersSection,
onBillingOrdersSectionChange = { billingOrdersSection = it },
settingsClubAdminRequest = settingsClubAdminRequest,
@@ -384,6 +422,12 @@ private fun MainTabs(dependencies: AppDependencies) {
diarySelectedEntryId = diarySelectedEntryId,
onDiarySelectedEntryId = { diarySelectedEntryId = it },
onMembersNestedOpenChange = { membersNestedOpen = it },
onOpenMemberPortraitCrop = onRequestOpenMemberPortraitCrop,
onOpenMembersGallery = onRequestOpenMemberGallery,
openMemberPortraitCropRequested = openMemberPortraitCropRequested,
onConsumeOpenMemberPortraitCrop = onConsumeOpenMemberPortraitCrop,
openMemberGalleryRequested = openMemberGalleryRequested,
onConsumeOpenMemberGallery = onConsumeOpenMemberGallery,
billingOrdersSection = billingOrdersSection,
onBillingOrdersSectionChange = { billingOrdersSection = it },
settingsClubAdminRequest = settingsClubAdminRequest,
@@ -421,6 +465,12 @@ private fun MainTabContent(
diarySelectedEntryId: Int?,
onDiarySelectedEntryId: (Int?) -> Unit,
onMembersNestedOpenChange: (Boolean) -> Unit,
onOpenMemberPortraitCrop: () -> Unit,
onOpenMembersGallery: () -> Unit,
openMemberPortraitCropRequested: Boolean,
onConsumeOpenMemberPortraitCrop: () -> Unit,
openMemberGalleryRequested: Boolean,
onConsumeOpenMemberGallery: () -> Unit,
billingOrdersSection: BillingOrdersSection?,
onBillingOrdersSectionChange: (BillingOrdersSection?) -> Unit,
settingsClubAdminRequest: ClubAdminSettingsSection?,
@@ -434,10 +484,16 @@ private fun MainTabContent(
dependencies = dependencies,
selectedEntryId = diarySelectedEntryId,
onSelectedEntryId = onDiarySelectedEntryId,
onOpenMemberPortraitCrop = onOpenMemberPortraitCrop,
onOpenMembersGallery = onOpenMembersGallery,
)
MainTab.Members -> MembersScreen(
dependencies = dependencies,
onNestedOpenChange = onMembersNestedOpenChange,
openMemberPortraitCropRequested = openMemberPortraitCropRequested,
onConsumeOpenMemberPortraitCrop = onConsumeOpenMemberPortraitCrop,
openMemberGalleryRequested = openMemberGalleryRequested,
onConsumeOpenMemberGallery = onConsumeOpenMemberGallery,
)
MainTab.Schedule -> ScheduleScreen(dependencies)
MainTab.Calendar -> CalendarScreen(
@@ -1415,6 +1471,8 @@ private fun DiaryListScreen(
dependencies: AppDependencies,
selectedEntryId: Int?,
onSelectedEntryId: (Int?) -> Unit,
onOpenMemberPortraitCrop: () -> Unit,
onOpenMembersGallery: () -> Unit,
) {
val clubState by dependencies.clubManager.state.collectAsState()
val diaryState by dependencies.diaryManager.state.collectAsState()
@@ -1484,6 +1542,8 @@ private fun DiaryListScreen(
entry = selectedEntry,
dependencies = dependencies,
onBack = { onSelectedEntryId(null) },
onOpenMemberPortraitCrop = onOpenMemberPortraitCrop,
onOpenMembersGallery = onOpenMembersGallery,
)
return
}
@@ -1783,6 +1843,8 @@ private fun DiaryDetailScreen(
entry: DiaryDate,
dependencies: AppDependencies,
onBack: () -> Unit,
onOpenMemberPortraitCrop: () -> Unit,
onOpenMembersGallery: () -> Unit,
) {
BackHandler(onBack = onBack)
@@ -2106,7 +2168,7 @@ private fun DiaryDetailScreen(
val activeMembers = remember(membersState.members) {
membersState.members.filter { it.active }.sortedWith(
compareBy({ it.lastName.lowercase() }, { it.firstName.lowercase() }),
compareBy({ it.firstName.lowercase() }, { it.lastName.lowercase() }),
)
}
@@ -2232,6 +2294,101 @@ private fun DiaryDetailScreen(
) {
Text(tr("common.delete", "Löschen"))
}
if (canReadMembers) {
var showMembersGalleryDialog by remember { mutableStateOf(false) }
OutlinedButton(
onClick = { showMembersGalleryDialog = true },
modifier = Modifier.heightIn(min = TouchMinHeight),
) { Text(tr("members.gallery", "MitgliederGalerie")) }
if (showMembersGalleryDialog) {
val dialogMembers = activeMembers.filter { m ->
(m.hasImage == true) || (m.imageUrl != null) || (m.primaryImageId != null) || (m.images.isNotEmpty())
}
AlertDialog(
onDismissRequest = { showMembersGalleryDialog = false },
title = { Text(tr("members.gallery", "MitgliederGalerie")) },
text = {
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
val gridState = rememberLazyGridState()
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 120.dp),
state = gridState,
modifier = Modifier.fillMaxWidth().padding(4.dp),
) {
items(dialogMembers) { m ->
val pRow = participants.find { it.memberId == m.id }
val checked = pRow?.isPresentParticipant() == true
Box(
modifier = Modifier
.padding(4.dp)
.aspectRatio(1f)
.clickable {
if (!canWriteDiary) return@clickable
dependencies.applicationScope.launch {
try {
if (!checked) dependencies.diaryManager.addTrainingParticipant(entry.id, m.id)
else dependencies.diaryManager.removeTrainingParticipant(entry.id, m.id)
participants = dependencies.diaryManager.listTrainingParticipants(entry.id)
} catch (_: Throwable) {
}
}
}
) {
AuthenticatedAsyncImage(
imageUrl = dependencies.apiConfig.toAbsoluteUrl(memberProfileImagePath(clubId, m.id)),
authHeaders = dependencies.diaryAuthHeaders(),
modifier = Modifier.fillMaxSize(),
contentDescription = m.fullName(),
contentScale = androidx.compose.ui.layout.ContentScale.Crop,
)
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.background(Color.Black.copy(alpha = 0.45f))
.padding(6.dp)
) {
Text(
text = m.fullName(),
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontSize = 12.sp,
)
}
Checkbox(
checked = checked,
onCheckedChange = {
dependencies.applicationScope.launch {
try {
if (it) dependencies.diaryManager.addTrainingParticipant(entry.id, m.id)
else dependencies.diaryManager.removeTrainingParticipant(entry.id, m.id)
participants = dependencies.diaryManager.listTrainingParticipants(entry.id)
} catch (_: Throwable) {
}
}
},
enabled = canWriteDiary,
modifier = Modifier.align(Alignment.TopEnd).padding(6.dp)
)
}
}
}
}
},
confirmButton = {
TextButton(onClick = { showMembersGalleryDialog = false }) { Text(tr("mobile.close", "Schließen")) }
},
)
}
}
if (canWriteMembers) {
OutlinedButton(
onClick = { onOpenMemberPortraitCrop() },
modifier = Modifier.heightIn(min = TouchMinHeight),
) { Text(tr("members.groupPortraitCropTitle", "Portrait aus Gruppenfoto")) }
}
}
if (showEdit) {
DiaryEditForm(
@@ -4130,6 +4287,10 @@ private fun displayActivityDate(raw: String?): String {
private fun MembersScreen(
dependencies: AppDependencies,
onNestedOpenChange: (Boolean) -> Unit,
openMemberPortraitCropRequested: Boolean,
onConsumeOpenMemberPortraitCrop: () -> Unit,
openMemberGalleryRequested: Boolean,
onConsumeOpenMemberGallery: () -> Unit,
) {
val clubState by dependencies.clubManager.state.collectAsState()
val membersState by dependencies.membersManager.state.collectAsState()
@@ -4137,6 +4298,18 @@ private fun MembersScreen(
val canReadMembers = clubState.currentPermissions?.canReadMembers() == true
val canWriteMembers = clubState.currentPermissions?.canWriteMembers() == true
var stack by remember { mutableStateOf<MembersStackRoute>(MembersStackRoute.Browse) }
LaunchedEffect(openMemberPortraitCropRequested) {
if (openMemberPortraitCropRequested) {
stack = MembersStackRoute.GroupPhotoPortraitCrop(returnToGroupPhotoManage = false)
onConsumeOpenMemberPortraitCrop()
}
}
LaunchedEffect(openMemberGalleryRequested) {
if (openMemberGalleryRequested) {
stack = MembersStackRoute.GroupPhoto
onConsumeOpenMemberGallery()
}
}
var query by rememberSaveable { mutableStateOf("") }
LaunchedEffect(stack, clubId) {

View File

@@ -15,6 +15,7 @@ fun AuthenticatedAsyncImage(
authHeaders: Map<String, String>,
modifier: Modifier = Modifier,
contentDescription: String? = null,
contentScale: ContentScale = ContentScale.Fit,
) {
val context = LocalContext.current
val request = remember(imageUrl, authHeaders) {
@@ -30,6 +31,6 @@ fun AuthenticatedAsyncImage(
model = request,
contentDescription = contentDescription,
modifier = modifier,
contentScale = ContentScale.Fit,
contentScale = contentScale,
)
}

View File

@@ -56,6 +56,9 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonElement
import android.util.Log
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.focus.FocusState
/**
* Vollständiger Turnier-Workspace (analog Web [TournamentTab]): Stammdaten, Ablauf/Gruppen,
@@ -284,6 +287,18 @@ private fun TournamentEditorMetaTab(
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) }
// Snapshot of last saved values to avoid repeated identical saves
val lastSaved = remember(detail.id) {
mutableStateOf(
UpdateTournamentMetaBody(
name = detail.name,
date = detail.date,
winningSets = detail.winningSets,
numberOfTables = detail.numberOfTables,
isDoublesTournament = detail.isDoublesTournament,
),
)
}
val scroll = rememberScrollState()
Column(
modifier = Modifier
@@ -293,45 +308,43 @@ private fun TournamentEditorMetaTab(
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 = name,
onValueChange = { name = it },
modifier = Modifier.fillMaxWidth().onFocusChanged { fs: FocusState -> if (!fs.isFocused) saveIfChanged(name, date, winningSets, tables, doubles, lastSaved, onSave) },
singleLine = true,
)
OutlinedTextField(
value = date,
onValueChange = { date = it },
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().onFocusChanged { fs: FocusState -> if (!fs.isFocused) saveIfChanged(name, date, winningSets, tables, doubles, lastSaved, onSave) },
label = { Text(tr("tournaments.date", "Datum")) },
singleLine = true,
)
OutlinedTextField(
value = winningSets,
onValueChange = { winningSets = it.filter { ch -> ch.isDigit() }.take(2) },
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().onFocusChanged { fs: FocusState -> if (!fs.isFocused) saveIfChanged(name, date, winningSets, tables, doubles, lastSaved, onSave) },
label = { Text(tr("tournaments.winningSets", "Gewinnsätze")) },
singleLine = true,
)
OutlinedTextField(
value = tables,
onValueChange = { tables = it.filter { ch -> ch.isDigit() } },
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().onFocusChanged { fs: FocusState -> if (!fs.isFocused) saveIfChanged(name, date, winningSets, tables, doubles, lastSaved, onSave) },
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 })
Switch(checked = doubles, onCheckedChange = {
doubles = it
saveIfChanged(name, date, winningSets, tables, doubles, lastSaved, onSave)
})
}
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,
),
)
saveIfChanged(name, date, winningSets, tables, doubles, lastSaved, onSave, force = true)
},
modifier = Modifier.fillMaxWidth(),
) {
@@ -340,6 +353,35 @@ private fun TournamentEditorMetaTab(
}
}
private fun saveIfChanged(
name: String,
date: String,
winningSets: String,
tables: String,
doubles: Boolean,
lastSaved: androidx.compose.runtime.MutableState<UpdateTournamentMetaBody>,
onSave: (UpdateTournamentMetaBody) -> Unit,
force: Boolean = false,
) {
val ws = winningSets.toIntOrNull()?.coerceAtLeast(1) ?: 3
val nt = tables.toIntOrNull()
val newBody = UpdateTournamentMetaBody(
name = name.ifBlank { null },
date = date.ifBlank { null },
winningSets = ws,
numberOfTables = nt,
isDoublesTournament = doubles,
)
if (!force && newBody == lastSaved.value) return
try {
Log.d("InternalTournamentEditor", "saveIfChanged -> saving body: $newBody")
} catch (e: Exception) {
// ignore
}
onSave(newBody)
lastSaved.value = newBody
}
@Composable
private fun TournamentEditorFlowTab(
clubId: Int,