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

@@ -0,0 +1,38 @@
import PredefinedActivity from '../models/PredefinedActivity.js';
import sequelize from '../database.js';
async function check() {
try {
await sequelize.authenticate();
const rows = await PredefinedActivity.findAll({ attributes: ['id', 'name', 'drawingData'] });
const invalid = [];
for (const r of rows) {
const id = r.id;
const name = r.name;
const raw = r.drawingData;
if (raw == null) continue;
if (typeof raw !== 'string') {
invalid.push({ id, name, reason: 'not-string', raw: String(raw).slice(0, 200) });
continue;
}
try {
JSON.parse(raw);
} catch (e) {
// If raw looks like an object literal without quotes, consider invalid
invalid.push({ id, name, reason: 'invalid-json', error: e.message, raw: raw.slice(0, 200) });
}
}
if (invalid.length === 0) {
console.log('No invalid drawingData found.');
} else {
console.log('Invalid drawingData rows:');
invalid.forEach(i => console.log(JSON.stringify(i)));
}
} catch (err) {
console.error('Script error', err);
} finally {
await sequelize.close();
}
}
check();

View File

@@ -268,6 +268,45 @@ app.use(express.urlencoded({ extended: true, verify: captureRawBody }));
// Wichtig: userId wird später in authMiddleware gesetzt, aber Middleware funktioniert auch ohne
app.use(requestLoggingMiddleware);
// Normalisierungs-Middleware: Wenn `drawingData` als Objekt ankommt, immer als JSON-String speichern.
// Vermeidet Typ-Mismatches zwischen Client (Objekt) und DB/Code (stringified JSON).
function normalizeDrawingDataInObject(obj) {
if (!obj || typeof obj !== 'object') return;
if (Array.isArray(obj)) return obj.forEach(normalizeDrawingDataInObject);
// Direkte Felder
try {
if (obj.drawingData && typeof obj.drawingData === 'object') {
obj.drawingData = JSON.stringify(obj.drawingData);
}
} catch (e) {
// Ignore
}
// häufiges Pattern: predefinedActivity.drawingData
try {
if (obj.predefinedActivity && obj.predefinedActivity.drawingData && typeof obj.predefinedActivity.drawingData === 'object') {
obj.predefinedActivity.drawingData = JSON.stringify(obj.predefinedActivity.drawingData);
}
} catch (e) {}
// Rekursiv durch alle Keys gehen
for (const k of Object.keys(obj)) {
try { normalizeDrawingDataInObject(obj[k]); } catch (e) {}
}
}
app.use((req, res, next) => {
try {
if (req.body) normalizeDrawingDataInObject(req.body);
if (req.query) normalizeDrawingDataInObject(req.query);
} catch (e) {
// Never fail the request because of normalization
console.error('[normalizeDrawingData] Error:', e && e.message ? e.message : e);
}
next();
});
// Globale Fehlerbehandlung, damit der Server bei unerwarteten Fehlern nicht hart abstürzt
process.on('uncaughtException', (err) => {
console.error('[uncaughtException]', err);

View File

@@ -237,6 +237,11 @@ class DiaryDateActivityService {
const gpImages = await PredefinedActivityImage.findAll({ where: { predefinedActivityId: gpJson.id }, order: [['createdAt','ASC']] });
gpJson.images = gpImages.map(i => { const ii = i.toJSON(); try { if (ii.drawingData) ii.drawingData = JSON.parse(ii.drawingData); } catch (e){}; return ii; });
const firstImg = gpImages[0];
if (gpJson.drawingData) {
try { if (typeof gpJson.drawingData === 'string') gpJson.drawingData = JSON.parse(gpJson.drawingData); } catch (e) {}
} else if (firstImg && firstImg.drawingData) {
try { gpJson.drawingData = JSON.parse(firstImg.drawingData); } catch (e) {}
}
if (firstImg) {
gpJson.imageUrl = `/api/predefined-activities/${gpJson.id}/image/${firstImg.id}`;
gpJson.imageLink = `/api/predefined-activities/${gpJson.id}/image/${firstImg.id}`;

View File

@@ -4,6 +4,7 @@ import GroupActivity from '../models/GroupActivity.js';
import PredefinedActivityImage from '../models/PredefinedActivityImage.js';
import sequelize from '../database.js';
import { Op } from 'sequelize';
import { normalizeStoredDrawingData } from '../utils/drawingData.js';
import { devLog } from '../utils/logger.js';
class PredefinedActivityService {
@@ -16,7 +17,7 @@ class PredefinedActivityService {
duration: data.duration,
imageLink: data.imageLink,
excludeFromStats: Boolean(data.excludeFromStats),
drawingData: data.drawingData ? JSON.stringify(data.drawingData) : null,
drawingData: normalizeStoredDrawingData(data.drawingData),
});
}
@@ -33,7 +34,7 @@ class PredefinedActivityService {
duration: data.duration,
imageLink: data.imageLink,
excludeFromStats: Boolean(data.excludeFromStats),
drawingData: data.drawingData ? JSON.stringify(data.drawingData) : null,
drawingData: normalizeStoredDrawingData(data.drawingData),
});
}

View File

@@ -94,6 +94,34 @@ function nextPowerOfTwo(n) {
return p;
}
function knockoutEntrantKey(entrant) {
return `${entrant.isExternal ? 'E' : 'M'}:${entrant.id}`;
}
function compareKnockoutEntrants(a, b) {
const comparisons = [
['place', 1],
['points', -1],
['setDiff', -1],
['setsWon', -1],
['pointsDiff', -1],
['pointsWon', -1],
];
for (const [property, direction] of comparisons) {
const aValue = Number(a[property] ?? 0);
const bValue = Number(b[property] ?? 0);
if (aValue !== bValue) {
return direction * (aValue - bValue);
}
}
if (!!a.isExternal !== !!b.isExternal) {
return a.isExternal ? 1 : -1;
}
return Number(a.id) - Number(b.id);
}
const THIRD_PLACE_ROUND = 'Spiel um Platz 3';
class TournamentService {
// -------- Multi-Stage (Runden) V1 --------
@@ -255,9 +283,15 @@ class TournamentService {
// - Für interne Teilnehmer brauchen wir die ClubMember-ID (Member.id / TournamentMember.clubMemberId),
// nicht die TournamentMember.id.
// - Für externe Teilnehmer ist `id` die ExternalTournamentParticipant.id (bestehende Logik).
participants: (g.participants || []).map(p => ({
participants: (g.participants || []).map((p, index) => ({
id: p.isExternal ? p.id : (p.clubMemberId ?? p.member?.id ?? p.id),
isExternal: !!p.isExternal,
place: Number(p.position ?? index + 1),
points: Number(p.points ?? 0),
setDiff: Number(p.setDiff ?? 0),
setsWon: Number(p.setsWon ?? 0),
pointsDiff: Number(p.pointsDiff ?? 0),
pointsWon: Number(p.pointsWon ?? 0),
})),
}));
@@ -289,7 +323,7 @@ class TournamentService {
if ((grp.classId ?? null) !== classId) continue;
for (const place of fromPlaces) {
const p = getByPlace(grp, Number(place));
if (p) items.push({ ...p, classId, place: Number(place) });
if (p) items.push({ ...p, classId });
}
}
@@ -455,16 +489,21 @@ class TournamentService {
const entrants = classItems.map(p => ({
id: Number(p.id),
isExternal: !!p.isExternal,
place: p.place || 999, // Platz-Information behalten
place: p.place || 999,
points: p.points ?? 0,
setDiff: p.setDiff ?? 0,
setsWon: p.setsWon ?? 0,
pointsDiff: p.pointsDiff ?? 0,
pointsWon: p.pointsWon ?? 0,
}));
// Dedupliziere (falls jemand in mehreren Regeln landet)
// Wenn jemand mehrfach vorkommt, nehme den besten Platz
const seen = new Map();
for (const e of entrants) {
const key = `${e.isExternal ? 'E' : 'M'}:${e.id}`;
const key = knockoutEntrantKey(e);
const existing = seen.get(key);
if (!existing || (e.place < existing.place)) {
if (!existing || compareKnockoutEntrants(e, existing) < 0) {
seen.set(key, e);
}
}
@@ -472,13 +511,22 @@ class TournamentService {
const thirdPlace = wantsThirdPlace;
if (uniqueEntrants.length >= 2) {
// Sortiere nach Platz: beste Plätze zuerst, dann schlechtere
// Wenn mehrere Teilnehmer den gleichen Platz haben, behalte die ursprüngliche Reihenfolge
uniqueEntrants.sort((a, b) => {
const placeA = a.place || 999;
const placeB = b.place || 999;
return placeA - placeB;
});
// Ein KO-Feld wird voll besetzt, sofern weitere Gruppenteilnehmer vorhanden sind.
// Beispiel: 3 Gruppen x 2 Qualifikanten ergeben sechs Starter; die beiden
// besten Drittplatzierten vervollständigen das Viertelfinale.
const targetBracketSize = nextPowerOfTwo(uniqueEntrants.length);
const requiredFillers = targetBracketSize - uniqueEntrants.length;
if (requiredFillers > 0) {
const selectedKeys = new Set(uniqueEntrants.map(knockoutEntrantKey));
const remainingEntrants = perGroupRanked
.filter(group => (group.classId ?? null) === classId)
.flatMap(group => group.participants)
.filter(entrant => !selectedKeys.has(knockoutEntrantKey(entrant)))
.sort(compareKnockoutEntrants);
uniqueEntrants.push(...remainingEntrants.slice(0, requiredFillers));
}
uniqueEntrants.sort(compareKnockoutEntrants);
// Paare: Bester gegen Schlechtesten, Zweiter gegen Vorletzten, etc.
// Reverse die zweite Hälfte, um das gewünschte Pairing zu erreichen

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { normalizeStoredDrawingData } from '../utils/drawingData.js';
describe('predefined activity drawing data', () => {
it('stores drawing objects as JSON once', () => {
expect(normalizeStoredDrawingData({ selectedStartPosition: 'AS1' }))
.toBe('{"selectedStartPosition":"AS1"}');
});
it('does not double encode JSON text sent by mobile clients', () => {
expect(normalizeStoredDrawingData('{"strokeType":"VH"}'))
.toBe('{"strokeType":"VH"}');
});
it('repairs previously double encoded JSON text', () => {
expect(normalizeStoredDrawingData('"{\\"targetPosition\\":\\"5\\"}"'))
.toBe('{"targetPosition":"5"}');
});
});

View File

@@ -0,0 +1,12 @@
export function normalizeStoredDrawingData(value) {
if (!value) return null;
let normalized = value;
for (let pass = 0; pass < 2 && typeof normalized === 'string'; pass += 1) {
try {
normalized = JSON.parse(normalized);
} catch (error) {
return value;
}
}
return typeof normalized === 'string' ? normalized : JSON.stringify(normalized);
}

View File

@@ -1,11 +1,15 @@
# DiaryView Port Plan (statt direkter Komplett-Implementierung)
# DiaryView Port: Umsetzungsstand Android
## Entscheidung
Die vollständige Rest-Portierung von `frontend/src/views/DiaryView.vue` in einem einzigen Durchlauf würde voraussichtlich **deutlich mehr als 40 Dateien** betreffen (UI-Module, Dialoge, Mapper/DTOs, Repository/Service/ViewModel-Erweiterungen, Socket-Handling, Strings, Tests, Doku).
## Stand 2026-05-26
Die priorisierten Funktionsblöcke A bis F sind in der nativen Android-Diary-Ansicht umgesetzt. Die Android-Oberflaeche bildet die Workflows mobilgerecht ab; sie kopiert nicht die Web-Anordnung pixelgenau.
Daher wurde gemäß Vorgabe (`Wenn mehr nötig: /docs/plan_diaryview.md statt Änderungen`) **kein weiterer Code geändert**.
- Plan: Haupt- und Gruppenaktivitaeten anlegen, bearbeiten, zuordnen, sortieren und loeschen; Zeitbloecke; Gruppenfilter und Bereitschaftsstatus.
- Teilnehmer: Status/Gruppe, Testmitglied-Schnellanlage, Formular-Uebergabe, erweiterte Suche und letzte Teilnahmen.
- Gruppen: Anlegen (auch mehrere), Umbenennen/Lead bearbeiten und bestaetigtes Loeschen.
- Medien/Export: Galerie, Aktivitaetsbilder, Court-Drawing-Editor mit Schlagsequenzen; vorhandene Zeichnungen werden per Icon in einem Dialog angezeigt; Tages-PDF inklusive Gruppen-/Teilnehmerzuordnung.
- Sicherheit/Livedaten: Loeschen eines Trainingstags nur leer und nach Bestaetigung; Diary-relevante Aktualisierungen bleiben in der vorhandenen Live-Aktualisierung.
## 1) Analyse von `DiaryView.vue`
## Web-Referenz
### Verwendete Components
- `CourtDrawingRender`
@@ -34,8 +38,7 @@ Daher wurde gemäß Vorgabe (`Wenn mehr nötig: /docs/plan_diaryview.md statt Ä
- `currentClub`
- `currentClubName`
### API-Aufrufe (relevant für Rest-Port)
Bereits in Android teilweise umgesetzt, aber für Vollparität fehlen weiterhin größere Blöcke:
### API-Aufrufe
- Diary Core: `/diary/{clubId}` (GET/POST/PUT/DELETE)
- Participants: `/participants/*`, `/participants/{dateId}/{memberId}/group`
- Activities: `/activities/{dateId}`, `/activities/add`
@@ -56,45 +59,40 @@ Bereits in Android teilweise umgesetzt, aber für Vollparität fehlen weiterhin
- Member: `member:changed`
- Group: `group:changed`
## 2) Warum >40 Dateien realistisch sind
Für „möglichst 1:1 Web-Funktionalität“ müssen zusätzlich zu bestehendem Stand mindestens folgende Pakete ausgebaut werden:
- Mehrere dedizierte Compose-Dialoge (Drawing, Notes, Tag-Historie, Activity-Stats, Accident, QuickAdd, Gallery, Image/Confirm/Info)
- Umfangreiche VM-State-Modelle pro Dialog/Tab/Flow
- Zusätzliche Repository-Modelle und Mapper für Group-Activities, Member-Activities, Accident, Gallery/Image-Modelle
- Socket-Event-spezifische State-Synchronisation im Diary-Flow
- Zusätzliche Strings/Testfälle für viele Spezialpfade
## Mobile Abbildung
Die Umsetzung bleibt bewusst in den vorhandenen Compose- und Shared-Bausteinen. Neue, eigenstaendige UI-Bausteine sind insbesondere `DiaryCourtDrawing.kt` sowie ausgelagerte Diary-Dialog-/Toolbar-Composables in `AppRoot.kt`. Die Shared-DTOs akzeptieren Drawing-Daten auch fuer eingebettete Planaktivitaeten.
Damit ist die 40-Dateien-Grenze in einem „alles in einem Durchlauf“-Block nicht sinnvoll einhaltbar, ohne die geforderte 1:1-Funktionalität zu unterlaufen.
## Umsetzungsbloecke
## 3) Empfohlene Umsetzungsblöcke (priorisiert)
### Block A: Training-Plan Vollständigkeit
### Block A: Training-Plan Vollstaendigkeit - umgesetzt
- Group-Activities (`/diary-date-activities/group*`)
- Activity-Member-Zuordnung (`/diary-member-activities/*`)
- Plan-Item Group-Update, Duration/DurationText vollständig
- Drag&Drop-Reorder final
- Reihenfolge per mobil geeigneten Hoch-/Runter-Aktionen
### Block B: Teilnehmer- und Mitglieder-Dialoge
### Block B: Teilnehmer- und Mitglieder-Dialoge - umgesetzt
- MemberNotesDialog-Äquivalent (inkl. Tag-Zuordnung auf Member+Date)
- QuickAddMemberDialog inkl. `clubmembers/set`
- Tag-History und Activity-Stats Dialoge
- Activity-Stats mit letzten Teilnahmen; die im Web-Template verbliebene `TagHistoryDialog`-Deklaration ist dort aus der sichtbaren Teilnehmeraktion nicht erreichbar (`openTagInfos` oeffnet die Statistik)
### Block C: Gruppenverwaltung + Vorschlagslogik
### Block C: Gruppenverwaltung + Vorschlagslogik - umgesetzt
- Gruppen CRUD (`/group*`) inklusive Lead
- New-Date Dialog mit TrainingGroup/TrainingTimes-Vorschlag
### Block D: Bilder/Galerie + Export
### Block D: Bilder/Galerie + Export - umgesetzt
- MemberGalleryDialog + Bildaufrufe
- ImageDialog
- TrainingDay PDF-Export (oder Android-kompatible Alternative)
### Block E: Accident + Drawing
### Block E: Accident + Drawing - umgesetzt
- AccidentFormDialog (`/accident*`)
- CourtDrawingDialog/Render-Flow mit PredefinedActivity-Sync
- CourtDrawingDialog/Render-Flow mit bis zu vier Folgeschlaegen, PredefinedActivity-Sync und Listeneintrag-Icon statt Inline-Zeichnung
### Block F: Socket-Live-Updates
### Block F: Socket-Live-Updates - umgesetzt
- Nur Diary-relevante Event-Subsets robust in VM integrieren
- Dokumentation in `/docs/socket_contract.md`
## 4) Konkreter nächster Umsetzungsschritt
Wenn du fortsetzen möchtest, starte mit **Block A** (Training-Plan Vollständigkeit), da er den größten funktionalen Mehrwert für den täglichen Diary-Betrieb liefert und auf dem bereits migrierten Stand aufsetzt.
## Verifikation
- Android-Compile: `./gradlew :composeApp:compileDebugKotlinAndroid`
- Shared DTO Regression: `DiaryDrawingSerializationTest`
- Manuelle Diary-Faelle: `mobile-app/REGRESSION_CHECKLIST.md`

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