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

This commit is contained in:
Torsten Schulz (local)
2026-05-27 23:53:41 +02:00
parent 2e7cf0c28d
commit e57cdc6ad8
25 changed files with 156689 additions and 171 deletions

View File

@@ -11,6 +11,7 @@ import android.text.TextPaint
import de.tsschulz.tt_tagebuch.shared.api.models.DiaryDateActivityItem
import de.tsschulz.tt_tagebuch.shared.api.models.DiaryFreeformActivity
import de.tsschulz.tt_tagebuch.shared.api.models.DiaryTrainingParticipant
import de.tsschulz.tt_tagebuch.shared.api.models.DiaryPlanGroup
import de.tsschulz.tt_tagebuch.shared.api.models.Member
import de.tsschulz.tt_tagebuch.shared.api.models.displayTitle
import de.tsschulz.tt_tagebuch.shared.api.models.isPresentParticipant
@@ -106,6 +107,8 @@ fun writeTrainingDaySummaryPdf(
participants: List<DiaryTrainingParticipant>,
freeform: List<DiaryFreeformActivity>,
planItems: List<DiaryDateActivityItem>,
planGroups: List<DiaryPlanGroup>,
activityParticipantRows: Map<Int, List<Int>>,
timeblockFallback: String,
) {
val doc = PdfDocument()
@@ -178,10 +181,33 @@ fun writeTrainingDaySummaryPdf(
newPageIfNeeded(40f)
val line = buildString {
append(item.displayTitle(timeblockFallback).ifBlank { "Eintrag ${item.id}" })
item.groupId?.let { gid ->
val groupName = planGroups.find { it.id == gid }?.name ?: "Gruppe $gid"
append(" [$groupName]")
}
item.durationText?.takeIf { it.isNotBlank() }?.let { append("$it") }
?: item.duration?.let { append("${it} min") }
}
y = canvas.drawStatic("$line", bodyPaint, MARGIN, y)
val assigned = activityParticipantRows[item.id].orEmpty().mapNotNull { participantId ->
val participant = participants.find { it.id == participantId } ?: return@mapNotNull null
activeMembers.find { it.id == participant.memberId }?.let { "${it.firstName} ${it.lastName}" }
}
if (assigned.isNotEmpty()) {
y = canvas.drawStatic(" Teilnehmer: ${assigned.joinToString(", ")}", bodyPaint, MARGIN, y)
}
item.groupActivities.forEach { nested ->
val nestedId = nested.id ?: return@forEach
val nestedAssigned = activityParticipantRows[nestedId].orEmpty().mapNotNull { participantId ->
val participant = participants.find { it.id == participantId } ?: return@mapNotNull null
activeMembers.find { it.id == participant.memberId }?.let { "${it.firstName} ${it.lastName}" }
}
val nestedTitle = nested.groupPredefinedActivity?.name ?: nested.groupPredefinedActivity?.code ?: "Gruppenaktivitaet"
y = canvas.drawStatic(" - $nestedTitle", bodyPaint, MARGIN, y)
if (nestedAssigned.isNotEmpty()) {
y = canvas.drawStatic(" Teilnehmer: ${nestedAssigned.joinToString(", ")}", bodyPaint, MARGIN, y)
}
}
}
}

View File

