diff --git a/backend/models/index.js b/backend/models/index.js index ade07375..72411639 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -357,6 +357,21 @@ MemberContact.belongsTo(Member, { foreignKey: 'memberId', as: 'member' }); Member.hasMany(MemberImage, { foreignKey: 'memberId', as: 'images' }); MemberImage.belongsTo(Member, { foreignKey: 'memberId', as: 'member' }); +MemberOrder.belongsTo(Member, { foreignKey: 'memberId', as: 'member' }); +Member.hasMany(MemberOrder, { foreignKey: 'memberId', as: 'orders' }); + +MemberOrder.belongsTo(Club, { foreignKey: 'clubId', as: 'club' }); +Club.hasMany(MemberOrder, { foreignKey: 'clubId', as: 'memberOrders' }); + +MemberOrder.hasMany(MemberOrderHistory, { foreignKey: 'memberOrderId', as: 'historyEntries' }); +MemberOrderHistory.belongsTo(MemberOrder, { foreignKey: 'memberOrderId', as: 'order' }); + +MemberOrderHistory.belongsTo(Member, { foreignKey: 'memberId', as: 'member' }); +Member.hasMany(MemberOrderHistory, { foreignKey: 'memberId', as: 'orderHistoryEntries' }); + +MemberOrderHistory.belongsTo(Club, { foreignKey: 'clubId', as: 'club' }); +Club.hasMany(MemberOrderHistory, { foreignKey: 'clubId', as: 'memberOrderHistoryEntries' }); + // Billing BillingTemplate.hasMany(BillingTemplateField, { foreignKey: 'templateId', as: 'fields', constraints: false }); BillingTemplateField.belongsTo(BillingTemplate, { foreignKey: 'templateId', as: 'template', constraints: false }); diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/calendar/CalendarAggregator.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/calendar/CalendarAggregator.kt index aae9d10d..97052dcf 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/calendar/CalendarAggregator.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/calendar/CalendarAggregator.kt @@ -24,6 +24,7 @@ enum class CalendarEventType { Tournament, OfficialTournament, Match, + FriendlyMatch, Holiday, SchoolHoliday, TrainingCancellation, @@ -231,6 +232,24 @@ object CalendarAggregator { ) } + fun fromFriendlyMatches(matches: List): List = + matches.mapNotNull { m -> + val d = parseIso(m.date) ?: return@mapNotNull null + val home = m.homeTeam?.name?.ifBlank { null } ?: "Heim" + val guest = m.guestTeam?.name?.ifBlank { null } ?: "Gast" + CalendarUiEvent( + id = "friendly-match-${m.friendlyMatchId ?: m.id}", + type = CalendarEventType.FriendlyMatch, + date = d, + timeLabel = formatTime(m.time), + title = "$home – $guest", + subtitle = "Freundschaftsspiel", + startsAtSort = combineSortKey(d, m.time), + matchId = m.id, + action = CalendarEventAction.OpenSchedule, + ) + } + fun fromOfficialParticipations(buckets: List): List = buckets.mapNotNull { t -> if (t.entries.isEmpty()) return@mapNotNull null @@ -353,6 +372,7 @@ object CalendarAggregator { cancellations: List, tournaments: List, matches: List, + friendlyMatches: List, official: List, holidays: ClubCalendarHolidaysEnvelope, ): List { @@ -362,6 +382,7 @@ object CalendarAggregator { raw.addAll(fromCancellations(cancellations)) raw.addAll(fromTournaments(tournaments)) raw.addAll(fromMatches(matches)) + raw.addAll(fromFriendlyMatches(friendlyMatches)) raw.addAll(fromOfficialParticipations(official)) raw.addAll(fromHolidays(holidays)) val afterCancel = applyTrainingCancellationsToRecurring(raw) 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 c3984626..c7b1ffb4 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 @@ -167,6 +167,7 @@ import java.io.File import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import android.util.Log /** Ab dieser Fensterbreite (dp): seitliche Navigation wie auf Tablet/Web. */ private const val MAIN_NAV_RAIL_MIN_WIDTH_DP = 600 @@ -415,12 +416,18 @@ private fun MainTabs( fun selectMainTab(tab: MainTab) { if (tab != selectedTab) { + Log.d("DiaryListDebug", "selectMainTab: clearing diarySelectedEntryId (was=$diarySelectedEntryId)") diarySelectedEntryId = null membersNestedOpen = false } selectedTab = tab } + fun setDiarySelectedEntryId(id: Int?) { + Log.d("DiaryListDebug", "setDiarySelectedEntryId: -> $id (previous=$diarySelectedEntryId)") + diarySelectedEntryId = id + } + /** Wenn jemand die Mitglieder‑Galerie anfordert, wechsle zu Members und öffne die Nested‑Ansicht. */ LaunchedEffect(openMemberGalleryRequested) { if (openMemberGalleryRequested) { @@ -472,7 +479,7 @@ private fun MainTabs( dependencies = dependencies, onNavigateTab = { selectMainTab(it) }, diarySelectedEntryId = diarySelectedEntryId, - onDiarySelectedEntryId = { diarySelectedEntryId = it }, + onDiarySelectedEntryId = { setDiarySelectedEntryId(it) }, onMembersNestedOpenChange = { membersNestedOpen = it }, onOpenMemberPortraitCrop = onRequestOpenMemberPortraitCrop, onOpenMembersGallery = onRequestOpenMemberGallery, @@ -497,7 +504,7 @@ private fun MainTabs( dependencies = dependencies, onNavigateTab = { selectMainTab(it) }, diarySelectedEntryId = diarySelectedEntryId, - onDiarySelectedEntryId = { diarySelectedEntryId = it }, + onDiarySelectedEntryId = { setDiarySelectedEntryId(it) }, onMembersNestedOpenChange = { membersNestedOpen = it }, onOpenMemberPortraitCrop = onRequestOpenMemberPortraitCrop, onOpenMembersGallery = onRequestOpenMemberGallery, @@ -585,11 +592,15 @@ private fun MainTabContent( MainTab.Calendar -> CalendarScreen( dependencies = dependencies, onOpenDiaryDate = { id -> - onDiarySelectedEntryId(id) + // Wichtig: zuerst zum Tagebuch wechseln, danach die Auswahl setzen. + // Wenn die Auswahl zuerst gesetzt wird, wird sie in selectMainTab() + // zurückgesetzt, weil dort die Auswahl beim Tabwechsel gelöscht wird. onNavigateTab(MainTab.Diary) + onDiarySelectedEntryId(id) }, onOpenDiaryTab = { onNavigateTab(MainTab.Diary) }, onOpenSchedule = { onNavigateTab(MainTab.Schedule) }, + onOpenFriendlyMatches = { onNavigateTab(MainTab.FriendlyMatches) }, onOpenTournaments = { onNavigateTab(MainTab.Tournaments) }, onOpenOfficialParticipations = { onNavigateTab(MainTab.OfficialParticipations) }, ) @@ -1624,12 +1635,38 @@ private fun DiaryListScreen( } LaunchedEffect(clubId) { - onSelectedEntryId(null) + // Lade die Tagebuch-Daten. Früher wurde hier die Auswahl zurückgesetzt + // (onSelectedEntryId(null)), was dazu führte, dass eine vom Kalender + // vorgewählte `diarySelectedEntryId` nicht sofort gerendert wurde. dependencies.diaryManager.loadDates(clubId) } + LaunchedEffect(diaryState.dates, selectedEntryId) { + try { + Log.d("DiaryListDebug", "LaunchedEffect: selectedEntryId=$selectedEntryId dates=${diaryState.dates.map { it.id }}") + if (selectedEntryId != null) { + val found = diaryState.dates.any { it.id == selectedEntryId } + Log.d("DiaryListDebug", "selectedEntryId present in dates? $found") + if (found) { + // Force parent/state sync in case order of events produced a stale state. + onSelectedEntryId(selectedEntryId) + } + } else { + // No selection set — if we have dates, default to the first one so details show immediately. + if (diaryState.dates.isNotEmpty()) { + val firstId = diaryState.dates.first().id + Log.d("DiaryListDebug", "No selectedEntryId - defaulting to first date id=$firstId") + onSelectedEntryId(firstId) + } + } + } catch (t: Throwable) { + Log.d("DiaryListDebug", "error in debug effect: ${t.message}") + } + } + val selectedEntry = diaryState.dates.firstOrNull { it.id == selectedEntryId } if (selectedEntry != null) { + Log.d("DiaryListDebug", "selectedEntry found -> id=${selectedEntry.id} date=${selectedEntry.date}") DiaryDetailScreen( clubId = clubId, entry = selectedEntry, @@ -2129,7 +2166,8 @@ private fun DiaryDetailScreen( val accList = async { runCatching { dependencies.diaryManager.listAccidents(clubId, entry.id) } } val photos = async { runCatching { dependencies.diaryManager.listMemberGroupPhotos(clubId) }.getOrElse { emptyList() } } planItems = activities.await() - planGroups = groups.await() + val loadedGroups = groups.await() + planGroups = loadedGroups participants = parts.await() freeformActivities = freeform.await() groupPhotos = photos.await() @@ -2219,7 +2257,8 @@ private fun DiaryDetailScreen( val accList = async { runCatching { dependencies.diaryManager.listAccidents(clubId, entry.id) } } val photos = async { runCatching { dependencies.diaryManager.listMemberGroupPhotos(clubId) }.getOrElse { emptyList() } } planItems = activities.await() - planGroups = groups.await() + val refreshedGroups = groups.await() + planGroups = refreshedGroups participants = parts.await() freeformActivities = freeform.await() groupPhotos = photos.await() @@ -3991,7 +4030,8 @@ private fun DiaryDetailScreen( newPlanGroupActivityGroupId = null showAddPlanGroupActivity = false planItems = dependencies.diaryManager.fetchDateActivities(clubId, entry.id) - planGroups = dependencies.diaryManager.listTrainingGroups(clubId, entry.id) + val updatedGroups = dependencies.diaryManager.listTrainingGroups(clubId, entry.id) + planGroups = updatedGroups } catch (t: Throwable) { planActionError = t.message } finally { @@ -4157,145 +4197,156 @@ private fun DiaryDetailScreen( Spacer(modifier = Modifier.width(26.dp)) } Divider() - sortedPlan.forEach { item -> - val cfg = dependencies.apiConfig - val mainImg = item.mainActivityImagePath()?.let { cfg.toAbsoluteUrl(it) } - val nestedImageUrls = item.groupActivities.mapNotNull { ga -> - val id = ga.id ?: return@mapNotNull null - ga.nestedActivityImagePath()?.let { id to cfg.toAbsoluteUrl(it) } - }.toMap() - DiaryPlanEditableCard( - item = item, - allPlanItems = planItems, - scheduledStart = planStartTimes[item.id], + val showGroupedPlanTable = planGroupFilter == null && planGroups.isNotEmpty() + if (showGroupedPlanTable) { + DiaryGroupedPlanTable( + sortedPlan = sortedPlan, planGroups = planGroups, - planMutating = planMutating, - canWriteDiary = canWriteDiary, - mainImageUrl = mainImg, - nestedImageUrls = nestedImageUrls, - canReadImages = canReadDiary, - visibleGroupId = planGroupFilter, - isExpanded = expandedPlanActionsItemId == item.id, - onToggleExpand = { - expandedPlanActionsItemId = if (expandedPlanActionsItemId == item.id) null else item.id - }, - onAssignParticipants = { - assigningParticipantsItem = item - assignParticipantsBusy = true - assignParticipantsError = null - dependencies.applicationScope.launch { - try { - val links = dependencies.diaryManager.listMemberActivityLinks(clubId, item.id) - assigningParticipantIds = links.map { it.participantId }.toSet() - } catch (t: Throwable) { - assignParticipantsError = t.message - assigningParticipantIds = emptySet() - } finally { - assignParticipantsBusy = false - } - } - }, - onAssignGroup = { - assignPlanGroupId = item.groupId - assigningPlanItem = item - }, - onOpenImage = { url -> planImageViewerUrl = url }, - onViewDrawing = { raw, title -> drawingViewer = raw to title }, - onOpenDrawing = { drawingPlanItem = item }, - onOpenNestedDrawing = { drawingNestedPlanItem = it }, - onViewNestedDrawing = { raw, title -> drawingViewer = raw to title }, - onEdit = { editingPlanItem = item }, - onDelete = { - dependencies.applicationScope.launch { - planMutating = true - planActionError = null - try { - dependencies.diaryManager.deletePlanActivity(clubId, item.id) - planItems = dependencies.diaryManager.fetchDateActivities(clubId, entry.id) - planGroups = dependencies.diaryManager.listTrainingGroups(clubId, entry.id) - } catch (t: Throwable) { - planActionError = t.message - } finally { - planMutating = false - } - } - }, - onMoveUp = { - val scope = sameTrainingPlanScope(planItems, item) - val idx = scope.indexOfFirst { it.id == item.id } - if (idx > 0) { - val targetOrder = scope[idx - 1].orderId - dependencies.applicationScope.launch { - planMutating = true - planActionError = null - try { - dependencies.diaryManager.updatePlanActivityOrder(clubId, item.id, targetOrder) - planItems = dependencies.diaryManager.fetchDateActivities(clubId, entry.id) - } catch (t: Throwable) { - planActionError = t.message - } finally { - planMutating = false - } - } - } - }, - onMoveDown = { - val scope = sameTrainingPlanScope(planItems, item) - val idx = scope.indexOfFirst { it.id == item.id } - if (idx >= 0 && idx < scope.lastIndex) { - val targetOrder = scope[idx + 1].orderId - dependencies.applicationScope.launch { - planMutating = true - planActionError = null - try { - dependencies.diaryManager.updatePlanActivityOrder(clubId, item.id, targetOrder) - planItems = dependencies.diaryManager.fetchDateActivities(clubId, entry.id) - } catch (t: Throwable) { - planActionError = t.message - } finally { - planMutating = false - } - } - } - }, - onDeleteNested = { nestedId -> - dependencies.applicationScope.launch { - planMutating = true - planActionError = null - try { - dependencies.diaryManager.deletePlanNestedGroupActivity(clubId, nestedId) - planItems = dependencies.diaryManager.fetchDateActivities(clubId, entry.id) - } catch (t: Throwable) { - planActionError = t.message - } finally { - planMutating = false - } - } - }, - onEditNested = { nested -> - editingNestedPlanItem = nested - }, - onAssignNested = { nested -> - val nestedId = nested.id - if (nestedId != null) { - assigningParticipantsItem = DiaryDateActivityItem( - id = nestedId, - predefinedActivity = nested.groupPredefinedActivity, - ) + planStartTimes = planStartTimes, + ) + } + if (!showGroupedPlanTable) { + sortedPlan.forEach { item -> + val cfg = dependencies.apiConfig + val mainImg = item.mainActivityImagePath()?.let { cfg.toAbsoluteUrl(it) } + val nestedImageUrls = item.groupActivities.mapNotNull { ga -> + val id = ga.id ?: return@mapNotNull null + ga.nestedActivityImagePath()?.let { id to cfg.toAbsoluteUrl(it) } + }.toMap() + DiaryPlanEditableCard( + item = item, + allPlanItems = planItems, + scheduledStart = planStartTimes[item.id], + planGroups = planGroups, + planMutating = planMutating, + canWriteDiary = canWriteDiary, + mainImageUrl = mainImg, + nestedImageUrls = nestedImageUrls, + canReadImages = canReadDiary, + visibleGroupId = planGroupFilter, + isExpanded = expandedPlanActionsItemId == item.id, + onToggleExpand = { + expandedPlanActionsItemId = if (expandedPlanActionsItemId == item.id) null else item.id + }, + onAssignParticipants = { + assigningParticipantsItem = item assignParticipantsBusy = true + assignParticipantsError = null dependencies.applicationScope.launch { try { - assigningParticipantIds = dependencies.diaryManager - .listMemberActivityLinks(clubId, nestedId) - .map { it.participantId } - .toSet() + val links = dependencies.diaryManager.listMemberActivityLinks(clubId, item.id) + assigningParticipantIds = links.map { it.participantId }.toSet() + } catch (t: Throwable) { + assignParticipantsError = t.message + assigningParticipantIds = emptySet() } finally { assignParticipantsBusy = false } } - } - }, - ) + }, + onAssignGroup = { + assignPlanGroupId = item.groupId + assigningPlanItem = item + }, + onOpenImage = { url -> planImageViewerUrl = url }, + onViewDrawing = { raw, title -> drawingViewer = raw to title }, + onOpenDrawing = { drawingPlanItem = item }, + onOpenNestedDrawing = { drawingNestedPlanItem = it }, + onViewNestedDrawing = { raw, title -> drawingViewer = raw to title }, + onEdit = { editingPlanItem = item }, + onDelete = { + dependencies.applicationScope.launch { + planMutating = true + planActionError = null + try { + dependencies.diaryManager.deletePlanActivity(clubId, item.id) + planItems = dependencies.diaryManager.fetchDateActivities(clubId, entry.id) + val updatedGroups = dependencies.diaryManager.listTrainingGroups(clubId, entry.id) + planGroups = updatedGroups + } catch (t: Throwable) { + planActionError = t.message + } finally { + planMutating = false + } + } + }, + onMoveUp = { + val scope = sameTrainingPlanScope(planItems, item) + val idx = scope.indexOfFirst { it.id == item.id } + if (idx > 0) { + val targetOrder = scope[idx - 1].orderId + dependencies.applicationScope.launch { + planMutating = true + planActionError = null + try { + dependencies.diaryManager.updatePlanActivityOrder(clubId, item.id, targetOrder) + planItems = dependencies.diaryManager.fetchDateActivities(clubId, entry.id) + } catch (t: Throwable) { + planActionError = t.message + } finally { + planMutating = false + } + } + } + }, + onMoveDown = { + val scope = sameTrainingPlanScope(planItems, item) + val idx = scope.indexOfFirst { it.id == item.id } + if (idx >= 0 && idx < scope.lastIndex) { + val targetOrder = scope[idx + 1].orderId + dependencies.applicationScope.launch { + planMutating = true + planActionError = null + try { + dependencies.diaryManager.updatePlanActivityOrder(clubId, item.id, targetOrder) + planItems = dependencies.diaryManager.fetchDateActivities(clubId, entry.id) + } catch (t: Throwable) { + planActionError = t.message + } finally { + planMutating = false + } + } + } + }, + onDeleteNested = { nestedId -> + dependencies.applicationScope.launch { + planMutating = true + planActionError = null + try { + dependencies.diaryManager.deletePlanNestedGroupActivity(clubId, nestedId) + planItems = dependencies.diaryManager.fetchDateActivities(clubId, entry.id) + } catch (t: Throwable) { + planActionError = t.message + } finally { + planMutating = false + } + } + }, + onEditNested = { nested -> + editingNestedPlanItem = nested + }, + onAssignNested = { nested -> + val nestedId = nested.id + if (nestedId != null) { + assigningParticipantsItem = DiaryDateActivityItem( + id = nestedId, + predefinedActivity = nested.groupPredefinedActivity, + ) + assignParticipantsBusy = true + dependencies.applicationScope.launch { + try { + assigningParticipantIds = dependencies.diaryManager + .listMemberActivityLinks(clubId, nestedId) + .map { it.participantId } + .toSet() + } finally { + assignParticipantsBusy = false + } + } + } + }, + ) + } } } } @@ -5898,16 +5949,99 @@ private fun MemberDetailRoute( } Spacer(modifier = Modifier.height(8.dp)) } - val avatarUrl = memberProfileImagePath(clubId, member.id)?.let { dependencies.apiConfig.toAbsoluteUrl(it) } - if (avatarUrl != null) { - AuthenticatedAsyncImage( - imageUrl = avatarUrl, - authHeaders = dependencies.diaryAuthHeaders(), - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 220.dp), - contentDescription = member.fullName(), - ) + val memberImageUrls = remember(member.id, member.images, member.primaryImageUrl, member.imageUrl) { + val urls = mutableListOf() + member.primaryImageUrl + ?.takeIf { it.isNotBlank() } + ?.let { urls += dependencies.apiConfig.toAbsoluteUrl(it) } + member.imageUrl + ?.takeIf { it.isNotBlank() } + ?.let { urls += dependencies.apiConfig.toAbsoluteUrl(it) } + member.images.forEach { image -> + image.url + ?.takeIf { it.isNotBlank() } + ?.let { urls += dependencies.apiConfig.toAbsoluteUrl(it) } + } + if (urls.isEmpty()) { + memberProfileImagePath(clubId, member.id) + ?.let { urls += dependencies.apiConfig.toAbsoluteUrl(it) } + } + urls.distinct() + } + val activeMemberImageUrl = remember(member.id, member.images, member.primaryImageUrl, member.imageUrl, memberImageUrls) { + val primary = member.primaryImageUrl + ?.takeIf { it.isNotBlank() } + ?.let { dependencies.apiConfig.toAbsoluteUrl(it) } + val legacy = member.imageUrl + ?.takeIf { it.isNotBlank() } + ?.let { dependencies.apiConfig.toAbsoluteUrl(it) } + primary ?: legacy ?: memberImageUrls.firstOrNull() + } + if (memberImageUrls.isNotEmpty()) { + if (memberImageUrls.size == 1) { + Box(modifier = Modifier.fillMaxWidth()) { + AuthenticatedAsyncImage( + imageUrl = memberImageUrls.first(), + authHeaders = dependencies.diaryAuthHeaders(), + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 220.dp), + contentDescription = member.fullName(), + ) + if (activeMemberImageUrl != null) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + .background(Color(0xCC2E7D32)) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + Text( + tr("members.activeImage", "Aktiv"), + color = Color.White, + style = MaterialTheme.typography.caption, + fontWeight = FontWeight.Bold, + ) + } + } + } + } else { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + memberImageUrls.forEachIndexed { index, url -> + Column(modifier = Modifier.width(220.dp)) { + Box(modifier = Modifier.fillMaxWidth().height(220.dp)) { + AuthenticatedAsyncImage( + imageUrl = url, + authHeaders = dependencies.diaryAuthHeaders(), + modifier = Modifier.fillMaxSize(), + contentDescription = "${member.fullName()} (${index + 1})", + ) + if (url == activeMemberImageUrl) { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(6.dp) + .background(Color(0xCC2E7D32)) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + Text( + tr("members.activeImage", "Aktiv"), + color = Color.White, + style = MaterialTheme.typography.caption, + fontWeight = FontWeight.Bold, + ) + } + } + } + } + } + } + } Spacer(modifier = Modifier.height(12.dp)) } DetailLine(tr("mobile.status", "Status"), if (member.active) tr("mobile.active", "Aktiv") else tr("mobile.inactive", "Inaktiv")) @@ -6389,7 +6523,7 @@ private fun MemberEditRoute( } } }, - enabled = !saving && firstName.isNotBlank() && lastName.isNotBlank(), + enabled = !saving && (seedMember != null || (firstName.trim().isNotBlank() && lastName.trim().isNotBlank())), modifier = Modifier.fillMaxWidth().heightIn(min = TouchMinHeight), ) { Text(tr("common.save", "Speichern")) } } @@ -7076,6 +7210,132 @@ private fun addMinutesToTime(timeHhMmSs: String, minutes: Int): String { return "%02d:%02d:00".format(nh, nm) } +@Composable +private fun DiaryGroupedPlanTable( + sortedPlan: List, + planGroups: List, + planStartTimes: Map, +) { + data class GroupedCellEntry( + val label: String, + val duration: String?, + ) + + data class GroupedPlanRow( + val startTime: String, + val sharedItems: List, + val groupItems: Map>, + ) + + val rows = remember(sortedPlan, planGroups, planStartTimes) { + val byStartTime = linkedMapOf>() + sortedPlan.forEach { item -> + if (item.isTimeblock) return@forEach + val key = planStartTimes[item.id]?.take(5) ?: "" + byStartTime.getOrPut(key) { mutableListOf() }.add(item) + } + + val toMinutes = { value: String -> + val parts = value.split(":") + if (parts.size < 2) Int.MAX_VALUE + else { + val hh = parts[0].toIntOrNull() ?: Int.MAX_VALUE + val mm = parts[1].toIntOrNull() ?: Int.MAX_VALUE + if (hh == Int.MAX_VALUE || mm == Int.MAX_VALUE) Int.MAX_VALUE + else hh * 60 + mm + } + } + + byStartTime + .map { (start, items) -> + val shared = mutableListOf() + val perGroup = mutableMapOf>() + items.forEach { item -> + val entry = GroupedCellEntry( + label = item.displayTitle("Zeitblock"), + duration = item.durationText?.takeIf { it.isNotBlank() } ?: item.duration?.toString(), + ) + val gid = item.groupId + if (gid == null) { + shared.add(entry) + } else { + perGroup.getOrPut(gid) { mutableListOf() }.add(entry) + } + } + GroupedPlanRow( + startTime = start, + sharedItems = shared, + groupItems = perGroup.mapValues { it.value.toList() }, + ) + } + .sortedWith( + compareBy { toMinutes(it.startTime) } + .thenBy { row -> + row.sharedItems.firstOrNull()?.label + ?: row.groupItems.values.firstOrNull()?.firstOrNull()?.label + ?: "" + }, + ) + } + + if (rows.isEmpty()) return + Spacer(modifier = Modifier.height(6.dp)) + + rows.forEach { row -> + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.Top) { + Text(row.startTime, modifier = Modifier.width(DiaryPlanColStart)) + if (row.sharedItems.isNotEmpty()) { + Column(modifier = Modifier.weight(1f)) { + row.sharedItems.forEach { entry -> + Text(entry.label) + } + } + Column(modifier = Modifier.width(DiaryPlanColDuration)) { + row.sharedItems.forEach { entry -> + Text(entry.duration ?: "", style = MaterialTheme.typography.caption) + } + } + } else { + Row(modifier = Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + planGroups.forEach { g -> + val entries = row.groupItems[g.id].orEmpty() + Box(modifier = Modifier.weight(1f)) { + if (entries.isNotEmpty()) { + Column(modifier = Modifier.padding(start = 4.dp)) { + Text( + g.name ?: "Gruppe ${g.id}", + style = MaterialTheme.typography.caption, + fontWeight = FontWeight.Bold, + ) + entries.forEach { entry -> + Text(entry.label) + } + } + } + } + } + } + Row(modifier = Modifier.width(DiaryPlanColDuration), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + planGroups.forEach { g -> + val entries = row.groupItems[g.id].orEmpty() + Box(modifier = Modifier.weight(1f)) { + if (entries.isNotEmpty()) { + Column(modifier = Modifier.padding(start = 4.dp)) { + entries.forEach { entry -> + Text(entry.duration ?: "", style = MaterialTheme.typography.caption) + } + } + } + } + } + } + } + Spacer(modifier = Modifier.width(26.dp)) + } + Divider() + } +} + @Composable private fun DiaryPlanToolbar( clubId: Int, diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/CalendarScreen.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/CalendarScreen.kt index 2c2bb7aa..fe9435c8 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/CalendarScreen.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/CalendarScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember 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.Modifier @@ -111,6 +112,7 @@ fun CalendarScreen( onOpenDiaryDate: (Int) -> Unit, onOpenDiaryTab: () -> Unit, onOpenSchedule: () -> Unit, + onOpenFriendlyMatches: () -> Unit, onOpenTournaments: () -> Unit, onOpenOfficialParticipations: () -> Unit, ) { @@ -120,10 +122,11 @@ fun CalendarScreen( val clubId = clubState.currentClubId ?: return val scope = rememberCoroutineScope() - var calYear by remember { mutableIntStateOf(LocalDate.now().year) } - var calMonth by remember { mutableIntStateOf(LocalDate.now().monthValue) } - val yearMonth = remember(calYear, calMonth) { YearMonth.of(calYear, calMonth) } - val displayedYear = calYear + var visibleMonthIso by rememberSaveable { mutableStateOf(YearMonth.now().toString()) } + val yearMonth = remember(visibleMonthIso) { + runCatching { YearMonth.parse(visibleMonthIso) }.getOrElse { YearMonth.now() } + } + val displayedYear = yearMonth.year var loading by remember { mutableStateOf(false) } var events by remember { mutableStateOf>(emptyList()) } @@ -134,6 +137,8 @@ fun CalendarScreen( } var sourceWarnings by remember { mutableStateOf>(emptyList()) } var loadAllSourcesFailed by remember { mutableStateOf(false) } + var filtersExpanded by rememberSaveable { mutableStateOf(false) } + var cancellationExpanded by rememberSaveable { mutableStateOf(false) } val agendaLocale = remember(languageCode) { localeForApp(languageCode) } var cancelStart by remember { mutableStateOf("") } @@ -141,7 +146,7 @@ fun CalendarScreen( var cancelReason by remember { mutableStateOf("") } var cancelBusy by remember { mutableStateOf(false) } - LaunchedEffect(clubId, displayedYear, calMonth, dataGeneration) { + LaunchedEffect(clubId, yearMonth, dataGeneration) { loading = true loadAllSourcesFailed = false sourceWarnings = emptyList() @@ -152,6 +157,7 @@ fun CalendarScreen( val canR = async { runCatching { dependencies.trainingCancellationApi.list(clubId, displayedYear) } } val tourR = async { runCatching { dependencies.tournamentsApi.listTournaments(clubId) } } val matchR = async { runCatching { dependencies.matchesApi.listMatchesForLeagues(clubId) } } + val friendlyR = async { runCatching { dependencies.matchesApi.listFriendlyMatches(clubId) } } val offR = async { runCatching { dependencies.officialTournamentsReadManager.fetchParticipationSummary(clubId) } } val holR = async { runCatching { dependencies.calendarHolidayApi.getClubHolidays(clubId, displayedYear) } } @@ -160,6 +166,7 @@ fun CalendarScreen( val canr = canR.await() val tourr = tourR.await() val matchr = matchR.await() + val friendlyr = friendlyR.await() val offr = offR.await() val holr = holR.await() @@ -168,10 +175,11 @@ fun CalendarScreen( recordCalendarSourceFailure(canr, tr("mobile.calendarSourceCancellations", "Trainingsausfälle"), warns) recordCalendarSourceFailure(tourr, tr("mobile.calendarSourceTournaments", "Turniere"), warns) recordCalendarSourceFailure(matchr, tr("mobile.calendarSourceMatches", "Punktspiele"), warns) + recordCalendarSourceFailure(friendlyr, tr("mobile.calendarSourceFriendlyMatches", "Freundschaftsspiele"), warns) recordCalendarSourceFailure(offr, tr("mobile.calendarSourceOfficial", "Turnierteilnahmen"), warns) recordCalendarSourceFailure(holr, tr("mobile.calendarSourceHolidays", "Ferien/Feiertage"), warns) - val results = listOf(dr, ttr, canr, tourr, matchr, offr, holr) + val results = listOf(dr, ttr, canr, tourr, matchr, friendlyr, offr, holr) loadAllSourcesFailed = results.all { it.isFailure } && results.any { !isUnauthorizedCalendarSource(it) } @@ -183,6 +191,7 @@ fun CalendarScreen( cancellations = canr.getOrDefault(emptyList()), tournaments = tourr.getOrDefault(emptyList()), matches = matchr.getOrDefault(emptyList()), + friendlyMatches = friendlyr.getOrDefault(emptyList()), official = offr.getOrDefault(emptyList()), holidays = holr.getOrElse { ClubCalendarHolidaysEnvelope() }, ) @@ -206,6 +215,7 @@ fun CalendarScreen( CalendarEventType.Tournament -> tr("mobile.calendarLegendTournament", "Turnier") CalendarEventType.OfficialTournament -> tr("mobile.calendarLegendOfficial", "Teilnahme") CalendarEventType.Match -> tr("mobile.calendarLegendMatch", "Punktspiel") + CalendarEventType.FriendlyMatch -> tr("mobile.calendarLegendFriendlyMatch", "Freundschaftsspiel") CalendarEventType.Holiday -> tr("mobile.calendarLegendHoliday", "Feiertag") CalendarEventType.SchoolHoliday -> tr("mobile.calendarLegendSchool", "Ferien") CalendarEventType.TrainingCancellation -> tr("mobile.calendarLegendCancellation", "Ausfall") @@ -217,6 +227,7 @@ fun CalendarScreen( CalendarEventType.Tournament -> MaterialTheme.colors.secondary CalendarEventType.OfficialTournament -> MaterialTheme.colors.secondaryVariant CalendarEventType.Match -> MaterialTheme.colors.primaryVariant + CalendarEventType.FriendlyMatch -> MaterialTheme.colors.secondary.copy(alpha = 0.9f) CalendarEventType.Holiday -> MaterialTheme.colors.error.copy(alpha = 0.75f) CalendarEventType.SchoolHoliday -> MaterialTheme.colors.error.copy(alpha = 0.45f) CalendarEventType.TrainingCancellation -> MaterialTheme.colors.onSurface.copy(alpha = 0.55f) @@ -226,7 +237,10 @@ fun CalendarScreen( when (e.action) { CalendarEventAction.OpenDiaryDate -> e.diaryDateId?.let { onOpenDiaryDate(it) } CalendarEventAction.OpenDiaryTab -> onOpenDiaryTab() - CalendarEventAction.OpenSchedule -> onOpenSchedule() + CalendarEventAction.OpenSchedule -> { + if (e.type == CalendarEventType.FriendlyMatch) onOpenFriendlyMatches() + else onOpenSchedule() + } CalendarEventAction.OpenTournaments -> onOpenTournaments() CalendarEventAction.OpenOfficialParticipations -> onOpenOfficialParticipations() CalendarEventAction.None -> Unit @@ -252,18 +266,14 @@ fun CalendarScreen( ) { TextButton(onClick = { val p = yearMonth.minusMonths(1) - calYear = p.year - calMonth = p.monthValue + visibleMonthIso = p.toString() }) { Text("‹") } TextButton(onClick = { - val n = LocalDate.now() - calYear = n.year - calMonth = n.monthValue + visibleMonthIso = YearMonth.now().toString() }) { Text(tr("mobile.calendarToday", "Heute")) } TextButton(onClick = { val n = yearMonth.plusMonths(1) - calYear = n.year - calMonth = n.monthValue + visibleMonthIso = n.toString() }) { Text("›") } } val titleRaw = yearMonth.format(monthTitleFmt) @@ -293,110 +303,146 @@ fun CalendarScreen( modifier = Modifier.padding(bottom = 8.dp), ) } - Text(tr("mobile.calendarLegend", "Anzeige"), style = MaterialTheme.typography.caption, fontWeight = FontWeight.Medium) - CalendarEventType.entries.chunked(2).forEach { rowTypes -> - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - rowTypes.forEach { t -> - val count = events.count { it.type == t } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.weight(1f).padding(vertical = 2.dp), - ) { - Switch( - checked = activeTypes[t] != false, - onCheckedChange = { activeTypes[t] = it }, - ) - Text( - "${typeLabel(t)} ($count)", - style = MaterialTheme.typography.caption, - modifier = Modifier.padding(start = 4.dp), - ) + Row( + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(tr("mobile.calendarLegend", "Anzeige"), style = MaterialTheme.typography.caption, fontWeight = FontWeight.Medium) + TextButton(onClick = { filtersExpanded = !filtersExpanded }) { + Text( + if (filtersExpanded) { + tr("mobile.calendarHideFilters", "Filter ausblenden ▲") + } else { + tr("mobile.calendarShowFilters", "Filter anzeigen ▼") + }, + style = MaterialTheme.typography.caption, + ) + } + } + if (filtersExpanded) { + CalendarEventType.entries.chunked(2).forEach { rowTypes -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + rowTypes.forEach { t -> + val count = events.count { it.type == t } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f).padding(vertical = 2.dp), + ) { + Switch( + checked = activeTypes[t] != false, + onCheckedChange = { activeTypes[t] = it }, + ) + Text( + "${typeLabel(t)} ($count)", + style = MaterialTheme.typography.caption, + modifier = Modifier.padding(start = 4.dp), + ) + } } } } } - Card(Modifier.fillMaxWidth().padding(top = 12.dp), elevation = 1.dp) { - Column(Modifier.padding(12.dp)) { - Text(tr("mobile.calendarCancellationTitle", "Training fällt aus"), fontWeight = FontWeight.SemiBold) + Row( + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(tr("mobile.calendarCancellationTitle", "Training fällt aus"), style = MaterialTheme.typography.subtitle2) + TextButton(onClick = { cancellationExpanded = !cancellationExpanded }) { Text( - tr("mobile.calendarCancellationHint", "Blendet regelmäßige Trainingszeiten an den Tagen aus."), - style = MaterialTheme.typography.caption, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f), - ) - OutlinedTextField( - value = cancelStart, - onValueChange = { cancelStart = it }, - label = { Text(tr("mobile.calendarCancellationStart", "Datum (YYYY-MM-DD)")) }, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - singleLine = true, - ) - OutlinedTextField( - value = cancelEnd, - onValueChange = { cancelEnd = it }, - label = { Text(tr("mobile.calendarCancellationEnd", "Bis optional")) }, - modifier = Modifier.fillMaxWidth().padding(top = 4.dp), - singleLine = true, - ) - OutlinedTextField( - value = cancelReason, - onValueChange = { cancelReason = it }, - label = { Text(tr("mobile.calendarCancellationReason", "Grund")) }, - modifier = Modifier.fillMaxWidth().padding(top = 4.dp), - singleLine = true, - ) - Button( - onClick = { - if (cancelStart.length < 10) return@Button - scope.launch { - cancelBusy = true - runCatching { - val end = cancelEnd.trim().ifBlank { cancelStart.trim() } - dependencies.trainingCancellationApi.upsert( - clubId, - TrainingCancellationUpsertBody( - startDate = cancelStart.trim().take(10), - endDate = end.take(10), - reason = cancelReason.trim().ifBlank { null }, - ), - ) - cancelStart = "" - cancelEnd = "" - cancelReason = "" - dataGeneration++ - } - cancelBusy = false - } + if (cancellationExpanded) { + tr("mobile.calendarHideCancellation", "Bereich ausblenden ▲") + } else { + tr("mobile.calendarShowCancellation", "Bereich anzeigen ▼") }, - enabled = !cancelBusy && cancelStart.length >= 10, - modifier = Modifier.fillMaxWidth().padding(top = 8.dp).heightIn(min = 48.dp), - ) { Text(if (cancelBusy) "…" else tr("mobile.calendarCancellationSave", "Eintragen")) } - val monthCancels = events - .filter { it.type == CalendarEventType.TrainingCancellation && it.cancellationId != null } - .filter { it.date.year == calYear && it.date.monthValue == calMonth } - .sortedBy { it.startsAtSort } - if (monthCancels.isNotEmpty()) { - Text(tr("mobile.calendarCancellationInMonth", "Ausfälle diesen Monat"), modifier = Modifier.padding(top = 12.dp), fontWeight = FontWeight.Medium) - monthCancels.forEach { c -> - Row( - Modifier.fillMaxWidth().padding(vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f)) { - Text("${c.date}–${c.endDate}", style = MaterialTheme.typography.caption) - Text(c.title, style = MaterialTheme.typography.body2, maxLines = 1, overflow = TextOverflow.Ellipsis) + style = MaterialTheme.typography.caption, + ) + } + } + if (cancellationExpanded) { + Card(Modifier.fillMaxWidth(), elevation = 1.dp) { + Column(Modifier.padding(12.dp)) { + Text( + tr("mobile.calendarCancellationHint", "Blendet regelmäßige Trainingszeiten an den Tagen aus."), + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f), + ) + OutlinedTextField( + value = cancelStart, + onValueChange = { cancelStart = it }, + label = { Text(tr("mobile.calendarCancellationStart", "Datum (YYYY-MM-DD)")) }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + singleLine = true, + ) + OutlinedTextField( + value = cancelEnd, + onValueChange = { cancelEnd = it }, + label = { Text(tr("mobile.calendarCancellationEnd", "Bis optional")) }, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + singleLine = true, + ) + OutlinedTextField( + value = cancelReason, + onValueChange = { cancelReason = it }, + label = { Text(tr("mobile.calendarCancellationReason", "Grund")) }, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + singleLine = true, + ) + Button( + onClick = { + if (cancelStart.length < 10) return@Button + scope.launch { + cancelBusy = true + runCatching { + val end = cancelEnd.trim().ifBlank { cancelStart.trim() } + dependencies.trainingCancellationApi.upsert( + clubId, + TrainingCancellationUpsertBody( + startDate = cancelStart.trim().take(10), + endDate = end.take(10), + reason = cancelReason.trim().ifBlank { null }, + ), + ) + cancelStart = "" + cancelEnd = "" + cancelReason = "" + dataGeneration++ + } + cancelBusy = false } - TextButton( - onClick = { - val id = c.cancellationId ?: return@TextButton - scope.launch { - runCatching { - dependencies.trainingCancellationApi.delete(clubId, id) - dataGeneration++ + }, + enabled = !cancelBusy && cancelStart.length >= 10, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp).heightIn(min = 48.dp), + ) { Text(if (cancelBusy) "…" else tr("mobile.calendarCancellationSave", "Eintragen")) } + val monthCancels = events + .filter { it.type == CalendarEventType.TrainingCancellation && it.cancellationId != null } + .filter { it.date.year == yearMonth.year && it.date.monthValue == yearMonth.monthValue } + .sortedBy { it.startsAtSort } + if (monthCancels.isNotEmpty()) { + Text(tr("mobile.calendarCancellationInMonth", "Ausfälle diesen Monat"), modifier = Modifier.padding(top = 12.dp), fontWeight = FontWeight.Medium) + monthCancels.forEach { c -> + Row( + Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text("${c.date}–${c.endDate}", style = MaterialTheme.typography.caption) + Text(c.title, style = MaterialTheme.typography.body2, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + TextButton( + onClick = { + val id = c.cancellationId ?: return@TextButton + scope.launch { + runCatching { + dependencies.trainingCancellationApi.delete(clubId, id) + dataGeneration++ + } } - } - }, - ) { Text(tr("common.delete", "Löschen"), color = MaterialTheme.colors.error) } + }, + ) { Text(tr("common.delete", "Löschen"), color = MaterialTheme.colors.error) } + } } } } diff --git a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/DiaryCourtDrawing.kt b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/DiaryCourtDrawing.kt index 70c6f5cc..ded6d4c8 100644 --- a/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/DiaryCourtDrawing.kt +++ b/mobile-app/composeApp/src/androidMain/kotlin/de/tsschulz/tt_tagebuch/app/ui/DiaryCourtDrawing.kt @@ -4,15 +4,19 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.AlertDialog +import androidx.compose.ui.window.Dialog +import androidx.compose.material.Surface import androidx.compose.material.MaterialTheme import androidx.compose.material.OutlinedButton import androidx.compose.material.OutlinedTextField @@ -23,8 +27,16 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.LinearEasing import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import android.util.Log +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color @@ -34,6 +46,7 @@ import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.Alignment import de.tsschulz.tt_tagebuch.shared.api.models.PredefinedActivityDto import de.tsschulz.tt_tagebuch.shared.api.models.PredefinedActivityUpsertBody import kotlinx.serialization.json.Json @@ -126,6 +139,7 @@ internal fun DiaryCourtDrawingDialog( onDismiss: () -> Unit, onSave: (PredefinedActivityUpsertBody) -> Unit, ) { + // remember state var name by remember(initial?.id) { mutableStateOf(initial?.name.orEmpty()) } var code by remember(initial?.id) { mutableStateOf(initial?.code.orEmpty()) } var duration by remember(initial?.id) { mutableStateOf(initial?.duration?.toString().orEmpty()) } @@ -137,86 +151,91 @@ internal fun DiaryCourtDrawingDialog( val cfg = LocalConfiguration.current val maxHeight = (cfg.screenHeightDp.dp * 0.86f) + val dialogWidth = (cfg.screenWidthDp.dp * 0.6f).coerceAtLeast(320.dp) + val safeMaxHeight = maxHeight.coerceAtLeast(240.dp) + + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Surface(modifier = Modifier.widthIn(min = dialogWidth, max = dialogWidth).padding(12.dp)) { + Column(modifier = Modifier.verticalScroll(rememberScrollState()).padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Uebungszeichnung", style = MaterialTheme.typography.h6) - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Uebungszeichnung") }, - text = { - Column( - modifier = Modifier.fillMaxWidth().heightIn(max = maxHeight).verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("Name") }, singleLine = true) OutlinedTextField(value = code, onValueChange = { code = it }, label = { Text("Kuerzel") }, singleLine = true) - DrawingChoiceRow("Start", listOf("AS1", "AS2", "AS3"), drawing.start) { drawing = drawing.copy(start = it) } - DrawingChoiceRow("Schlagseite", listOf("VH", "RH"), drawing.stroke) { drawing = drawing.copy(stroke = it) } - DrawingChoiceRow("Spin", listOf("US", "OS", "SS", "SUS"), drawing.spin) { drawing = drawing.copy(spin = it) } - DrawingChoiceRow("Ziel", (1..9).map(Int::toString), drawing.target) { drawing = drawing.copy(target = it) } - DiaryCourtDrawingPreview(drawing) + + Box(modifier = Modifier.fillMaxWidth().heightIn(min = safeMaxHeight * 2f).onGloballyPositioned { coords -> + Log.d(TAG, "Dialog Box size=${coords.size}") + }) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + DrawingChoiceRow("Start", listOf("AS1", "AS2", "AS3"), drawing.start) { drawing = drawing.copy(start = it) } + DrawingChoiceRow("Schlagseite", listOf("VH", "RH"), drawing.stroke) { drawing = drawing.copy(stroke = it) } + DrawingChoiceRow("Spin", listOf("US", "OS", "SS", "SUS"), drawing.spin) { drawing = drawing.copy(spin = it) } + DrawingChoiceRow("Ziel", (1..9).map(Int::toString), drawing.target) { drawing = drawing.copy(target = it) } + var playKey by remember { mutableStateOf(0) } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { playKey += 1 }) { Text("Animation starten") } + } + DiaryCourtDrawingPreview(drawing, playKey = playKey) + } + } + if (drawing.additionalStrokes.isNotEmpty()) { Text("Folgeschlaege", style = MaterialTheme.typography.caption, fontWeight = FontWeight.SemiBold) drawing.additionalStrokes.forEachIndexed { index, extra -> Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text("${index + 2}. ${extra.side} ${extra.type} -> ${extra.target}") - TextButton(onClick = { - drawing = drawing.copy(additionalStrokes = drawing.additionalStrokes.filterIndexed { itemIndex, _ -> itemIndex != index }) - }) { Text("Entfernen") } + TextButton(onClick = { drawing = drawing.copy(additionalStrokes = drawing.additionalStrokes.filterIndexed { i, _ -> i != index }) }) { Text("Entfernen") } } } } + if (drawing.additionalStrokes.size < 4) { Text("Folgeschlag hinzufuegen", style = MaterialTheme.typography.caption, fontWeight = FontWeight.SemiBold) DrawingChoiceRow("Seite", listOf("VH", "RH"), nextSide) { nextSide = it } DrawingChoiceRow("Schlag", listOf("US", "OS", "SS", "SUS"), nextType) { nextType = it } DrawingChoiceRow("Ziel", (1..9).map(Int::toString), nextTarget) { nextTarget = it } - OutlinedButton( - enabled = nextTarget.isNotBlank(), - onClick = { - drawing = drawing.copy( - additionalStrokes = drawing.additionalStrokes + DiaryCourtStroke(nextSide, nextType, nextTarget), - ) - nextTarget = "" - }, - ) { Text("Folgeschlag uebernehmen") } + OutlinedButton(enabled = nextTarget.isNotBlank(), onClick = { + drawing = drawing.copy(additionalStrokes = drawing.additionalStrokes + DiaryCourtStroke(nextSide, nextType, nextTarget)) + nextTarget = "" + }) { Text("Folgeschlag uebernehmen") } } + Text(drawing.fullCode(), style = MaterialTheme.typography.caption) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { OutlinedTextField( value = durationText, onValueChange = { durationText = it }, label = { Text("Dauer (Text)") }, - modifier = Modifier.weight(1f), + modifier = Modifier.fillMaxWidth(), singleLine = true, ) OutlinedTextField( value = duration, onValueChange = { duration = it.filter(Char::isDigit) }, label = { Text("Min.") }, - modifier = Modifier.weight(0.65f), + modifier = Modifier.fillMaxWidth(0.4f), singleLine = true, ) } + + Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End) { + TextButton(enabled = name.isNotBlank() && drawing.valid, onClick = { + onSave( + PredefinedActivityUpsertBody( + name = name.trim(), + code = code.trim().ifBlank { null }, + duration = duration.toIntOrNull(), + durationText = durationText.trim().ifBlank { null }, + drawingData = drawing.toJson(), + excludeFromStats = initial?.excludeFromStats ?: false, + ), + ) + }) { Text("Speichern") } + TextButton(onClick = onDismiss) { Text("Abbrechen") } + } } - }, - confirmButton = { - TextButton( - enabled = name.isNotBlank() && drawing.valid, - onClick = { - onSave( - PredefinedActivityUpsertBody( - name = name.trim(), - code = code.trim().ifBlank { null }, - duration = duration.toIntOrNull(), - durationText = durationText.trim().ifBlank { null }, - drawingData = drawing.toJson(), - excludeFromStats = initial?.excludeFromStats ?: false, - ), - ) - }, - ) { Text("Speichern") } - }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Abbrechen") } }, - ) + } + } } @Composable @@ -227,21 +246,25 @@ internal fun DiaryCourtDrawingViewerDialog( ) { val cfg = LocalConfiguration.current val maxHeight = (cfg.screenHeightDp.dp * 0.86f) + val dialogWidth = (cfg.screenWidthDp.dp * 0.6f).coerceAtLeast(320.dp) + val safeMaxHeight = maxHeight.coerceAtLeast(240.dp) - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(title) }, - text = { - Column( - modifier = Modifier.fillMaxWidth().heightIn(max = maxHeight).verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - DiaryCourtDrawingPreview(data) + Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Surface(modifier = Modifier.widthIn(min = dialogWidth, max = dialogWidth).padding(12.dp)) { + Column(modifier = Modifier.verticalScroll(rememberScrollState()).padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, style = MaterialTheme.typography.h6) + var playKey by remember { mutableStateOf(0) } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { playKey += 1 }) { Text("Animation starten") } + } + DiaryCourtDrawingPreview(data, playKey = playKey) Text(data.fullCode(), style = MaterialTheme.typography.body2) + Row(modifier = Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onDismiss) { Text("Schliessen") } + } } - }, - confirmButton = { TextButton(onClick = onDismiss) { Text("Schliessen") } }, - ) + } + } } @Composable @@ -262,17 +285,33 @@ private fun DrawingChoiceRow(label: String, values: List, selected: Stri } @Composable -internal fun DiaryCourtDrawingPreview(data: DiaryCourtDrawingData, modifier: Modifier = Modifier) { +internal fun DiaryCourtDrawingPreview(data: DiaryCourtDrawingData, modifier: Modifier = Modifier, playKey: Int = 0) { val cfg = LocalConfiguration.current - val previewHeight = (cfg.screenHeightDp.dp * 0.28f).coerceAtLeast(150.dp) + val dialogWidthDp = (cfg.screenWidthDp.dp * 0.6f).coerceAtLeast(320.dp) + val canvasPadding = 10.dp + val canvasWidth = dialogWidthDp - (canvasPadding * 2) + val aspect = 0.45f + val canvasHeight = (canvasWidth * aspect) + + // show static final view on open (no animation) — animation runs only when playKey>0 + val animationProgress = remember { Animatable(1f) } + LaunchedEffect(playKey) { + if (playKey > 0 && data.target.isNotBlank()) { + animationProgress.snapTo(0f) + // much slower overall but with constant speed (linear easing) + animationProgress.animateTo(1f, animationSpec = tween(durationMillis = 4800, easing = LinearEasing)) + } + } Canvas( modifier = modifier - .fillMaxWidth() - .height(previewHeight) + .width(canvasWidth) + .height(canvasHeight) + .onGloballyPositioned { coords -> Log.d(TAG, "Canvas px size=${coords.size}") } .background(Color(0xFFF0F4F1)) - .padding(10.dp), + .padding(canvasPadding), ) { + if (size.width <= 0f || size.height <= 0f) return@Canvas val left = size.width * 0.15f val top = size.height * 0.10f val right = size.width * 0.92f @@ -280,19 +319,48 @@ internal fun DiaryCourtDrawingPreview(data: DiaryCourtDrawingData, modifier: Mod val midX = (left + right) / 2f val midY = (top + bottom) / 2f drawRect(Color(0xFF315F43), topLeft = Offset(left, top), size = Size(right - left, bottom - top)) - drawRect(Color.White, topLeft = Offset(left, top), size = Size(right - left, bottom - top), style = Stroke(width = 4f)) - drawLine(Color.White, Offset(midX, top - 5f), Offset(midX, bottom + 5f), strokeWidth = 4f) - drawLine(Color(0xFFD9E4DD), Offset(left, midY), Offset(right, midY), strokeWidth = 2f) + drawRect(Color.White, topLeft = Offset(left, top), size = Size(right - left, bottom - top), style = Stroke(width = 8f)) + drawLine(Color.White, Offset(midX, top - 5f), Offset(midX, bottom + 5f), strokeWidth = 8f) + drawLine(Color(0xFFD9E4DD), Offset(left, midY), Offset(right, midY), strokeWidth = 4f) + + // original mappings (left/right matrices) + // left-of-pipe matrix: + // 3 6 9 + // 2 5 8 + // 1 4 7 + val originalLeft = mapOf( + 1 to (0 to 2), 2 to (0 to 1), 3 to (0 to 0), + 4 to (1 to 2), 5 to (1 to 1), 6 to (1 to 0), + 7 to (2 to 2), 8 to (2 to 1), 9 to (2 to 0), + ) + // right-of-pipe matrix: + // 7 4 1 + // 8 5 2 + // 9 6 3 + val originalRight = mapOf( + 1 to (2 to 0), 2 to (2 to 1), 3 to (2 to 2), + 4 to (1 to 0), 5 to (1 to 1), 6 to (1 to 2), + 7 to (0 to 0), 8 to (0 to 1), 9 to (0 to 2), + ) + + // Flip the matrices as requested: swap which map is used for onRight + val mapOnRightTrue = originalRight + val mapOnRightFalse = originalLeft fun targetPoint(target: String, onRight: Boolean): Offset { val number = target.toIntOrNull() ?: 5 - val row = (number - 1) / 3 - val column = (number - 1) % 3 + val (colIndex, rowIndex) = if (onRight) { + mapOnRightTrue[number] ?: (1 to 1) + } else { + mapOnRightFalse[number] ?: (1 to 1) + } + val halfLeft = if (onRight) midX else left val halfRight = if (onRight) right else midX + return Offset( - halfLeft + (halfRight - halfLeft) * (0.20f + column * 0.30f), - top + (bottom - top) * (0.18f + row * 0.32f), + halfLeft + (halfRight - halfLeft) * (0.20f + colIndex * 0.30f), + top + (bottom - top) * (0.18f + rowIndex * 0.32f), ) } @@ -301,39 +369,126 @@ internal fun DiaryCourtDrawingPreview(data: DiaryCourtDrawingData, modifier: Mod "AS3" -> bottom - (bottom - top) * 0.18f else -> midY } - val start = Offset(left - 14f, startY) - drawCircle(Color(0xFFC84C32), radius = 8f, center = start) + // circle center left of the table + val circleCenter = Offset(left - 14f, startY) + drawCircle(Color(0xFFC84C32), radius = 8f, center = circleCenter) + // compute arrow start point similar to web: offset horizontally and vertically depending on stroke side + val isVH = (data.stroke == "VH") + val start = Offset( + circleCenter.x + (if (isVH) 13f else 18f), + circleCenter.y + (if (isVH) 8f else 0f) + ) if (data.target.isNotBlank()) { - var previous = start - var target = targetPoint(data.target, true) - drawCourtArrow(previous, target, Color(0xFFC84C32)) - drawCircle(Color(0xFF90AF55), radius = 9f, center = target) - previous = target + // prepare segments and debug info (target number, side, row/col) + val segments = mutableListOf>() + val segmentInfo = mutableListOf() + fun computeRowCol(numberStr: String, onRight: Boolean): Pair { + val number = numberStr.toIntOrNull() ?: 5 + val (col, row) = if (onRight) { + // use same flipped maps as targetPoint + (mapOnRightTrue[number] ?: (1 to 1)) + } else { + (mapOnRightFalse[number] ?: (1 to 1)) + } + return row to col + } + + var prev = start + val mainNum = data.target + var t = targetPoint(mainNum, true) + segments.add(prev to t) + val (mainRow, mainCol) = computeRowCol(mainNum, true) + segmentInfo.add("main num=$mainNum onRight=true row=$mainRow col=$mainCol") + prev = t data.additionalStrokes.forEachIndexed { index, extra -> - target = targetPoint(extra.target, index % 2 != 0) - drawCourtArrow(previous, target, Color(0xFFE8A52A)) - drawCircle(Color(0xFF90AF55), radius = 7f, center = target) - previous = target + val onRight = index % 2 != 0 + t = targetPoint(extra.target, onRight) + segments.add(prev to t) + val (r, c) = computeRowCol(extra.target, onRight) + segmentInfo.add("extra#${index + 1} num=${extra.target} onRight=$onRight row=$r col=$c") + prev = t + } + + val lengths = segments.map { (s, e) -> + val dx = (e.x - s.x) + val dy = (e.y - s.y) + kotlin.math.sqrt(dx * dx + dy * dy) + } + val totalLen = lengths.sum().coerceAtLeast(1f) + val drawUpTo = animationProgress.value * totalLen + + var drawn = 0f + // debug log coordinates + Log.d(TAG, "Segments count=${segments.size}") + for (i in segments.indices) { + val (s, e) = segments[i] + val len = lengths[i].coerceAtLeast(1f) + val color = when (i) { + 0 -> Color(0xFFC84C32) + else -> { + val extraColors = listOf(Color(0xFF1F6A8A), Color(0xFF4F7F32), Color(0xFF8A5A1F)) + extraColors[(i - 1) % extraColors.size] + } + } + if (drawn + len <= drawUpTo) { + // if segment goes from right->left, shift up by half the stroke width + val strokeW = 10f + val shiftY = strokeW / 2f + val sAdj = if (e.x < s.x) Offset(s.x, s.y - shiftY) else s + val eAdj = if (e.x < s.x) Offset(e.x, e.y - shiftY) else e + drawCourtArrow(sAdj, eAdj, color) + drawCircle(Color(0xFF90AF55), radius = if (i == 0) 9f else 7f, center = eAdj) + drawn += len + val info = segmentInfo.getOrNull(i) ?: "n/a" + Log.d(TAG, "Segment $i full: s=${s.x.toInt()},${s.y.toInt()} e=${e.x.toInt()},${e.y.toInt()} len=${len.toInt()} info=$info") + } else if (drawn < drawUpTo) { + val remain = (drawUpTo - drawn) / len + val mid = Offset(s.x + (e.x - s.x) * remain, s.y + (e.y - s.y) * remain) + val path = Path().apply { + val strokeW = 10f + val shiftY = strokeW / 2f + val sAdj = if (e.x < s.x) Offset(s.x, s.y - shiftY) else s + val midAdj = if (e.x < s.x) Offset(mid.x, mid.y - shiftY) else mid + moveTo(sAdj.x, sAdj.y) + lineTo(midAdj.x, midAdj.y) + } + drawPath(path, color, style = Stroke(width = 10f, cap = StrokeCap.Round)) + val midForCircle = if (e.x < s.x) Offset(mid.x, mid.y - (10f / 2f)) else mid + drawCircle(Color(0xFF90AF55), radius = 6f, center = midForCircle) + val info = segmentInfo.getOrNull(i) ?: "n/a" + Log.d(TAG, "Segment $i partial: s=${s.x.toInt()},${s.y.toInt()} mid=${mid.x.toInt()},${mid.y.toInt()} len=${len.toInt()} progress=${animationProgress.value} info=$info") + break + } else { + break + } } } } } private fun DrawScope.drawCourtArrow(start: Offset, target: Offset, color: Color) { + val strokeW = 10f + val shiftY = strokeW / 2f + // if drawing right->left, shift both points up by half the stroke width + val s = if (target.x < start.x) Offset(start.x, start.y - shiftY) else start + val t = if (target.x < start.x) Offset(target.x, target.y - shiftY) else target val path = Path().apply { - moveTo(start.x, start.y) - lineTo(target.x, target.y) + moveTo(s.x, s.y) + lineTo(t.x, t.y) } - drawPath(path, color, style = Stroke(width = 5f, cap = StrokeCap.Round)) - val vector = start - target + drawPath(path, color, style = Stroke(width = strokeW, cap = StrokeCap.Round)) + val vector = t - s val len = kotlin.math.sqrt(vector.x * vector.x + vector.y * vector.y).coerceAtLeast(1f) val unit = Offset(vector.x / len, vector.y / len) val perpendicular = Offset(-unit.y, unit.x) val head = Path().apply { - moveTo(target.x, target.y) - lineTo(target.x + unit.x * 16f + perpendicular.x * 7f, target.y + unit.y * 16f + perpendicular.y * 7f) - lineTo(target.x + unit.x * 16f - perpendicular.x * 7f, target.y + unit.y * 16f - perpendicular.y * 7f) + moveTo(t.x, t.y) + lineTo(t.x - unit.x * 16f + perpendicular.x * 7f, t.y - unit.y * 16f + perpendicular.y * 7f) + lineTo(t.x - unit.x * 16f - perpendicular.x * 7f, t.y - unit.y * 16f - perpendicular.y * 7f) close() } + drawPath(head, color) } + +private const val TAG = "DiaryCourtDrawing" diff --git a/tmp/screen_after_fix.png b/tmp/screen_after_fix.png new file mode 100644 index 00000000..e1dc0ff5 Binary files /dev/null and b/tmp/screen_after_fix.png differ diff --git a/tmp/screen_after_fix2.png b/tmp/screen_after_fix2.png new file mode 100644 index 00000000..bfb29734 Binary files /dev/null and b/tmp/screen_after_fix2.png differ diff --git a/tmp/screen_after_fix3.png b/tmp/screen_after_fix3.png new file mode 100644 index 00000000..b7765736 Binary files /dev/null and b/tmp/screen_after_fix3.png differ diff --git a/tmp/screen_after_flip2.png b/tmp/screen_after_flip2.png new file mode 100644 index 00000000..6e9d4a2a Binary files /dev/null and b/tmp/screen_after_flip2.png differ diff --git a/tmp/screen_anim_fast_linear_shift_half.png b/tmp/screen_anim_fast_linear_shift_half.png new file mode 100644 index 00000000..0b4ed6cd Binary files /dev/null and b/tmp/screen_anim_fast_linear_shift_half.png differ diff --git a/tmp/screen_anim_very_slow_linear.png b/tmp/screen_anim_very_slow_linear.png new file mode 100644 index 00000000..75739b8e Binary files /dev/null and b/tmp/screen_anim_very_slow_linear.png differ diff --git a/tmp/screen_diary_navigation_fix.png b/tmp/screen_diary_navigation_fix.png new file mode 100644 index 00000000..e8d6a47e Binary files /dev/null and b/tmp/screen_diary_navigation_fix.png differ diff --git a/tmp/screen_diary_selection_fix.png b/tmp/screen_diary_selection_fix.png new file mode 100644 index 00000000..de0dd82d Binary files /dev/null and b/tmp/screen_diary_selection_fix.png differ