feat: Enhance socket service for club management and event handling
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 46s

- 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.
This commit is contained in:
Torsten Schulz (local)
2026-05-20 11:36:00 +02:00
parent 1c5457ae8c
commit 6aa544a1de
7 changed files with 586 additions and 241 deletions

View File

@@ -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<String?>(null) }
var participantMutating by remember { mutableStateOf(false) }
var participantGroupMenuMemberId by remember { mutableStateOf<Int?>(null) }
var participantsSectionExpanded by rememberSaveable { mutableStateOf(false) }
var accidents by remember { mutableStateOf<List<AccidentReportDto>>(emptyList()) }
var accidentSectionError by remember { mutableStateOf<String?>(null) }
var newAccidentMemberId by rememberSaveable { mutableStateOf<Int?>(null) }
@@ -1989,6 +2052,34 @@ private fun DiaryDetailScreen(
}
}
// Member portrait picker (used as quick "Aktivitätsbild" upload)
var pendingMemberPortraitId by remember { mutableStateOf<Int?>(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<Int?>(null) }
var ordersList by remember { mutableStateOf<List<de.tsschulz.tt_tagebuch.shared.api.models.MemberOrderDto>>(emptyList()) }
var ordersLoading by remember { mutableStateOf(false) }
var ordersError by remember { mutableStateOf<String?>(null) }
var statsForMemberId by remember { mutableStateOf<Int?>(null) }
var statsList by remember { mutableStateOf<List<MemberActivityStatDto>>(emptyList()) }
var statsLoading by remember { mutableStateOf(false) }
var statsError by remember { mutableStateOf<String?>(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", "MitgliederGalerie")) },
text = {
Column(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(tr("members.gallery", "MitgliederGalerie"), 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(),

View File

@@ -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")
}
}

View File

@@ -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 = {

View File

@@ -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 = {

View File

@@ -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")
}
}
)

View File

@@ -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"

View File

@@ -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<Pair<String, JSONObject>>()
private val _events = MutableSharedFlow<Pair<String, JSONObject>>(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",
)
}
}