Refactor code structure for improved readability and maintainability
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 45s

This commit is contained in:
Torsten Schulz (local)
2026-05-30 15:53:07 +02:00
parent 88d852719d
commit 25f3802d66
13 changed files with 847 additions and 350 deletions

View File

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

View File

@@ -24,6 +24,7 @@ enum class CalendarEventType {
Tournament,
OfficialTournament,
Match,
FriendlyMatch,
Holiday,
SchoolHoliday,
TrainingCancellation,
@@ -231,6 +232,24 @@ object CalendarAggregator {
)
}
fun fromFriendlyMatches(matches: List<ScheduleMatchDto>): List<CalendarUiEvent> =
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<OfficialParticipationBucketDto>): List<CalendarUiEvent> =
buckets.mapNotNull { t ->
if (t.entries.isEmpty()) return@mapNotNull null
@@ -353,6 +372,7 @@ object CalendarAggregator {
cancellations: List<TrainingCancellationDto>,
tournaments: List<InternalTournamentSummaryDto>,
matches: List<ScheduleMatchDto>,
friendlyMatches: List<ScheduleMatchDto>,
official: List<OfficialParticipationBucketDto>,
holidays: ClubCalendarHolidaysEnvelope,
): List<CalendarUiEvent> {
@@ -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)

View File

@@ -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 MitgliederGalerie anfordert, wechsle zu Members und öffne die NestedAnsicht. */
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<String>()
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<DiaryDateActivityItem>,
planGroups: List<DiaryPlanGroup>,
planStartTimes: Map<Int, String>,
) {
data class GroupedCellEntry(
val label: String,
val duration: String?,
)
data class GroupedPlanRow(
val startTime: String,
val sharedItems: List<GroupedCellEntry>,
val groupItems: Map<Int, List<GroupedCellEntry>>,
)
val rows = remember(sortedPlan, planGroups, planStartTimes) {
val byStartTime = linkedMapOf<String, MutableList<DiaryDateActivityItem>>()
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<GroupedCellEntry>()
val perGroup = mutableMapOf<Int, MutableList<GroupedCellEntry>>()
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<GroupedPlanRow> { 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,

View File

@@ -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<List<CalendarUiEvent>>(emptyList()) }
@@ -134,6 +137,8 @@ fun CalendarScreen(
}
var sourceWarnings by remember { mutableStateOf<List<String>>(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) }
}
}
}
}

View File

@@ -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<String>, 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<Pair<Offset, Offset>>()
val segmentInfo = mutableListOf<String>()
fun computeRowCol(numberStr: String, onRight: Boolean): Pair<Int, Int> {
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"

BIN
tmp/screen_after_fix.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

BIN
tmp/screen_after_fix2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

BIN
tmp/screen_after_fix3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

BIN
tmp/screen_after_flip2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB