Refactor code structure for improved readability and maintainability
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 45s
@@ -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 });
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<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,
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
After Width: | Height: | Size: 169 KiB |
BIN
tmp/screen_after_fix2.png
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
tmp/screen_after_fix3.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
tmp/screen_after_flip2.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
tmp/screen_anim_fast_linear_shift_half.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
tmp/screen_anim_very_slow_linear.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
tmp/screen_diary_navigation_fix.png
Normal file
|
After Width: | Height: | Size: 190 KiB |
BIN
tmp/screen_diary_selection_fix.png
Normal file
|
After Width: | Height: | Size: 267 KiB |