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

@@ -23,8 +23,13 @@ Kurztest vor Release oder nach größeren Änderungen. Angelehnt an `mobile-app/
- [ ] Tagebuch: Liste, Tag öffnen, **Zurück**
- [ ] **Neu**: Dialog Datum/Zeiten → Anlegen → **Sprung** in den neuen Tag
- [ ] Trainingsplan: Eintrag anlegen, Reihenfolge, löschen (Stichprobe)
- [ ] Trainingsplan: Gruppe anlegen/bearbeiten; Gruppenfilter; Unteraktivitaet bearbeiten und Teilnehmer zuordnen
- [ ] Trainingsplan: Zeichnung mit mehreren Schlaegen neu anlegen/bearbeiten; in der Liste erscheint nur das Icon, das den Vorschaudialog oeffnet
- [ ] Trainingsplan: leerer Plan zeigt `Offen`, vollstaendig gepflegter Plan mit Zeitfenster zeigt `Bereit`
- [ ] Teilnehmer: hinzufügen, Status (Stichprobe)
- [ ] PDF teilen (Intent öffnet sich)
- [ ] Teilnehmer: Testmitglied schnell anlegen, Test-Filter, Formular-Uebergabe und Statistik/letzte Teilnahmen
- [ ] Leeren Trainingstag nach Rueckfrage loeschen; Loeschen eines befuellten Tags bleibt deaktiviert
- [ ] Tages-PDF teilen; Gruppen- und Aktivitaets-Teilnehmerzuordnungen sind enthalten
## Mitglieder (4)

View File

@@ -71,11 +71,12 @@ Web: `DiaryView.vue` (sehr groß). API-Cluster (Auszug in Web nach `apiClien
- [x] Plan laden: `GET /diary-date-activities/:clubId/:diaryDateId``DiaryApi` / Tagebuch-Detail
- [x] Einträge anlegen: `POST /diary-date-activities/:clubId`, ggf. **Gruppe** `POST /diary-date-activities/group` — Android-Formulare + `CreateDiaryPlanActivityRequest` / `AddDiaryPlanGroupActivityRequest`
- [x] Einträge bearbeiten: `PUT /diary-date-activities/:clubId/:id` (Zuordnung `groupId` im Body wie Backend) — Dialog „Bearbeiten“; Unter-Einträge (`GroupActivity`): API `updateNestedGroupActivity`, mobil z.B. nur **Löschen**
- [x] Einträge bearbeiten: `PUT /diary-date-activities/:clubId/:id` (Zuordnung `groupId` im Body wie Backend) — Dialog „Bearbeiten“; Unter-Einträge (`GroupActivity`) bearbeiten, Teilnehmer zuordnen und Drawing pflegen
- [x] Reihenfolge: `PUT .../order` — ↑/↓ je Trainingsgruppen-Scope
- [x] Löschen: `DELETE ...`, Gruppen-Aktivität `DELETE .../group/...`
- [x] **Zeitblöcke** (`isTimeblock`) inkl. UX wie Web — Checkbox beim Anlegen, Kennzeichnung in der Karte
- [x] **Gruppen** für Plan: `GET/POST/DELETE /api/group``GroupApi` + Liste mit Löschen; **PUT** Umbenennen (`changeGroup`) nur API, keine eigene UI
- [x] **Gruppen** für Plan: `GET/POST/PUT/DELETE /api/group`Liste mit Mehrfachanlage, Lead-/Namensbearbeitung und bestaetigtem Loeschen
- [x] **Drawing:** Objekt-/String-kompatible API-Daten, Editor mit Folgeschlaegen, Anzeige nur per Icon und Vorschau-Dialog; Backend speichert `drawingData` ohne doppelte Serialisierung
### 3.3 Teilnehmer & Status
@@ -84,6 +85,7 @@ Web: `DiaryView.vue` (sehr groß). API-Cluster (Auszug in Web nach `apiClien
- [x] Status **entschuldigt/abgesagt**: `PUT /participants/:dateId/:memberId/status`
- [x] **Gruppenzuordnung Training**: `PUT /participants/:dateId/:memberId/group``ParticipantsApi.updateParticipantGroup`, Dropdown bei anwesenden Teilnehmern wenn Trainingsgruppen existieren
- [x] **UX:** Teilnehmerliste im Tagebuch-Detail **aufklappbar**, standard **eingeklappt** (`DiaryDetailScreen`)
- [x] **Testmitglieder-Workflow:** Filter/Suche, Schnellanlage im Trainingstag, Formular-Uebergabe und Warnhinweis nach mehreren Teilnahmen
- [x] `GET /diary-member-activities/:clubId/:activityId``DiaryMemberActivitiesApi` / „Wer macht mit?“ je Planzeile bzw. Gruppen-Aktivität
- [x] Zuweisen: `POST ...` mit `participantIds` — inkl. `ensureParticipantRowId` wenn noch keine Teilnehmer-Zeile
@@ -112,10 +114,11 @@ Web: `DiaryView.vue` (sehr groß). API-Cluster (Auszug in Web nach `apiClien
### 3.9 PDF / Export
- [x] Trainingsplan- und Tages-PDF (`DiaryPdfExporter` / `writeTrainingPlanPdf`, `writeTrainingDaySummaryPdf`), Teilen über `FileProvider` + `sharePdfFile` (`DiaryPdfShare`)
- [x] Tages-PDF mit Plan-Gruppen und den je Aktivitaet/Unteraktivitaet zugeordneten Teilnehmern
### 3.10 Sonstiges Diary-UX
- [x] **Web-DiaryView → mobil (Kurzüberblick):** Tages-Metadaten, Plan inkl. Zeitblöcke/Gruppen, Teilnehmer inkl. Status/Gruppe, „Wer macht mit?“, Freitext-Aktivitäten, Tags/Notizen, Unfälle, Mitglieds-Notizen/Tags-Dialog, Galerie, PDF-Exporte ohne die vielen Web-Tabs als 1:1-Spiegel; fehlende Parität steht in den Phasen 4+ / offenen Punkten.
- [x] **Web-DiaryView → mobil (Kurzüberblick):** Tages-Metadaten, sicher bestaetigtes Loeschen, Plan inkl. Zeitbloecke/Gruppen/Filter/Drawings und korrektem Bereitschaftsstatus, Teilnehmer inkl. Status/Gruppe/Testworkflow, „Wer macht mit?“, Freitext-Aktivitaeten, Tags/Notizen, Unfaelle, Galerie sowie PDF-Exporte.
- [x] **Berechtigungen:** `ClubPermissionHelpers` (`canReadDiary`, `canWriteDiary`, `canReadMembers`, `canWriteMembers`) Lese-Hinweis, Schreib-Aktionen und Gruppenfoto-Löschen in `AppRoot.kt` / `DiaryDetailScreen` entsprechend gekapselt
---

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

155212
mobile-app/device_logcat.txt Normal file

File diff suppressed because it is too large Load Diff

BIN
mobile-app/diary_row.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

View File

@@ -29,6 +29,10 @@ org.gradle.configureondemand=true
# Default is false.
org.gradle.configuration-cache=true
# Avoid Kotlin daemon RMI failures when incremental cache errors occur in Android Studio builds.
# Gradle still uses its daemon; Kotlin compilation runs in the Gradle build process.
kotlin.compiler.execution.strategy=in-process
kotlin.code.style=official
android.useAndroidX=true
android.nonTransitiveRClass=true

View File

@@ -1,7 +1,7 @@
[versions]
# composeApp (Play Store / „Über die App“-Build)
appVersionCode = "15"
appVersionName = "1.6.0"
appVersionCode = "16"
appVersionName = "1.6.1"
agp = "9.2.1"
android-compileSdk = "35"
android-minSdk = "24"

BIN
mobile-app/pulled_base.apk Normal file

Binary file not shown.

View File

@@ -0,0 +1,51 @@
package de.tsschulz.tt_tagebuch.shared.api.models
import kotlinx.serialization.json.Json
import org.junit.Test
import kotlin.test.assertEquals
class DiaryDrawingSerializationTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun predefinedActivity_deserializesDrawingDataAndImageLink() {
val raw = """{"id":7,"name":"Aufschlag","imageLink":"/images/7.png","drawingData":{"targetPosition":"5"}}"""
val activity = json.decodeFromString(PredefinedActivityDto.serializer(), raw)
assertEquals("/images/7.png", activity.imageLink)
assertEquals("""{"targetPosition":"5"}""", activity.drawingData)
}
@Test
fun planActivity_deserializesNestedDrawingAndGroupScope() {
val raw = """{"id":1,"drawingData":{"selectedStartPosition":"AS1"},"groupActivities":[{"id":2,"groupId":4,"drawingData":{"targetPosition":"4"},"groupPredefinedActivity":{"id":9,"drawingData":{"strokeType":"RH"}}}]}"""
val activity = json.decodeFromString(DiaryDateActivityItem.serializer(), raw)
val nested = activity.groupActivities.single()
assertEquals("""{"selectedStartPosition":"AS1"}""", activity.drawingData)
assertEquals(4, nested.groupId)
assertEquals("""{"targetPosition":"4"}""", nested.drawingData)
assertEquals("""{"strokeType":"RH"}""", nested.groupPredefinedActivity?.drawingData)
}
@Test
fun planActivity_findsDrawingStoredOnActivityImage() {
val raw = """{"id":3,"predefinedActivity":{"id":10,"images":[{"id":12,"drawingData":{"strokeType":"VH","targetPosition":"5"}}]}}"""
val activity = json.decodeFromString(DiaryDateActivityItem.serializer(), raw)
assertEquals("""{"strokeType":"VH","targetPosition":"5"}""", activity.predefinedActivity.drawingDataForDisplay())
}
@Test
fun predefinedActivity_keepsStringDrawingDataCompatible() {
val raw = """{"id":8,"drawingData":"{\"strokeType\":\"VH\"}"}"""
val activity = json.decodeFromString(PredefinedActivityDto.serializer(), raw)
assertEquals("""{"strokeType":"VH"}""", activity.drawingData)
}
}

