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:
38
backend/scripts/checkDrawingData.js
Normal file
38
backend/scripts/checkDrawingData.js
Normal 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();
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
20
backend/tests/predefinedActivityDrawingData.test.js
Normal file
20
backend/tests/predefinedActivityDrawingData.test.js
Normal 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"}');
|
||||
});
|
||||
});
|
||||
12
backend/utils/drawingData.js
Normal file
12
backend/utils/drawingData.js
Normal 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);
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -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