From 6aa544a1dec13d4683d5e25ea32ed8ff30dc582b Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 20 May 2026 11:36:00 +0200 Subject: [PATCH] feat: Enhance socket service for club management and event handling - Implemented club connection management in SocketService to handle joining and leaving clubs. - Added event handling for various real-time updates including participant changes and diary notes. - Updated AppRoot and DiaryDetailScreen to utilize new socket service features for real-time data synchronization. - Introduced member portrait upload functionality in DiaryDetailScreen. - Improved clipboard management across multiple screens for better user experience. - Updated versioning in libs.versions.toml for app version increment. - Refactored navigation icons to use AutoMirrored icons for better compatibility. --- .../de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt | 695 +++++++++++++----- .../tt_tagebuch/app/ui/ScheduleScreen.kt | 35 +- .../tt_tagebuch/app/ui/MemberDetailScreen.kt | 4 +- .../tt_tagebuch/app/ui/MemberEditScreen.kt | 4 +- .../tt_tagebuch/app/ui/ParticipantScreen.kt | 4 +- mobile-app/gradle/libs.versions.toml | 4 +- .../tt_tagebuch/shared/api/SocketService.kt | 81 +- 7 files changed, 586 insertions(+), 241 deletions(-) diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt index b5b32733..e2359466 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/AppRoot.kt @@ -1,5 +1,6 @@ package de.tsschulz.tt_tagebuch.app.ui +import android.content.ClipData import android.content.Intent import android.net.Uri import android.widget.Toast @@ -78,18 +79,17 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInParent -import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp @@ -298,6 +298,70 @@ private fun MainTabs( } } + DisposableEffect(clubState.currentClubId) { + val id = clubState.currentClubId + if (id != null) { + dependencies.socketService.connect(id) + } + onDispose { + dependencies.socketService.disconnect() + } + } + + LaunchedEffect(clubState.currentClubId, clubState.currentPermissions) { + val id = clubState.currentClubId ?: return@LaunchedEffect + val perms = clubState.currentPermissions ?: return@LaunchedEffect + dependencies.socketService.events.collectLatest { (event, data) -> + when (event) { + "member:changed" -> { + if (perms.canReadMembers()) { + dependencies.membersManager.loadMembers(id) + } + if (perms.canReadDiary()) { + dependencies.diaryManager.loadDates(id) + } + } + "participant:added", + "participant:removed", + "participant:updated", + "diary:note:added", + "diary:note:updated", + "diary:note:deleted", + "diary:tag:added", + "diary:tag:removed", + "diary:date:updated", + "activity:member:added", + "activity:member:removed", + "activity:changed", + "group:changed", + -> { + if (perms.canReadDiary()) { + dependencies.diaryManager.loadDates(id) + } + if (event.startsWith("participant:") && perms.canReadStatistics()) { + dependencies.trainingStatsManager.loadStats(id) + } + } + "schedule:match:updated", + "schedule:match-report:submitted", + -> { + if (perms.canReadSchedule()) { + dependencies.scheduleManager.refresh(id) + } + } + "tournament:changed" -> { + if (perms.canReadTournaments()) { + dependencies.clubInternalTournamentsManager.loadList(id) + dependencies.clubInternalTournamentsManager.state.value.selectedId?.let { tournamentId -> + dependencies.clubInternalTournamentsManager.loadDetail(id, tournamentId) + } + dependencies.officialTournamentsReadManager.load(id) + } + } + } + } + } + /** Nach Netzwerk-Wiederkehr Listen neu laden (Server ist Quelle der Wahrheit). */ LaunchedEffect(networkConnected, clubState.currentClubId, clubState.currentPermissions) { val id = clubState.currentClubId ?: return@LaunchedEffect @@ -1912,7 +1976,6 @@ private fun DiaryDetailScreen( var participantsError by remember { mutableStateOf(null) } var participantMutating by remember { mutableStateOf(false) } var participantGroupMenuMemberId by remember { mutableStateOf(null) } - var participantsSectionExpanded by rememberSaveable { mutableStateOf(false) } var accidents by remember { mutableStateOf>(emptyList()) } var accidentSectionError by remember { mutableStateOf(null) } var newAccidentMemberId by rememberSaveable { mutableStateOf(null) } @@ -1989,6 +2052,34 @@ private fun DiaryDetailScreen( } } + // Member portrait picker (used as quick "Aktivitätsbild" upload) + var pendingMemberPortraitId by remember { mutableStateOf(null) } + val pickMemberPortrait = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + if (uri == null || pendingMemberPortraitId == null) return@rememberLauncherForActivityResult + val mid = pendingMemberPortraitId ?: return@rememberLauncherForActivityResult + dependencies.applicationScope.launch { + try { + val bytes = androidContext.contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: return@launch + dependencies.membersManager.uploadMemberPortrait(clubId, mid, bytes) + dependencies.membersManager.loadMembers(clubId) + } catch (_: Throwable) { + } finally { + pendingMemberPortraitId = null + } + } + } + + // Quick dialogs: orders and training stats for a member + var ordersForMemberId by remember { mutableStateOf(null) } + var ordersList by remember { mutableStateOf>(emptyList()) } + var ordersLoading by remember { mutableStateOf(false) } + var ordersError by remember { mutableStateOf(null) } + + var statsForMemberId by remember { mutableStateOf(null) } + var statsList by remember { mutableStateOf>(emptyList()) } + var statsLoading by remember { mutableStateOf(false) } + var statsError by remember { mutableStateOf(null) } + LaunchedEffect(clubId) { dependencies.membersManager.loadMembers(clubId) } @@ -2042,6 +2133,53 @@ private fun DiaryDetailScreen( participantsLoading = false } + LaunchedEffect(clubId, entry.id) { + dependencies.socketService.events.collectLatest { (event, data) -> + val eventDateId = data.optInt("dateId", -1) + val affectsThisDate = eventDateId == entry.id + when (event) { + "member:changed" -> { + dependencies.membersManager.loadMembers(clubId) + participants = dependencies.diaryManager.listTrainingParticipants(entry.id) + } + "participant:added", + "participant:removed", + "participant:updated", + -> if (affectsThisDate) { + participants = dependencies.diaryManager.listTrainingParticipants(entry.id) + dependencies.membersManager.loadMembers(clubId) + } + "activity:changed", + "group:changed", + -> if (affectsThisDate) { + coroutineScope { + val activities = async { dependencies.diaryManager.fetchDateActivities(clubId, entry.id) } + val groups = async { + runCatching { dependencies.diaryManager.listTrainingGroups(clubId, entry.id) } + .getOrElse { emptyList() } + } + planItems = activities.await() + planGroups = groups.await() + } + } + "activity:member:added", + "activity:member:removed", + -> if (affectsThisDate) { + planItems = dependencies.diaryManager.fetchDateActivities(clubId, entry.id) + } + "diary:note:added", + "diary:note:updated", + "diary:note:deleted", + "diary:tag:added", + "diary:tag:removed", + "diary:date:updated", + -> if (affectsThisDate) { + dependencies.diaryManager.loadDates(clubId) + } + } + } + } + val refreshPlanAfterMutation: () -> Unit = { dependencies.applicationScope.launch { planMutating = true @@ -2212,8 +2350,111 @@ private fun DiaryDetailScreen( } } + val accidentSelectableMembers = remember(activeMembers, participants) { + // Prefer currently listed participants; fallback to active members. + val participantIds = participants.map { it.memberId }.toSet() + val fromParticipants = activeMembers.filter { it.id in participantIds } + if (fromParticipants.isNotEmpty()) fromParticipants else activeMembers + } + + LaunchedEffect(accidentSelectableMembers, newAccidentMemberId) { + if (accidentSelectableMembers.isEmpty()) { + newAccidentMemberId = null + return@LaunchedEffect + } + if (newAccidentMemberId == null || accidentSelectableMembers.none { it.id == newAccidentMemberId }) { + newAccidentMemberId = accidentSelectableMembers.first().id + } + } + val narrowPhone = LocalConfiguration.current.screenWidthDp < MAIN_NAV_RAIL_MIN_WIDTH_DP + ordersForMemberId?.let { memberId -> + val member = activeMembers.firstOrNull { it.id == memberId } + AlertDialog( + onDismissRequest = { + ordersForMemberId = null + ordersList = emptyList() + ordersError = null + }, + title = { Text(member?.fullName() ?: tr("orders", "Bestellungen")) }, + text = { + Column(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) { + when { + ordersLoading -> { + CircularProgressIndicator( + modifier = Modifier.padding(24.dp).align(Alignment.CenterHorizontally), + ) + } + ordersError != null -> Text(ordersError.orEmpty(), color = MaterialTheme.colors.error) + ordersList.isEmpty() -> Text( + tr("orders.noOrdersMember", "Für dieses Mitglied gibt es noch keine Bestellungen."), + ) + else -> { + ordersList.forEach { order -> + Column(modifier = Modifier.fillMaxWidth().padding(6.dp)) { + Text(order.item, fontWeight = FontWeight.SemiBold) + Text(order.status, style = MaterialTheme.typography.caption) + Divider(modifier = Modifier.padding(vertical = 6.dp)) + } + } + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + ordersForMemberId = null + ordersList = emptyList() + }, + ) { Text(tr("common.close", "Schließen")) } + }, + ) + } + + statsForMemberId?.let { memberId -> + val member = activeMembers.firstOrNull { it.id == memberId } + AlertDialog( + onDismissRequest = { + statsForMemberId = null + statsList = emptyList() + statsError = null + }, + title = { Text(member?.fullName() ?: tr("trainingStats.title", "Übungsstatistik")) }, + text = { + Column(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) { + when { + statsLoading -> { + CircularProgressIndicator( + modifier = Modifier.padding(24.dp).align(Alignment.CenterHorizontally), + ) + } + statsError != null -> Text(statsError.orEmpty(), color = MaterialTheme.colors.error) + statsList.isEmpty() -> Text(tr("trainingStats.noData", "Keine Statistikdaten gefunden.")) + else -> { + statsList.forEach { stat -> + Column(modifier = Modifier.fillMaxWidth().padding(6.dp)) { + Text(stat.name ?: stat.code ?: "?", fontWeight = FontWeight.SemiBold) + Text("${stat.count}x", style = MaterialTheme.typography.caption) + Divider(modifier = Modifier.padding(vertical = 6.dp)) + } + } + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + statsForMemberId = null + statsList = emptyList() + }, + ) { Text(tr("common.close", "Schließen")) } + }, + ) + } + Column( modifier = Modifier .fillMaxSize() @@ -2320,75 +2561,89 @@ private fun DiaryDetailScreen( if (showMembersGalleryDialog) { val dialogMembers = activeMembers.filter { m -> - (m.hasImage == true) || (m.imageUrl != null) || (m.primaryImageId != null) || (m.images.isNotEmpty()) + val participantRow = participants.find { it.memberId == m.id } + val isExcused = participantRow?.attendanceStatus?.lowercase() == "excused" + !isExcused && + ((m.hasImage == true) || (m.imageUrl != null) || (m.primaryImageId != null) || (m.images.isNotEmpty())) } AlertDialog( onDismissRequest = { showMembersGalleryDialog = false }, - title = { Text(tr("members.gallery", "Mitglieder‑Galerie")) }, text = { - Column(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) { + Column(modifier = Modifier.fillMaxWidth()) { + Text(tr("members.gallery", "Mitglieder‑Galerie"), style = MaterialTheme.typography.h6) + Spacer(modifier = Modifier.height(8.dp)) val gridState = rememberLazyGridState() - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 120.dp), - state = gridState, - modifier = Modifier.fillMaxWidth().padding(4.dp), + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 240.dp, max = 420.dp) + .clipToBounds(), ) { - 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, - ) + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 120.dp), + state = gridState, + modifier = Modifier + .fillMaxSize() + .padding(4.dp), + ) { + items(dialogMembers) { m -> + val pRow = participants.find { it.memberId == m.id } + val checked = pRow?.isPresentParticipant() == true 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) { + .padding(4.dp) + .aspectRatio(1f) + .clipToBounds() + .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) { + } } } - }, - enabled = canWriteDiary, - modifier = Modifier.align(Alignment.TopEnd).padding(6.dp) - ) + ) { + 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) + ) + } } } } @@ -2431,60 +2686,52 @@ private fun DiaryDetailScreen( ) { Tab( selected = activeDiaryTab == DiaryDetailTab.Plan, - onClick = { - activeDiaryTab = DiaryDetailTab.Plan - yTrainingPlan?.let { target -> scope.launch { scroll.animateScrollTo(target.coerceAtLeast(0)) } } - }, + onClick = { activeDiaryTab = DiaryDetailTab.Plan }, text = { Text("${tr("diary.trainingPlan", "Trainingsplan")} (${planItems.size})") }, ) Tab( selected = activeDiaryTab == DiaryDetailTab.Participants, - onClick = { - activeDiaryTab = DiaryDetailTab.Participants - yParticipants?.let { target -> scope.launch { scroll.animateScrollTo(target.coerceAtLeast(0)) } } + onClick = { activeDiaryTab = DiaryDetailTab.Participants }, + text = { + Text( + "${tr("diary.participants", "Teilnehmer")} (${participants.count { it.isPresentParticipant() }})", + ) }, - text = { Text("${tr("diary.participants", "Teilnehmer")} (${participants.size})") }, ) Tab( selected = activeDiaryTab == DiaryDetailTab.Activities, - onClick = { - activeDiaryTab = DiaryDetailTab.Activities - yDayActivities?.let { target -> scope.launch { scroll.animateScrollTo(target.coerceAtLeast(0)) } } - }, + onClick = { activeDiaryTab = DiaryDetailTab.Activities }, text = { Text("${tr("diary.activities", "Aktivitäten")} (${freeformActivities.size})") }, ) } - OutlinedButton( - onClick = { participantsSectionExpanded = !participantsSectionExpanded }, + if (activeDiaryTab == DiaryDetailTab.Participants) { + Box( modifier = Modifier .fillMaxWidth() .onGloballyPositioned { coords -> yParticipants = coords.positionInParent().y.toInt() } - .padding(top = 4.dp) - .heightIn(min = TouchMinHeight), + .padding(top = 4.dp, bottom = 4.dp), ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text(tr("diary.participants", "Trainingsteilnehmer"), style = MaterialTheme.typography.subtitle1) - if (!participantsSectionExpanded) { - Text( - tr("diary.participantsCollapsedHint", "Zum Bearbeiten aufklappen."), - style = MaterialTheme.typography.caption, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.72f), - ) + SectionTitle(tr("diary.participants", "Trainingsteilnehmer")) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 6.dp), + horizontalArrangement = Arrangement.End, + ) { + OutlinedButton( + onClick = { + yAccidents?.let { target -> + scope.launch { scroll.scrollTo((target - 12).coerceAtLeast(0)) } } - } - Icon( - imageVector = if (participantsSectionExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, - contentDescription = null, - ) + }, + enabled = canWriteDiary, + modifier = Modifier.heightIn(min = 36.dp), + ) { + Text(tr("diary.addAccidentQuick", "Unfall eintragen"), style = MaterialTheme.typography.caption) } } - if (participantsSectionExpanded) { if (!participantsLoading && participantsError == null && filteredMembersForToolbar.isEmpty()) { EmptyText(tr("mobile.noMembers", "Keine Mitglieder gefunden")) } @@ -2497,8 +2744,9 @@ private fun DiaryDetailScreen( "cancelled" -> tr("mobile.participantCancelled", "Abgesagt") else -> null } + val actionEnabled = !participantMutating && !diaryState.isLoading val toggleThisMember: () -> Unit = t@{ - if (!canWriteDiary || participantMutating || diaryState.isLoading) return@t + if (!canWriteDiary || !actionEnabled) return@t val wantAdd = !checked participantMutating = true dependencies.applicationScope.launch { @@ -2514,13 +2762,114 @@ private fun DiaryDetailScreen( } } } + val setStatus: (String) -> Unit = status@{ targetStatus -> + if (!canWriteDiary || !actionEnabled) return@status + participantMutating = true + dependencies.applicationScope.launch { + try { + if (statusKey == targetStatus) { + dependencies.diaryManager.addTrainingParticipant(entry.id, member.id) + } else { + if (pRow == null) { + dependencies.diaryManager.addTrainingParticipant(entry.id, member.id) + } + dependencies.diaryManager.setTrainingParticipantAttendanceStatus( + entry.id, + member.id, + targetStatus, + ) + } + participants = dependencies.diaryManager.listTrainingParticipants(entry.id) + } finally { + participantMutating = false + } + } + } + val openOrders: () -> Unit = { + ordersForMemberId = member.id + ordersLoading = true + ordersError = null + dependencies.applicationScope.launch { + try { + val env = dependencies.memberOrdersApi.listForMember(clubId, member.id) + ordersList = env.orders + } catch (t: Throwable) { + ordersError = t.message + ordersList = emptyList() + } finally { + ordersLoading = false + } + } + } + val openStats: () -> Unit = { + statsForMemberId = member.id + statsLoading = true + statsError = null + dependencies.applicationScope.launch { + try { + statsList = dependencies.membersManager.memberActivityStats(clubId, member.id, "year") + } catch (t: Throwable) { + statsError = t.message + statsList = emptyList() + } finally { + statsLoading = false + } + } + } + val openNotes: () -> Unit = { + memberNotesSheetMember = member + newMemberContextNote = "" + newMemberContextTagName = "" + memberTagPickMenu = false + } + val uploadActivityImage: () -> Unit = { + pendingMemberPortraitId = member.id + pickMemberPortrait.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + } + val actionButtons: @Composable () -> Unit = { + val primaryColor = MaterialTheme.colors.primary + Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { + IconButton( + onClick = { setStatus("excused") }, + enabled = canWriteDiary && actionEnabled, + modifier = Modifier.size(40.dp), + ) { + Text( + "📴", + color = if (statusKey == "excused") primaryColor else Color.Unspecified, + fontSize = 18.sp, + ) + } + IconButton( + onClick = uploadActivityImage, + enabled = canWriteDiary && actionEnabled, + modifier = Modifier.size(40.dp), + ) { + Text("🖼️", fontSize = 18.sp) + } + IconButton( + onClick = openOrders, + enabled = actionEnabled, + modifier = Modifier.size(40.dp), + ) { + Text("📦", fontSize = 18.sp) + } + IconButton( + onClick = openNotes, + enabled = canWriteDiary && actionEnabled, + modifier = Modifier.size(40.dp), + ) { + Text("ℹ️", fontSize = 18.sp) + } + } + } Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp), verticalAlignment = Alignment.Top, ) { - if (canReadMembers && participantsSectionExpanded) { + if (canReadMembers) { val mUrl = dependencies.apiConfig.toAbsoluteUrl(memberProfileImagePath(clubId, member.id)) val auth = dependencies.diaryAuthHeaders() Box( @@ -2538,68 +2887,47 @@ private fun DiaryDetailScreen( } Checkbox( checked = checked, - enabled = canWriteDiary && !participantMutating && !diaryState.isLoading, + enabled = canWriteDiary && actionEnabled, onCheckedChange = { wantChecked -> if (wantChecked == checked) return@Checkbox toggleThisMember() }, ) Column( - modifier = Modifier - .weight(1f) - .clickable(enabled = canWriteDiary && !participantMutating && !diaryState.isLoading) { toggleThisMember() }, + modifier = Modifier.weight(1f), ) { - Text(member.fullName(), fontWeight = FontWeight.SemiBold) - statusLabel?.let { - Text( - it, - style = MaterialTheme.typography.caption, - color = MaterialTheme.colors.primary, - modifier = Modifier.padding(top = 2.dp), - ) + if (narrowPhone) { + Text(member.fullName(), fontWeight = FontWeight.SemiBold) + statusLabel?.let { + Text( + it, + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.primary, + modifier = Modifier.padding(top = 2.dp), + ) + } + actionButtons() + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(member.fullName(), fontWeight = FontWeight.SemiBold) + statusLabel?.let { + Text( + it, + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.primary, + modifier = Modifier.padding(top = 2.dp), + ) + } + } + actionButtons() + } } if (checked) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.padding(top = 4.dp), - ) { - TextButton( - enabled = canWriteDiary && !participantMutating && !diaryState.isLoading, - onClick = { - participantMutating = true - dependencies.applicationScope.launch { - try { - dependencies.diaryManager.setTrainingParticipantAttendanceStatus( - entry.id, - member.id, - "excused", - ) - participants = dependencies.diaryManager.listTrainingParticipants(entry.id) - } finally { - participantMutating = false - } - } - }, - ) { Text(tr("mobile.markExcused", "Entschuldigt")) } - TextButton( - enabled = canWriteDiary && !participantMutating && !diaryState.isLoading, - onClick = { - participantMutating = true - dependencies.applicationScope.launch { - try { - dependencies.diaryManager.setTrainingParticipantAttendanceStatus( - entry.id, - member.id, - "cancelled", - ) - participants = dependencies.diaryManager.listTrainingParticipants(entry.id) - } finally { - participantMutating = false - } - } - }, - ) { Text(tr("mobile.markCancelled", "Abgesagt")) } - } if (planGroups.isNotEmpty()) { val curG = pRow?.groupId val curLabel = curG?.let { gid -> @@ -2650,36 +2978,9 @@ private fun DiaryDetailScreen( } } } - } else if (pRow != null && (statusKey == "excused" || statusKey == "cancelled")) { - TextButton( - enabled = canWriteDiary && !participantMutating && !diaryState.isLoading, - onClick = { - participantMutating = true - dependencies.applicationScope.launch { - try { - dependencies.diaryManager.addTrainingParticipant(entry.id, member.id) - participants = dependencies.diaryManager.listTrainingParticipants(entry.id) - } finally { - participantMutating = false - } - } - }, - modifier = Modifier.padding(top = 4.dp), - ) { Text(tr("mobile.markPresentAgain", "Wieder anwesend")) } } - TextButton( - enabled = canWriteDiary && !participantMutating && !diaryState.isLoading, - onClick = { - memberNotesSheetMember = member - newMemberContextNote = "" - newMemberContextTagName = "" - memberTagPickMenu = false - }, - modifier = Modifier.padding(top = 4.dp), - ) { Text(tr("diary.memberNotesTags", "Notizen & Tags")) } } } - } } Box(modifier = Modifier.onGloballyPositioned { coords -> yAccidents = coords.positionInParent().y.toInt() }) { @@ -2707,18 +3008,18 @@ private fun DiaryDetailScreen( } Box(modifier = Modifier.fillMaxWidth().padding(top = 4.dp)) { val amLabel = newAccidentMemberId?.let { mid -> - activeMembers.find { it.id == mid }?.fullName() + accidentSelectableMembers.find { it.id == mid }?.fullName() } ?: tr("diary.accidentPickMember", "Betroffenes Mitglied") OutlinedButton( onClick = { accidentMemberMenu = true }, - enabled = canWriteDiary && !accidentSubmitBusy, + enabled = canWriteDiary && !accidentSubmitBusy && accidentSelectableMembers.isNotEmpty(), modifier = Modifier.fillMaxWidth(), ) { Text(amLabel) } DropdownMenu( expanded = accidentMemberMenu, onDismissRequest = { accidentMemberMenu = false }, ) { - activeMembers.forEach { m -> + accidentSelectableMembers.forEach { m -> DropdownMenuItem( onClick = { newAccidentMemberId = m.id @@ -2728,6 +3029,14 @@ private fun DiaryDetailScreen( } } } + if (accidentSelectableMembers.isEmpty()) { + Text( + tr("diary.accidentNoMembers", "Keine verfügbaren Mitglieder für den Eintrag."), + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.error, + modifier = Modifier.padding(top = 6.dp), + ) + } OutlinedTextField( value = newAccidentDescription, onValueChange = { newAccidentDescription = it }, @@ -2981,6 +3290,9 @@ private fun DiaryDetailScreen( ) } + } + + if (activeDiaryTab == DiaryDetailTab.Activities) { OutlinedButton( onClick = { gallerySectionExpanded = !gallerySectionExpanded }, modifier = Modifier @@ -3147,6 +3459,9 @@ private fun DiaryDetailScreen( ) { Text(tr("mobile.add", "Hinzufügen")) } } + } + + if (activeDiaryTab == DiaryDetailTab.Plan) { Box(modifier = Modifier.onGloballyPositioned { coords -> yTrainingPlan = coords.positionInParent().y.toInt() }) { SectionTitle(tr("diary.trainingPlan", "Trainingsplan")) } @@ -4268,6 +4583,7 @@ private fun DiaryDetailScreen( }, ) } + } } } } @@ -4455,8 +4771,11 @@ private fun MembersScreen( var selectedAgeTo by rememberSaveable { mutableStateOf("") } var selectedGender by rememberSaveable { mutableStateOf("") } var genderMenuOpen by remember { mutableStateOf(false) } - val clipboard = LocalClipboardManager.current val androidCtx = LocalContext.current + val copyToClipboard: (String) -> Unit = { value -> + val cm = androidCtx.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + cm.setPrimaryClip(ClipData.newPlainText("tt_tagebuch", value)) + } val qualityReq = remember(clubState.clubs, clubId) { clubState.clubs.firstOrNull { it.id == clubId }?.memberDataQualityRequirements ?: MemberDataQualityRequirements() } @@ -5148,7 +5467,7 @@ private fun MembersScreen( horizontalArrangement = Arrangement.spacedBy(6.dp), ) { OutlinedButton( - onClick = { clipboard.setText(AnnotatedString(filteredPhonesPlain.joinToString("; "))) }, + onClick = { copyToClipboard(filteredPhonesPlain.joinToString("; ")) }, enabled = filteredPhonesPlain.isNotEmpty(), modifier = Modifier.heightIn(min = 34.dp), ) { Text(tr("members.copyFilteredPhones", "Telefone kopieren"), style = MaterialTheme.typography.caption) } @@ -5158,13 +5477,13 @@ private fun MembersScreen( val p = formatMemberPhonesLine(m) if (p == MEMBER_CONTACT_EMPTY) null else "${m.fullName()}: $p" }.joinToString("\n") - clipboard.setText(AnnotatedString(block)) + copyToClipboard(block) }, enabled = filteredMembers.any { formatMemberPhonesLine(it) != MEMBER_CONTACT_EMPTY }, modifier = Modifier.heightIn(min = 34.dp), ) { Text(tr("members.copyPhonesWithNames", "Telefone mit Namen"), style = MaterialTheme.typography.caption) } OutlinedButton( - onClick = { clipboard.setText(AnnotatedString(filteredEmailsPlain.joinToString("; "))) }, + onClick = { copyToClipboard(filteredEmailsPlain.joinToString("; ")) }, enabled = filteredEmailsPlain.isNotEmpty(), modifier = Modifier.heightIn(min = 34.dp), ) { Text(tr("members.copyFilteredEmails", "E-Mails kopieren"), style = MaterialTheme.typography.caption) } @@ -5174,14 +5493,14 @@ private fun MembersScreen( val emails = extractEmailAddressesFromMember(m) if (emails.isEmpty()) null else "${m.fullName()}: ${emails.joinToString(", ")}" }.joinToString("\n") - clipboard.setText(AnnotatedString(block)) + copyToClipboard(block) }, enabled = filteredMembers.any { extractEmailAddressesFromMember(it).isNotEmpty() }, modifier = Modifier.heightIn(min = 34.dp), ) { Text(tr("members.copyEmailsWithNames", "E-Mails mit Namen"), style = MaterialTheme.typography.caption) } OutlinedButton( onClick = { - clipboard.setText(AnnotatedString(buildMembersCsvExport(filteredMembers))) + copyToClipboard(buildMembersCsvExport(filteredMembers)) membersActionNote = trStr("members.csvCopied", "CSV in die Zwischenablage kopiert.") }, enabled = filteredMembers.isNotEmpty(), diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/ScheduleScreen.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/ScheduleScreen.kt index 6d9f817c..66b31ad1 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/ScheduleScreen.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/ScheduleScreen.kt @@ -1,6 +1,7 @@ package de.tsschulz.tt_tagebuch.app.ui import android.content.Intent +import android.content.ClipData import android.net.Uri import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -33,7 +34,6 @@ import androidx.compose.material.OutlinedTextField import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -43,9 +43,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import de.tsschulz.tt_tagebuch.app.AppDependencies @@ -59,7 +57,6 @@ import de.tsschulz.tt_tagebuch.shared.api.models.FriendlyResultRowDto import de.tsschulz.tt_tagebuch.shared.api.models.canReadSchedule import de.tsschulz.tt_tagebuch.shared.api.models.canWriteSchedule import de.tsschulz.tt_tagebuch.shared.i18n.MobileStrings -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch private val SchedulePad = 20.dp @@ -75,8 +72,11 @@ internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean val languageCode = LocalLanguageCode.current fun tr(key: String, fb: String) = MobileStrings.get(languageCode, key, fb) val scope = rememberCoroutineScope() - val clipboard = LocalClipboardManager.current val context = LocalContext.current + val copyToClipboard: (String) -> Unit = { value -> + val cm = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + cm.setPrimaryClip(ClipData.newPlainText("tt_tagebuch", value)) + } var teamMenu by remember { mutableStateOf(false) } var otherTeamMenu by remember { mutableStateOf(false) } @@ -108,25 +108,6 @@ internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean } } - DisposableEffect(clubId) { - dependencies.socketService.connect(clubId) - onDispose { dependencies.socketService.disconnect() } - } - - LaunchedEffect(clubId, friendlyOnly) { - dependencies.socketService.events.collectLatest { (event, data) -> - if (event == "schedule:match:updated") { - val matchJson = data.optJSONObject("match") - val isFriendlyEvent = matchJson?.optBoolean("isFriendly", false) == true - if (friendlyOnly && (isFriendlyEvent || matchJson == null)) { - dependencies.scheduleManager.loadFriendlyMatches(clubId) - } else if (!friendlyOnly && !isFriendlyEvent) { - dependencies.scheduleManager.refresh(clubId) - } - } - } - } - if (permissions != null && !permissions.canReadSchedule()) { Column( modifier = Modifier @@ -369,18 +350,18 @@ internal fun ScheduleScreen(dependencies: AppDependencies, friendlyOnly: Boolean Text(tr("schedule.ageClass", "Liga") + ": $it", style = MaterialTheme.typography.caption) } m.code?.takeIf { it.isNotBlank() }?.let { code -> - TextButton(onClick = { clipboard.setText(AnnotatedString(code)) }) { + TextButton(onClick = { copyToClipboard(code) }) { Text(tr("schedule.code", "Code") + ": $code") } } Row { m.homePin?.takeIf { it.isNotBlank() }?.let { pin -> - TextButton(onClick = { clipboard.setText(AnnotatedString(pin)) }) { + TextButton(onClick = { copyToClipboard(pin) }) { Text(tr("schedule.homePin", "Heim-PIN") + ": $pin") } } m.guestPin?.takeIf { it.isNotBlank() }?.let { pin -> - TextButton(onClick = { clipboard.setText(AnnotatedString(pin)) }) { + TextButton(onClick = { copyToClipboard(pin) }) { Text(tr("schedule.guestPin", "Gast-PIN") + ": $pin") } } diff --git a/mobile-app/composeApp/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/MemberDetailScreen.kt b/mobile-app/composeApp/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/MemberDetailScreen.kt index 0ab5dd38..b4d25aa3 100644 --- a/mobile-app/composeApp/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/MemberDetailScreen.kt +++ b/mobile-app/composeApp/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/MemberDetailScreen.kt @@ -5,7 +5,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.* import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.filled.Phone @@ -29,7 +29,7 @@ class MemberDetailScreen(private val member: Member) : Screen { title = { Text("${member.firstName} ${member.lastName}") }, navigationIcon = { IconButton(onClick = { navigator?.pop() }) { - Icon(Icons.Default.ArrowBack, contentDescription = "Back") + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } }, actions = { diff --git a/mobile-app/composeApp/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/MemberEditScreen.kt b/mobile-app/composeApp/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/MemberEditScreen.kt index ee3215ff..710e030d 100644 --- a/mobile-app/composeApp/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/MemberEditScreen.kt +++ b/mobile-app/composeApp/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/MemberEditScreen.kt @@ -6,7 +6,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Delete import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -42,7 +42,7 @@ class MemberEditScreen(private val clubId: Int, private val member: Member? = nu title = { Text(if (member == null) "Neues Mitglied" else "Mitglied bearbeiten") }, navigationIcon = { IconButton(onClick = { navigator?.pop() }) { - Icon(Icons.Default.ArrowBack, contentDescription = "Back") + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } }, actions = { diff --git a/mobile-app/composeApp/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/ParticipantScreen.kt b/mobile-app/composeApp/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/ParticipantScreen.kt index be041eb6..15fcc26d 100644 --- a/mobile-app/composeApp/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/ParticipantScreen.kt +++ b/mobile-app/composeApp/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/ParticipantScreen.kt @@ -5,7 +5,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.* import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -34,7 +34,7 @@ class ParticipantScreen(private val dateId: Int, private val dateStr: String) : title = { Text("Teilnehmer - $dateStr") }, navigationIcon = { IconButton(onClick = { navigator?.pop() }) { - Icon(Icons.Default.ArrowBack, contentDescription = "Back") + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } } ) diff --git a/mobile-app/gradle/libs.versions.toml b/mobile-app/gradle/libs.versions.toml index e59819eb..a259b183 100644 --- a/mobile-app/gradle/libs.versions.toml +++ b/mobile-app/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] # composeApp (Play Store / „Über die App“-Build) -appVersionCode = "13" -appVersionName = "1.4.3" +appVersionCode = "14" +appVersionName = "1.5.0" agp = "9.2.1" android-compileSdk = "35" android-minSdk = "24" diff --git a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/SocketService.kt b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/SocketService.kt index 0ba17b15..22072c57 100644 --- a/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/SocketService.kt +++ b/mobile-app/shared/src/commonMain/kotlin/de/tsschulz/tt_tagebuch/shared/api/SocketService.kt @@ -8,45 +8,90 @@ import org.json.JSONObject class SocketService(private val socketUrl: String) { private var socket: Socket? = null + private var currentClubId: Int? = null - private val _events = MutableSharedFlow>() + private val _events = MutableSharedFlow>(extraBufferCapacity = 64) val events = _events.asSharedFlow() fun connect(clubId: Int) { + val existing = socket + if (existing != null) { + val previousClubId = currentClubId + if (previousClubId != null && previousClubId != clubId) { + existing.emit("leave-club", previousClubId) + } + currentClubId = clubId + if (existing.connected()) { + existing.emit("join-club", clubId) + } + return + } + val options = IO.Options().apply { path = "/socket.io/" transports = arrayOf("polling", "websocket") + reconnection = true + reconnectionAttempts = Int.MAX_VALUE + reconnectionDelay = 1_000 + reconnectionDelayMax = 5_000 + timeout = 20_000 + forceNew = false } - socket = IO.socket(socketUrl, options) + currentClubId = clubId + socket = IO.socket(normalizedSocketUrl(), options) socket?.on(Socket.EVENT_CONNECT) { println("✅ Connected to Socket.IO") - socket?.emit("join-club", clubId) - } - - socket?.on("participant:added") { args -> - val data = args[0] as JSONObject - _events.tryEmit("participant:added" to data) - } - - socket?.on("diary:note:added") { args -> - val data = args[0] as JSONObject - _events.tryEmit("diary:note:added" to data) + currentClubId?.let { socket?.emit("join-club", it) } } - socket?.on("schedule:match:updated") { args -> - val data = args[0] as JSONObject - _events.tryEmit("schedule:match:updated" to data) + socket?.on(Socket.EVENT_DISCONNECT) { + println("❌ Socket.IO disconnected") } - // Add more events as needed - + realtimeEvents.forEach { eventName -> + socket?.on(eventName) { args -> + val data = args.firstOrNull() as? JSONObject ?: JSONObject() + _events.tryEmit(eventName to data) + } + } + socket?.connect() } fun disconnect() { + currentClubId?.let { socket?.emit("leave-club", it) } socket?.disconnect() + socket?.off() socket = null + currentClubId = null + } + + private fun normalizedSocketUrl(): String = + socketUrl + .replaceFirst("wss://", "https://") + .replaceFirst("ws://", "http://") + + private companion object { + val realtimeEvents = listOf( + "participant:added", + "participant:removed", + "participant:updated", + "diary:note:added", + "diary:note:updated", + "diary:note:deleted", + "diary:tag:added", + "diary:tag:removed", + "diary:date:updated", + "activity:member:added", + "activity:member:removed", + "activity:changed", + "member:changed", + "group:changed", + "tournament:changed", + "schedule:match:updated", + "schedule:match-report:submitted", + ) } }