View File

@@ -13,6 +13,8 @@ data class DiaryDateActivityItem(
val groupId: Int? = null,
val planGroup: DiaryPlanGroupSummary? = null,
val predefinedActivity: PredefinedActivitySummary? = null,
@Serializable(with = FlexibleNullableDrawingDataSerializer::class)
val drawingData: String? = null,
val groupActivities: List<GroupActivitySummary> = emptyList(),
)
@@ -30,15 +32,29 @@ data class PredefinedActivitySummary(
/** Wie Backend: z. B. `/api/predefined-activities/…/image/…` */
val imageLink: String? = null,
val imageUrl: String? = null,
@Serializable(with = FlexibleNullableDrawingDataSerializer::class)
val drawingData: String? = null,
val images: List<PredefinedActivityImageSummary> = emptyList(),
)
@Serializable
data class PredefinedActivityImageSummary(
val id: Int? = null,
val imagePath: String? = null,
@Serializable(with = FlexibleNullableDrawingDataSerializer::class)
val drawingData: String? = null,
)
@Serializable
data class GroupActivitySummary(
val id: Int? = null,
val orderId: Int? = null,
val groupId: Int? = null,
val duration: Int? = null,
val durationText: String? = null,
val groupPredefinedActivity: PredefinedActivitySummary? = null,
@Serializable(with = FlexibleNullableDrawingDataSerializer::class)
val drawingData: String? = null,
)
fun PredefinedActivitySummary?.displayLabel(): String {
@@ -55,3 +71,9 @@ fun DiaryDateActivityItem.displayTitle(fallbackTimeblock: String): String {
if (label.isNotEmpty()) return label
return if (isTimeblock) fallbackTimeblock else ""
}
fun PredefinedActivitySummary?.drawingDataForDisplay(): String? {
if (this == null) return null
return drawingData?.takeIf { it.isNotBlank() }
?: images.firstNotNullOfOrNull { image -> image.drawingData?.takeIf { it.isNotBlank() } }
}

View File

@@ -0,0 +1,43 @@
package de.tsschulz.tt_tagebuch.shared.api.models
import kotlinx.serialization.KSerializer
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonEncoder
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
/**
* The API may expose drawing data as stored JSON text or as an expanded JSON object.
* The app keeps one canonical text representation for the existing drawing parser.
*/
object FlexibleNullableDrawingDataSerializer : KSerializer<String?> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("FlexibleNullableDrawingData", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): String? {
val input = decoder as? JsonDecoder
?: error("FlexibleNullableDrawingDataSerializer requires JsonDecoder")
return when (val element = input.decodeJsonElement()) {
is JsonNull -> null
is JsonPrimitive -> element.contentOrNull
else -> element.toString()
}
}
@OptIn(ExperimentalSerializationApi::class)
override fun serialize(encoder: Encoder, value: String?) {
when (encoder) {
is JsonEncoder -> encoder.encodeJsonElement(
if (value == null) JsonNull else JsonPrimitive(value),
)
else -> if (value == null) encoder.encodeNull() else encoder.encodeString(value)
}
}
}