@@ -0,0 +1,326 @@
package de.tsschulz.tt_tagebuch.app.ui
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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.AlertDialog
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
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 de.tsschulz.tt_tagebuch.shared.api.models.PredefinedActivityDto
import de.tsschulz.tt_tagebuch.shared.api.models.PredefinedActivityUpsertBody
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
internal data class DiaryCourtStroke(
val side: String = "VH",
val type: String = "US",
val target: String = "",
)
internal data class DiaryCourtDrawingData(
val start: String = "AS2",
val stroke: String = "VH",
val spin: String = "",
val target: String = "",
val additionalStrokes: List<DiaryCourtStroke> = emptyList(),
) {
val valid: Boolean get() = start.isNotBlank() && stroke.isNotBlank() && spin.isNotBlank() && target.isNotBlank()
fun fullCode(): String = buildString {
append("$start $stroke $spin")
if (target.isNotBlank()) append(" -> $target")
additionalStrokes.forEach { extra ->
append(" / ${extra.side} ${extra.type} -> ${extra.target}")
}
}.trim()
fun toJson(): String = buildJsonObject {
put("selectedStartPosition", start)
put("selectedCirclePosition", when (start) {
"AS1" -> "top"
"AS3" -> "bottom"
else -> "middle"
})
put("strokeType", stroke)
put("spinType", spin)
put("targetPosition", target)
put("exerciseCounter", 1 + additionalStrokes.size)
put("code", fullCode())
put("renderCode", fullCode())
putJsonArray("additionalStrokes") {
additionalStrokes.forEachIndexed { index, extra ->
add(buildJsonObject {
put("side", extra.side)
put("type", extra.type)
put("targetPosition", extra.target)
put("counter", index + 2)
})
}
}
}.toString()
companion object {
fun fromJson(raw: String?): DiaryCourtDrawingData {
if (raw.isNullOrBlank()) return DiaryCourtDrawingData()
val obj = runCatching { Json.parseToJsonElement(raw) as? JsonObject }.getOrNull() ?: return DiaryCourtDrawingData()
fun string(key: String, fallback: String) = obj[key]?.jsonPrimitive?.contentOrNull ?: fallback
val extras = (obj["additionalStrokes"] as? JsonArray).orEmpty().mapNotNull { rawStroke ->
val strokeObj = rawStroke as? JsonObject ?: return@mapNotNull null
val target = strokeObj["targetPosition"]?.jsonPrimitive?.contentOrNull.orEmpty()
if (target.isBlank()) return@mapNotNull null
DiaryCourtStroke(
side = strokeObj["side"]?.jsonPrimitive?.contentOrNull ?: "VH",
type = strokeObj["type"]?.jsonPrimitive?.contentOrNull ?: "US",
target = target,
)
}.take(4)
return DiaryCourtDrawingData(
start = string("selectedStartPosition", "AS2"),
stroke = string("strokeType", "VH"),
spin = string("spinType", ""),
target = string("targetPosition", ""),
additionalStrokes = extras,
)
}
}
}
@Composable
internal fun DiaryCourtDrawingDialog(
initial: PredefinedActivityDto?,
onDismiss: () -> Unit,
onSave: (PredefinedActivityUpsertBody) -> Unit,
) {
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()) }
var durationText by remember(initial?.id) { mutableStateOf(initial?.durationText.orEmpty()) }
var drawing by remember(initial?.id) { mutableStateOf(DiaryCourtDrawingData.fromJson(initial?.drawingData)) }
var nextSide by remember(initial?.id) { mutableStateOf("VH") }
var nextType by remember(initial?.id) { mutableStateOf("US") }
var nextTarget by remember(initial?.id) { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Uebungszeichnung") },
text = {
Column(
modifier = Modifier.fillMaxWidth().heightIn(max = 600.dp).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)
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") }
}
}
}
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") }
}
Text(drawing.fullCode(), style = MaterialTheme.typography.caption)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = durationText,
onValueChange = { durationText = it },
label = { Text("Dauer (Text)") },
modifier = Modifier.weight(1f),
singleLine = true,
)
OutlinedTextField(
value = duration,
onValueChange = { duration = it.filter(Char::isDigit) },
label = { Text("Min.") },
modifier = Modifier.weight(0.65f),
singleLine = true,
)
}
}
},
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
internal fun DiaryCourtDrawingViewerDialog(
title: String,
data: DiaryCourtDrawingData,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
DiaryCourtDrawingPreview(data)
Text(data.fullCode(), style = MaterialTheme.typography.body2)
}
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Schliessen") } },
)
}
@Composable
private fun DrawingChoiceRow(label: String, values: List<String>, selected: String, select: (String) -> Unit) {
Column {
Text(label, style = MaterialTheme.typography.caption, fontWeight = FontWeight.SemiBold)
Row(
modifier = Modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
values.forEach { value ->
OutlinedButton(onClick = { select(value) }) {
Text(if (selected == value) "[$value]" else value)
}
}
}
}
}
@Composable
internal fun DiaryCourtDrawingPreview(data: DiaryCourtDrawingData, modifier: Modifier = Modifier) {
Canvas(
modifier = modifier
.fillMaxWidth()
.height(155.dp)
.background(Color(0xFFF0F4F1))
.padding(10.dp),
) {
val left = size.width * 0.15f
val top = size.height * 0.10f
val right = size.width * 0.92f
val bottom = size.height * 0.90f
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)
fun targetPoint(target: String, onRight: Boolean): Offset {
val number = target.toIntOrNull() ?: 5
val row = (number - 1) / 3
val column = (number - 1) % 3
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),
)
}
val startY = when (data.start) {
"AS1" -> top + (bottom - top) * 0.18f
"AS3" -> bottom - (bottom - top) * 0.18f
else -> midY
}
val start = Offset(left - 14f, startY)
drawCircle(Color(0xFFC84C32), radius = 8f, center = start)
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
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
}
}
}
}
private fun DrawScope.drawCourtArrow(start: Offset, target: Offset, color: Color) {
val path = Path().apply {
moveTo(start.x, start.y)
lineTo(target.x, target.y)
}
drawPath(path, color, style = Stroke(width = 5f, cap = StrokeCap.Round))
val vector = start - target
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)
close()
}
drawPath(head, color)
}