feat: Enhance socket service for club management and event handling
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 46s
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:
@@ -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", "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(),
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user