View File

@@ -10,6 +10,9 @@ data class PredefinedActivityDto(
val description: String? = null,
val duration: Int? = null,
val durationText: String? = null,
val imageLink: String? = null,
@Serializable(with = FlexibleNullableDrawingDataSerializer::class)
val drawingData: String? = null,
val excludeFromStats: Boolean? = null,
)

View File

@@ -16,6 +16,7 @@ import de.tsschulz.tt_tagebuch.shared.api.models.DiaryMemberTagLinkDto
import de.tsschulz.tt_tagebuch.shared.api.models.DiaryMemberTagMutationBody
import de.tsschulz.tt_tagebuch.shared.api.models.DiaryTag
import de.tsschulz.tt_tagebuch.shared.api.models.PredefinedActivityDto
import de.tsschulz.tt_tagebuch.shared.api.models.PredefinedActivityUpsertBody
import de.tsschulz.tt_tagebuch.shared.api.models.AddDiaryPlanGroupActivityRequest
import de.tsschulz.tt_tagebuch.shared.api.models.CreateDiaryPlanActivityRequest
import de.tsschulz.tt_tagebuch.shared.api.models.CreateTrainingGroupBody
@@ -121,6 +122,14 @@ class DiaryManager(
return predefinedActivitiesApi.getById(id)
}
suspend fun createPredefinedActivity(body: PredefinedActivityUpsertBody): PredefinedActivityDto {
return predefinedActivitiesApi.create(body)
}
suspend fun updatePredefinedActivity(id: Int, body: PredefinedActivityUpsertBody): PredefinedActivityDto {
return predefinedActivitiesApi.update(id, body)
}
suspend fun listAccidents(clubId: Int, diaryDateId: Int): List<AccidentReportDto> {
return accidentApi.list(clubId, diaryDateId)
}

1
mobile-app/uidump.xml Normal file

File diff suppressed because one or more lines are too long