Refactor code structure for improved readability and maintainability
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 53s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 53s
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
Binary file not shown.
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
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
BIN
mobile-app/diary_row.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 231 KiB |
@@ -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
|
||||
|
||||
@@ -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
BIN
mobile-app/pulled_base.apk
Normal file
Binary file not shown.
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() } }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
1
mobile-app/uidump.xml
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user