chore: update .gitignore and enhance backend and mobile app functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s

- Added mobile app build directories and configuration files to .gitignore for cleaner repository management.
- Improved error handling in diaryMemberController by requiring diaryDateId and memberId query parameters.
- Refactored DiaryMemberService to log tag IDs instead of raw values for better debugging.
- Enhanced TournamentParticipantsTab and TournamentTab components with improved touch-action properties for better user experience.
- Updated mobile app's gradle.properties and build.gradle.kts for compatibility with AGP 9.x and Kotlin 2.1.21, including new dependencies for Coil and UCrop.
- Refactored MainApplication to simplify initialization and improved MainActivity to handle dependencies more robustly.
- Updated various UI components in the mobile app to enhance layout and functionality, including MemberDetailScreen and MemberEditScreen.
This commit is contained in:
Torsten Schulz (local)
2026-05-12 23:14:31 +02:00
parent 27f8af559b
commit 48f71b9df1
138 changed files with 54488 additions and 56 deletions

8
.gitignore vendored
View File

@@ -9,3 +9,11 @@ backend/images/*
backend/backend-debug.log
backend/*.log
backend/.env.local
mobile-app/.gradle/
mobile-app/.idea/
mobile-app/.kotlin/
mobile-app/build/
mobile-app/composeApp/build/
mobile-app/shared/build/
mobile-app/local.properties

View File

@@ -61,8 +61,12 @@ const removeMemberNote = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const { clubId, noteId } = req.params;
const { diaryDateId, memberId } = req.query;
if (!diaryDateId || !memberId) {
return res.status(400).json({ error: 'diaryDateId and memberId query parameters are required' });
}
await DiaryMemberService.removeNoteFromMember(userToken, clubId, noteId);
const notes = await DiaryMemberService.getNotesForMember(userToken, req.params.clubId, diaryDateId, memberId);
const notes = await DiaryMemberService.getNotesForMember(userToken, clubId, diaryDateId, memberId);
res.status(200).json(notes);
} catch (error) {
console.error('[removeMemberNote] - Error: ', error.message);
@@ -74,7 +78,7 @@ const removeMemberTag = async (req, res) => {
try {
const { diaryDateId, memberId, tagId } = req.body;
const { authcode: userToken } = req.headers;
await DiaryMemberService.removeTagFromMemberAndDate(userToken, req.params.clubId, diaryDateId, memberId, tagId);
await DiaryMemberService.removeTagFromMemberAndDate(userToken, req.params.clubId, diaryDateId, memberId, { id: tagId });
const tags = await DiaryMemberService.getTagsForMemberAndDate(userToken, req.params.clubId, diaryDateId, memberId);
res.status(200).json(tags);
} catch (error) {

View File

@@ -51,7 +51,7 @@ class DiaryMemberService {
if (tagLink) {
await tagLink.destroy();
} else {
devLog(diaryDateId, memberId, tagId);
devLog(diaryDateId, memberId, tag?.id);
throw new Error('Das Tag ist nicht verknüpft.');
}
}

106
docs/MOBILE_APP_KMP_PLAN.md Normal file
View File

@@ -0,0 +1,106 @@
# Mobile App Restart (KMP) Plan
Ziel: Eine **wirklich native** Mobile App (Android + iOS) mit **gemeinsamem Shared-Code** (Kotlin Multiplatform) und nativer UI.
Wichtiger Befund aus dem Repo: Die Auth ist **token-basiert (JWT)**, nicht Cookie-Session:
- `POST /api/auth/login``{ token }`
- Token wird bei Requests als Header `authcode: <token>` (Web-Standard) gesendet.
- Alternativ akzeptiert das Backend auch `Authorization: Bearer <token>` (Middleware).
## 1) Ziel-Architektur
### Module
- `mobile-app/shared/`
- Domain/UseCases
- API Client (Ktor)
- Token Storage Abstraktion
- Repositories
- (später) Offline Cache
- `mobile-app/androidApp/` (oder bestehend: `composeApp` Android target)
- Android UI (Jetpack Compose)
- Android-spezifische Implementierungen (Secure storage, logging)
- `mobile-app/iosApp/`
- iOS UI (SwiftUI) **oder** Compose Multiplatform UI
- iOS-spezifische Implementierungen (Keychain, logging)
### UI-Strategie (2 sinnvolle Wege)
**A) Native UI je Plattform (Compose + SwiftUI)** (empfohlen für “wirklich native”)
- Shared: Logik/Netzwerk/State
- Android UI: Compose
- iOS UI: SwiftUI
**B) Gemeinsame UI mit Compose Multiplatform**
- Shared: UI + Logik
- Android/iOS nutzen die gleiche Compose-UI
- Weniger UI-Duplizierung, aber iOS “native feel” erfordert extra Pflege.
Empfehlung: Start mit **A**, da iOS dann maximal “native” ist; Shared bleibt groß genug, dass der doppelte UI-Aufwand beherrschbar bleibt.
## 2) Networking & Auth (Shared)
### Endpunkte (v1)
- Login: `POST /api/auth/login`
- Logout: `POST /api/auth/logout` (Token wird serverseitig invalidiert)
- Session Check: `GET /api/session/status` (auth erforderlich)
### Request-Regeln
- Header `authcode` auf **jedem** API Request setzen (wie Web).
- Globales Handling:
- 401 → Token löschen, UI zurück zu Login.
- Timeouts/Netzfehler → kein Auto-Logout.
### Implementierungsvorschlag
- Ktor Client:
- `ContentNegotiation` + Kotlinx Serialization
- `HttpTimeout`
- DefaultRequest: `url(baseUrl)`, `header("authcode", token)`
- Token Storage:
- Interface im `shared`
- Android: EncryptedSharedPreferences oder Jetpack Security
- iOS: Keychain
Aktueller Stand im Repo:
- `TokenStorage` + Android/iOS Implementierungen liegen in `mobile-app/shared/src/**/de/tt_tagebuch/shared/state/*TokenStorage*`.
## 3) Parity-Plan (“wie Webapp”, aber in Wellen)
“Komplett wie Webapp” wird als **Feature-Parität** umgesetzt, nicht als Big-Bang.
### Release 1 (Must-have)
- Auth: Login/Logout/Passwort reset (wenn benötigt)
- Club-Kontext: Verein wählen, Berechtigungen/Access Requests
- Tagebuch: Liste/Detail/CRUD + Filter/Suche
- Mitglieder: Liste/Detail + Filter/Suche
### Parity-Wellen
Welle A: Training Stats, Personal Settings
Welle B: Approvals, Club Settings, Member Transfer
Welle C: Team Management, Permissions, Logs
Welle D: Orders + Billing
Welle E: Tournaments + Schedule + CourtDrawingTool + PDF/Exports + Upload/Crop
Jede Welle: API-Gaps schließen → UI → Tests → Release.
## 4) i18n-Strategie (Single Source of Truth)
Quelle bleibt `frontend/src/i18n/locales/de.json`.
Option 1 (empfohlen): **Generator**
- Script generiert:
- `shared`-Resources (Keys/Strings) oder
- Android `strings.xml` + iOS `.strings`
- Vorteil: Keys bleiben identisch, kein Drift.
Option 2: JSON direkt in App laden
- Einfacher Start, aber weniger “platform idiomatic”.
## 5) Repo/Build Setup
- Gradle Wrapper (`mobile-app/gradlew`) für reproduzierbare Builds.
- CI (später):
- Android: `./gradlew :androidApp:assembleDebug`
- iOS: Xcode build (oder Fastlane)
## 6) Definition of Done (pro Feature)
- API Requests laufen stabil (Retry/Timeout ok)
- 401 führt zuverlässig zu Logout
- i18n Keys vorhanden (Generator/Checks)
- Android: Emulator + Device Smoke
- iOS: Simulator Smoke

View File

@@ -1183,7 +1183,7 @@ export default {
display: block;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
touch-action: pan-x;
touch-action: pan-x pan-y;
overscroll-behavior-x: contain;
}
@@ -1196,7 +1196,7 @@ export default {
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
touch-action: pan-x;
touch-action: pan-x pan-y;
}
.participants-class-section {
@@ -1204,7 +1204,7 @@ export default {
max-width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
touch-action: pan-x;
touch-action: pan-x pan-y;
}
.participants-table {
@@ -1221,8 +1221,9 @@ export default {
overflow-y: auto;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
touch-action: pan-x;
touch-action: pan-x pan-y;
overscroll-behavior-x: contain;
overscroll-behavior-y: contain;
}
.participants-table-unified thead th {

View File

@@ -3801,24 +3801,21 @@ export default {
const participantsResponse = await apiClient.get(`/participants/${trainingForDate.id}`);
const participants = participantsResponse.data;
const presentParticipants = Array.isArray(participants)
? participants.filter(participant => participant.attendanceStatus === 'present')
: [];
if (participants && participants.length > 0) {
// Lade die Member-Details für jeden Teilnehmer
if (presentParticipants.length > 0) {
const membersResponse = await apiClient.get(`/clubmembers/get/${this.currentClub}/true`);
const membersById = new Map((membersResponse.data || []).map(member => [Number(member.id), member]));
const trainingParticipants = [];
for (const participant of participants) {
try {
// Lade Member-Details über die Member-API
const memberResponse = await apiClient.get(`/clubmembers/get/${this.currentClub}/true`);
const member = memberResponse.data.find(m => m.id === participant.memberId);
if (member) {
trainingParticipants.push({
clubMemberId: participant.memberId,
member: member
});
}
} catch (memberError) {
console.error('Fehler beim Laden der Member-Details:', memberError);
for (const participant of presentParticipants) {
const member = membersById.get(Number(participant.memberId));
if (member) {
trainingParticipants.push({
clubMemberId: participant.memberId,
member: member
});
}
}
@@ -4895,7 +4892,7 @@ button {
width: 100%;
max-width: 100%;
-webkit-overflow-scrolling: touch;
touch-action: pan-x;
touch-action: pan-x pan-y;
}
.participants-table {
@@ -4926,7 +4923,8 @@ button {
overflow-x: auto;
margin: 0;
-webkit-overflow-scrolling: touch;
touch-action: pan-x;
touch-action: pan-x pan-y;
overscroll-behavior-y: contain;
}
.participants-table-body td {

81
mobile-app/DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,81 @@
# Development
## Backend-URL
**Standard:** `https://tt-tagebuch.de` gesetzt in `gradle.properties` (`backendBaseUrl=…`) und als Fallback in `composeApp/build.gradle.kts`.
Für lokale Entwicklung läuft der Backend-Server auf deinem Rechner typischerweise unter **`http://localhost:3005`**.
Die **Android-Studio-VM (Emulator)** kann **`localhost` / `127.0.0.1` nicht** für diesen lokalen Server nutzen: dort ist „localhost“ der Emulator selbst, nicht dein PC. Für lokale Emulator-Tests nutze `http://10.0.2.2:3005`.
**HTTP / Cleartext:** Ab Android 9 blockiert das System unverschlüsseltes HTTP standardmäßig. Für die lokale Dev-URL ist das in `composeApp/src/androidMain/res/xml/network_security_config.xml` für `10.0.2.2`, `localhost` und `127.0.0.1` freigegeben.
Testest du mit der **LAN-IP deines PCs** (echtes Gerät), musst du diese IP dort als weiteres `<domain>` ergänzen oder kurzzeitig HTTPS nutzen.
### Wenn die App trotz Rebuild noch `localhost` zeigt
1. **Android Studio überschreibt Gradle:**
**Settings → Build, Execution, Deployment → Build Tools → Gradle**
beim Projekt `mobile-app` prüfen, ob unter **Command-line options** etwas wie
`-PbackendBaseUrl=http://localhost:3005` steht das entfernen oder bewusst setzen.
2. **Alte Installation / Build-Cache:** Emulator-App deinstallieren, Clean, neu installieren:
```bash
cd mobile-app
adb uninstall de.tt_tagebuch.app
./gradlew :composeApp:clean :composeApp:installDebug --no-configuration-cache
```
Oder das Skript: `./scripts/install-debug-emulator.sh`
3. **BuildConfig prüfen:** Nach dem Build in
`composeApp/build/generated/.../BuildConfig.java`
den Wert von `BACKEND_BASE_URL` ansehen der muss `https://tt-tagebuch.de` sein (oder deine bewusste Override-URL).
### Android Studio „Run“ geht nicht
- Projekt **`mobile-app/`** als Root öffnen (nicht nur den übergeordneten Monorepo-Ordner, wenn Studio das Modul nicht erkennt).
- **Run → Edit Configurations → Android App:** Modul **`composeApp`** wählen.
- Alternativ immer über die Kommandozeile installieren (siehe oben) und die App im Emulator starten.
Überschreiben per Gradle-Property, z. B. lokaler Emulator:
```bash
./gradlew :composeApp:installDebug -PbackendBaseUrl=http://10.0.2.2:3005
```
**Alternative:** Port vom PC auf den Emulator weiterleiten, dann geht wieder `localhost`:
```bash
adb reverse tcp:3005 tcp:3005
./gradlew :composeApp:installDebug -PbackendBaseUrl=http://localhost:3005
```
## Smoke flow (Android)
1. Start backend (default `http://localhost:3005`)
2. Start Android emulator
3. App installieren: `./scripts/install-debug-emulator.sh` oder Run in Studio (Modul **`composeApp`**)
4. Login → Club auswählen → Rolle wird angezeigt
5. Open `Tagebuch` → die letzten Einträge werden angezeigt
## Gradle Wrapper
Use the wrapper from `mobile-app/`:
```bash
cd mobile-app
./gradlew tasks
```
Note (Codex sandbox only): the sandbox home directory is read-only, so run with:
```bash
GRADLE_USER_HOME=/tmp/gradle-home ./gradlew tasks
```
## i18n generation
```bash
node scripts/generate-mobile-i18n.js
```
This reads all web locale JSON files and writes the generated KMP bundle to
`mobile-app/shared/src/commonMain/kotlin/de/tt_tagebuch/shared/i18n/MobileStrings.kt`.
## Build
Recommended:
- Open `mobile-app/` in Android Studio and run the `composeApp` configuration.

16
mobile-app/README.md Normal file
View File

@@ -0,0 +1,16 @@
# Mobile App (Native) Kotlin Multiplatform
Dieses Verzeichnis ist der Neustart der Mobile App als **wirklich native Android+iOS App** via **Kotlin Multiplatform (KMP)**.
## Status
- Der erste Versuch lag vorher in `mobile-app/` und wurde als Legacy gesichert.
- Ein React-Native/Expo Restart-Versuch liegt in `mobile-app_legacy_rn_expo/` (nicht mehr der aktuelle Weg).
## Auth (wichtig)
Das Backend nutzt für die App/API **JWT Tokens**:
- `POST /api/auth/login``{ token }`
- Requests senden den Token als Header `authcode: <token>` (wie Webapp).
## Plan
Siehe `docs/MOBILE_APP_KMP_PLAN.md`.

212
mobile-app/TODO.md Normal file
View File

@@ -0,0 +1,212 @@
# Mobile App TODO Web-Parität (Android, KMP-Shared)
Dieses Dokument ist die **Arbeitsliste**, um die **funktionale Abdeckung der Web-App** (`frontend/src/router.js`, Views unter `frontend/src/views/`) in der **nativen Android-App** (Jetpack Compose, Shared Code unter `mobile-app/shared`) nachzubauen.
**Wichtig:** Ein „komplettes“ Umsetzen dieser Liste ist ein **Mehrmonats-/Team-Projekt**. Es wird **iterativ** abgearbeitet; unten sind nur die Punkte angehakt, die im Code **tatsächlich** vorhanden sind (Stand siehe Git). Alles andere bleibt offen.
**Legende:** `[x]` umgesetzt · `[ ]` offen · Die „Baseline“ am Ende beschreibt den älteren Grundstock.
**Vorgehen:** Pro Phase vertikale Schnitte (API im `shared` Modul → Manager/Use-Case → UI). Web-Referenz immer die gleichnamige `*.vue`-Datei und `apiClient`-Aufrufe darin.
---
## Bereits umgesetzt (Baseline, Stand 2026-05)
- Auth (`authcode`), Token-Persistenz, 401 → Login
- Club-Auswahl, Permissions, Access-Request
- Tabs inkl. **Start**-Hub (`MainTab.Home`), Unter-Screens (Tagebuch-Tag, Mitglied-Detail), Tab-/Rail ausblenden in Details
- **Tagebuch:** Datenliste, Tag-Detail, Zeiten, Notizen, Tags (Tagesbezug), **Freitext-Aktivitäten** (`/activities`), Trainingsplan **lesen & CRUD** (Phase 3.2), Teilnehmer an/ab (einklappbare Liste, standard eingeklappt), **Zuordnung zu Plan-Aktivitäten** (3.4), **Unfälle/Vorfälle** (3.7), Löschen Tag
- **Mitglieder:** Liste, Suche, statisches Detail
- **Trainings-Statistik:** Basis-KPIs + Top-Liste
- **Einstellungen:** Sprache, Session-Check, Logout, Backend-Anzeige
- i18n-Generator + `MobileStrings`
- Theming näher an Web (`TtTagebuchTheme`)
---
## Phase 0 Architektur & Grundlagen für Vollausbau
- [ ] **Navigation:** Zentraler Nav-Graph (z. B. Navigation Compose) mit Back-Stack pro Tab oder einheitlichem Stack; Tiefe: Club → Modul → Unterseiten
- [ ] **Feature-Paketierung:** UI/API pro Bereich trennen (`diary`, `members`, `schedule`, …), Composables entschlacken
- [ ] **Use-Cases:** Geschäftslogik aus Composables in testbare Funktionen/Klassen im `shared`
- [x] **Fehlerbild (Basis):** JSON-Fehlerbody (`error` / `message`) aus API-Antworten → [ApiException]-Text (`ApiErrorMessage.kt`, authed + public Client); bekannte Tokens (`alreadyexists`, …) → Deutsch; Retries bewusst noch offen
- [ ] **Echtzeit (optional):** Socket-Events wie Web (`socketService`) oder dokumentiertes Polling nach Schreiboperationen
- [ ] **Medien:** Entscheidung Bilder/PDF (Coil, Cache, Download, Intents)
- [x] **Öffentlicher HTTP-Client** ohne Auth-Header (`PublicHttpClient`, `PublicAuthApi` im `shared`)
---
## Phase 1 Auth, Onboarding, öffentliche Seiten
Web-Routen: `/login`, `/register`, `/activate/:code`, `/forgot-password`, `/reset-password/:token`, `/impressum`, `/datenschutz`, `/` (Home öffentlich)
- [x] **Registrierung** (`Register.vue`) Android: `RegisterScreen`, `POST /api/auth/register`
- [x] **Account aktivieren** (`Activate.vue`) Android: `ActivateAccountScreen`, `GET /api/auth/activate/:code`
- [x] **Passwort vergessen / E-Mail** (`ForgotPassword.vue`) `ForgotPasswordScreen`, `POST /api/auth/forgot-password`
- [x] **Passwort setzen mit Token** (`ResetPassword.vue`) `ResetPasswordScreen`, `POST /api/auth/reset-password`
- [x] **Home / Landing eingeloggt** (`Home.vue`) Tab **Start**: Willkommen, Kacheln zu Tagebuch/Mitglieder/Statistik/Mehr, Vereinsinfos via `GET /api/clubs/:id` (`HomeScreen`, `ClubsApi.getClub`)
- [x] **Impressum** Einstellungen → öffnet `BACKEND_BASE_URL/impressum` im Browser
- [x] **Datenschutz** Einstellungen → öffnet `BACKEND_BASE_URL/datenschutz` im Browser
- [ ] SEO-Marketingseiten (`TableTennisClubSoftware`, `ClubMemberManagementPage`, …): **nur falls** im Store gefordert; sonst `[ ] optional / nicht mobil`
---
## Phase 2 Verein anlegen & Vereins-Ansicht
- [x] **Verein erstellen** (`CreateClub.vue`) + API `POST /api/clubs`, `ClubSelectScreen` + `ClubManager.createClub`
- [x] **Vereins-Profil / Show Club** (`ClubView.vue`) Basis: erweitertes `Club`-Modell + Karte auf **Start** (Begrüßung, Verbands-Nr., MyTischtennis-Kürzel); volle Parität (Links, Zahlen) offen
---
## Phase 3 Tagebuch (DiaryView) Hauptblock
Web: `DiaryView.vue` (sehr groß). API-Cluster (Auszug in Web nach `apiClient` verifizieren):
### 3.1 Tages-Metadaten & Listen
- [x] Tagebuch-Daten **vollständig** laden beim Datumswechsel Plan, Trainingsgruppen und Teilnehmer **parallel** in einem `LaunchedEffect` (`DiaryDetailScreen`)
- [x] **Aktivitäten-Liste** `/activities/:dateId``DiaryApi.listFreeformActivities` / `addFreeformActivity`, Abschnitt „Weitere Tages-Aktivitäten“ im Tagebuch-Detail
### 3.2 Trainingsplan (CRUD)
- [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] 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
### 3.3 Teilnehmer & Status
- [x] Liste: `GET /participants/:dateId`
- [x] Hinzufügen/Entfernen: `POST /participants/add`, `POST /participants/remove`
- [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] `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
- [x] Entfernen: `DELETE .../:participantId`
### 3.5 Mitgliedsbezogene Notizen/Tags im Tagebuch
- [x] `GET/POST .../diarymember/:clubId/note`, `DELETE .../note/:id` (mit `diaryDateId`/`memberId` als Query) — `DiaryMemberApi` / Dialog „Notizen & Tags“ je Teilnehmer
- [x] `GET/POST .../diarymember/:clubId/tag`, `POST .../tag/remove` — bestehende Tags, neu anlegen (`POST /tags` + Verknüpfung)
### 3.6 Predefined Activities (Auswahl & Suche im Tagebuch)
- [x] `GET /predefined-activities`, `GET ...?scope=standard`, `GET /search/query` — Trainingsplan anlegen (global + Gruppe) und Eintrag bearbeiten (Suche / vordefinierte ID)
- [x] `GET /predefined-activities/:id``PredefinedActivitiesApi.getById` / `DiaryManager.getPredefinedActivity` (für spätere Detail-UI)
### 3.7 Unfälle / Vorfälle
- [x] `POST /accident`, `GET /accident/:clubId/:diaryDateId``AccidentApi` / `DiaryManager` / Abschnitt im Tagebuch-Detail
### 3.8 Galerie & Bilder
- [x] Mitgliederbilder: `memberProfileImagePath` + `AuthenticatedAsyncImage` (Coil mit `diaryAuthHeaders`) in der Teilnehmerliste bei `diary.read` + `members.read`
- [x] Übungs-/Predefined-Bilder: `DiaryImagePaths` (`mainActivityImagePath` / `nestedActivityImagePath`) + `ApiConfig.toAbsoluteUrl`, Vollbild-Dialog im Tagebuch-Detail
- [x] **Gruppenfotos:** `MemberGroupPhotosApi` (`GET/POST/DELETE /api/member-group-photos/:clubId`), Galerie-Abschnitt + `PickVisualMedia` in `DiaryDetailScreen`
### 3.9 PDF / Export
- [x] Trainingsplan- und Tages-PDF (`DiaryPdfExporter` / `writeTrainingPlanPdf`, `writeTrainingDaySummaryPdf`), Teilen über `FileProvider` + `sharePdfFile` (`DiaryPdfShare`)
### 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] **Berechtigungen:** `ClubPermissionHelpers` (`canReadDiary`, `canWriteDiary`, `canReadMembers`, `canWriteMembers`) Lese-Hinweis, Schreib-Aktionen und Gruppenfoto-Löschen in `AppRoot.kt` / `DiaryDetailScreen` entsprechend gekapselt
---
## Phase 4 Mitglieder (MembersView) — erledigt
- [x] **Mitglied-CRUD:** `POST /api/clubmembers/set/:clubId``MembersApi.setMember`, `MemberSetBody` / `Member.toSetBody`, `MembersManager.saveMember`; neu + bearbeiten in `MemberEditRoute` (`AppRoot.kt`)
- [x] **Felder / Formulare:** Stammdaten, Adresse, **mehrere Telefon- und E-Mail-Zeilen** (Primär, Elternkontakt, Name) wie Web-`contacts`, Status (aktiv, Testmitglied, Formular, Freigaben, Bilder Internet), Geschlecht; TTR/QTTR nur Anzeige beim Bearbeiten
- [x] **Profilbild:** `POST /api/clubmembers/image/...` (Multipart) — `MembersApi.uploadMemberPortrait`, Galerie-Pick + UCrop in `MemberDetailRoute` / `MemberPortraitCrop.kt`
- [x] **Aktivität / letzte Teilnahmen:** `GET /api/member-activities/:clubId/:memberId` (Perioden-Query), `.../last-participations``MemberActivitiesApi`, Abschnitte im Profil
- [x] **Trainingsgruppen:** `GET/POST/DELETE /api/training-groups/...``TrainingGroupsApi`, Zuweisen/Entfernen im Profil
- [x] **Trainingszeiten (Verein):** `GET /api/training-times/:clubId``TrainingTimesApi`, Anzeige je Gruppe im Profil
- [x] **Bild zuschneiden:** Yalantis **UCrop** nach `PickVisualMedia` — JitPack, `UCropActivity` im Manifest
---
## Phase 5 Trainings-Statistik (Parität TrainingStatsView)
- [ ] Alle Kennzahlen/Tabellen/Filter aus Web
- [ ] Zeiträume, Exporte, falls vorhanden
---
## Phase 6 Terminplan (ScheduleView)
- [ ] Kalender-/Listenansicht, CRUD oder Sync wie Web
- [ ] API-Endpunkte aus `ScheduleView.vue` ins `shared` übernehmen
---
## Phase 7 Turniere
- [ ] `TournamentsView.vue` Vereinsturniere
- [ ] `OfficialTournaments.vue` / offizielle Teilnahmen
- [ ] `TournamentTab.vue` eingebettete Logik, soweit mobil relevant
- [ ] API aus jeweiligen Views dokumentieren und abarbeiten
---
## Phase 8 Freigaben & Verwaltung
- [ ] **Ausstehende Freigaben** (`PendingApprovalsView.vue`)
- [ ] **Team-Management** (`TeamManagementView.vue`)
- [ ] **Berechtigungen** (`PermissionsView.vue`) rollenbasiert
- [ ] **Logs** (`LogsView.vue`) eher Admin; nur wenn nötig mobil
---
## Phase 9 Vereins- & Stammdaten-Einstellungen
- [ ] **ClubSettings** (`ClubSettings.vue`) alle Unterbereiche
- [ ] **Predefined Activities Verwaltung** (`PredefinedActivities.vue`) CRUD, Bilder/Zeichnungen falls API
- [ ] **Mitgliedstransfer-Einstellungen** (`MemberTransferSettingsView.vue`)
---
## Phase 10 Persönliche Konten & Integrationen
- [ ] **Persönliche Einstellungen** vollständig (`PersonalSettings.vue` vs. aktuelles „Mehr“)
- [ ] **MyTischtennis-Konto** (`MyTischtennisAccount.vue`)
- [ ] **ClickTT-Konto** (`ClickTtAccount.vue`)
- [ ] **ClickTT-Ansicht** (`ClickTtView.vue`)
---
## Phase 11 Abrechnung & Bestellungen
- [ ] **Orders** (`OrdersView.vue`)
- [ ] **Billing** (`BillingView.vue`)
- [ ] Rechtliches/UX: ggf. WebView oder Deep-Link, wenn Zahlungsflüsse web-only
---
## Phase 12 Qualität, Tests, Release
- [ ] **Regression-Checkliste** pro Phase (manuell)
- [ ] Automatisierte Tests: `shared` (Serialisierung, Mapper), wo möglich UI-Tests kritische Flows
- [ ] **Barrierefreiheit:** Talkback, Kontraste, Touch-Ziele
- [ ] **Performance:** große Listen (Paging), Bildcache
- [ ] Store-Texte, Datenverarbeitung (Play Policy) für Medien und Kontakte
---
## Hinweise zur Pflege dieser Liste
1. Beim Abhaken eines Punktes **kurz** vermerken (Commit-Message oder Unterpunkt), welche Datei/API neu ist.
2. Wenn Web eine Funktion **einstellt** oder API ändert: TODO hier und `DEVELOPMENT.md` anpassen.
3. **DiaryView** zuerst in **Unterkapitel** zerlegen (3.13.10), dann Issues/PRs pro Unterkapitel sonst bleibt der Block unendlich.
---
## Referenz: Web-Routen (Router)
Siehe `frontend/src/router.js` jede `path`-Zeile sollte langfristig einem mobilen Eintrag (oder einer bewussten Ausnahme) zugeordnet sein.

View File

@@ -1,3 +1,9 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
val backendBaseUrl = providers.gradleProperty("backendBaseUrl")
.orElse("https://tt-tagebuch.de")
.get()
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidApplication)
@@ -8,8 +14,8 @@ plugins {
kotlin {
androidTarget {
compilations.all {
kotlinOptions {
jvmTarget = "1.8"
compilerOptions.configure {
jvmTarget.set(JvmTarget.JVM_17)
}
}
}
@@ -20,6 +26,7 @@ kotlin {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material)
implementation(compose.materialIconsExtended)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(libs.voyager.navigator)
@@ -32,6 +39,8 @@ kotlin {
implementation(libs.androidx.appcompat)
implementation(libs.androidx.core.ktx)
implementation(libs.koin.android)
implementation(libs.coil.compose)
implementation(libs.yalantis.ucrop)
}
}
}
@@ -46,6 +55,10 @@ android {
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1
versionName = "1.0.0"
buildConfigField("String", "BACKEND_BASE_URL", "\"$backendBaseUrl\"")
}
buildFeatures {
buildConfig = true
}
packaging {
resources {
@@ -58,8 +71,8 @@ android {
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}

View File

@@ -8,18 +8,34 @@
android:allowBackup="true"
android:icon="@android:mipmap/sym_def_app_icon"
android:label="Trainingstagebuch"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@android:mipmap/sym_def_app_icon"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
android:theme="@android:style/Theme.Material.Light.NoActionBar"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name="com.yalantis.ucrop.UCropActivity"
android:exported="false"
android:screenOrientation="portrait"
android:theme="@style/Theme.AppCompat.Light.NoActionBar" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -0,0 +1,116 @@
package de.tt_tagebuch.app
import android.content.Context
import android.content.Intent
import android.net.Uri
import de.tt_tagebuch.shared.api.AccidentApi
import de.tt_tagebuch.shared.api.ApiConfig
import de.tt_tagebuch.shared.api.AuthApi
import de.tt_tagebuch.shared.api.PublicAuthApi
import de.tt_tagebuch.shared.api.ClubsApi
import de.tt_tagebuch.shared.api.DiaryApi
import de.tt_tagebuch.shared.api.DiaryMemberActivitiesApi
import de.tt_tagebuch.shared.api.DiaryMemberApi
import de.tt_tagebuch.shared.api.GroupApi
import de.tt_tagebuch.shared.api.ParticipantsApi
import de.tt_tagebuch.shared.api.PredefinedActivitiesApi
import de.tt_tagebuch.shared.api.MemberActivitiesApi
import de.tt_tagebuch.shared.api.MemberGroupPhotosApi
import de.tt_tagebuch.shared.api.MembersApi
import de.tt_tagebuch.shared.api.PermissionsApi
import de.tt_tagebuch.shared.api.SessionApi
import de.tt_tagebuch.shared.api.TrainingGroupsApi
import de.tt_tagebuch.shared.api.TrainingStatsApi
import de.tt_tagebuch.shared.api.TrainingTimesApi
import de.tt_tagebuch.shared.api.http.AndroidHttpClientEngineFactory
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.http.PublicHttpClient
import de.tt_tagebuch.shared.state.AndroidClubStorage
import de.tt_tagebuch.shared.state.AndroidLanguageStorage
import de.tt_tagebuch.shared.state.AndroidTokenStorage
import de.tt_tagebuch.shared.state.AuthManager
import de.tt_tagebuch.shared.state.ClubManager
import de.tt_tagebuch.shared.state.DiaryManager
import de.tt_tagebuch.shared.state.LanguageManager
import de.tt_tagebuch.shared.state.MembersManager
import de.tt_tagebuch.shared.state.MutableTokenProvider
import de.tt_tagebuch.shared.state.TrainingStatsManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
class AppDependencies(context: Context) {
private val appContext = context.applicationContext
private val applicationJob = SupervisorJob()
/**
* Für Suspend-Aufrufe aus Button-Callbacks: überlebt das Verlassen der Composition
* (z. B. Club wählen → anderer Screen), im Gegensatz zu [rememberCoroutineScope].
*/
val applicationScope = CoroutineScope(applicationJob + Dispatchers.Main.immediate)
val apiConfig = ApiConfig(baseUrl = BuildConfig.BACKEND_BASE_URL)
val unauthorizedEvents = MutableStateFlow(0)
private val tokenProvider = MutableTokenProvider()
private val client = AuthedHttpClient(
apiConfig = apiConfig,
tokenProvider = tokenProvider,
httpClientEngineFactory = AndroidHttpClientEngineFactory(),
onUnauthorized = { unauthorizedEvents.value += 1 },
)
private val publicHttpClient = PublicHttpClient(
apiConfig = apiConfig,
httpClientEngineFactory = AndroidHttpClientEngineFactory(),
)
val publicAuthApi = PublicAuthApi(publicHttpClient)
val authManager = AuthManager(
tokenProvider = tokenProvider,
tokenStorage = AndroidTokenStorage(context.applicationContext),
authApi = AuthApi(client),
sessionApi = SessionApi(client),
)
val clubManager = ClubManager(
clubStorage = AndroidClubStorage(context.applicationContext),
clubsApi = ClubsApi(client),
permissionsApi = PermissionsApi(client),
)
val diaryManager = DiaryManager(
DiaryApi(client),
ParticipantsApi(client),
GroupApi(client),
DiaryMemberActivitiesApi(client),
DiaryMemberApi(client),
PredefinedActivitiesApi(client),
AccidentApi(client),
MemberGroupPhotosApi(client),
)
val membersManager = MembersManager(
MembersApi(client),
TrainingGroupsApi(client),
MemberActivitiesApi(client),
TrainingTimesApi(client),
)
val trainingStatsManager = TrainingStatsManager(TrainingStatsApi(client))
val languageManager = LanguageManager(AndroidLanguageStorage(context.applicationContext))
val sessionApi = SessionApi(client)
/** Header wie [AuthedHttpClient] (für Coil-Bilder, Downloads). */
fun diaryAuthHeaders(): Map<String, String> = buildMap {
tokenProvider.token?.let { put("authcode", it) }
tokenProvider.username?.let { put("userid", it) }
}
/** Öffnet einen Pfad auf dem konfigurierten Backend im Browser (z. B. Impressum, Datenschutz). */
fun openBackendPath(path: String) {
val base = apiConfig.baseUrl.trimEnd('/')
val suffix = path.trim().let { p -> if (p.startsWith("/")) p else "/$p" }
val uri = Uri.parse("$base$suffix")
val intent = Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
runCatching { appContext.startActivity(intent) }
}
}

View File

@@ -3,12 +3,36 @@ package de.tt_tagebuch.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import de.tt_tagebuch.app.ui.AppRoot
import de.tt_tagebuch.app.ui.TtTagebuchTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
App()
val context = LocalContext.current
val dependencies = remember {
try {
AppDependencies(context.applicationContext)
} catch (t: Throwable) {
android.util.Log.e("MainActivity", "Failed to initialize AppDependencies", t)
throw t
}
}
TtTagebuchTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background,
) {
AppRoot(dependencies)
}
}
}
}
}

View File

@@ -1,18 +1,6 @@
package de.tt_tagebuch.app
import android.app.Application
import de.tt_tagebuch.app.di.appModule
import de.tt_tagebuch.shared.di.initKoin
import org.koin.android.ext.koin.androidContext
class MainApplication : Application() {
override fun onCreate() {
super.onCreate()
initKoin(
baseUrl = "https://tt-tagebuch.de",
additionalModules = listOf(appModule)
) {
androidContext(this@MainApplication)
}
}
}
class MainApplication : Application()

View File

@@ -0,0 +1,191 @@
package de.tt_tagebuch.app.pdf
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Typeface
import android.graphics.pdf.PdfDocument
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import de.tt_tagebuch.shared.api.models.DiaryDateActivityItem
import de.tt_tagebuch.shared.api.models.DiaryFreeformActivity
import de.tt_tagebuch.shared.api.models.DiaryTrainingParticipant
import de.tt_tagebuch.shared.api.models.Member
import de.tt_tagebuch.shared.api.models.displayTitle
import de.tt_tagebuch.shared.api.models.isPresentParticipant
import java.io.File
import java.io.FileOutputStream
import java.util.Locale
private const val PAGE_W = 595
private const val PAGE_H = 842
private const val MARGIN = 40f
private const val MAX_TEXT_W = (PAGE_W - MARGIN * 2).toInt()
private fun newTitlePaint(): TextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLACK
textSize = 16f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
}
private fun newBodyPaint(): TextPaint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLACK
textSize = 11f
typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
textLocale = Locale.GERMANY
}
private fun Canvas.drawStatic(text: String, paint: TextPaint, x: Float, y: Float): Float {
if (text.isEmpty()) return y
val layout = StaticLayout.Builder.obtain(text, 0, text.length, paint, MAX_TEXT_W)
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
.setLineSpacing(0f, 1.05f)
.setIncludePad(false)
.build()
save()
translate(x, y)
layout.draw(this)
restore()
return y + layout.height + 6f
}
fun writeTrainingPlanPdf(
outFile: File,
clubName: String,
dateLabel: String,
timeLabel: String,
planItems: List<DiaryDateActivityItem>,
timeblockFallback: String,
) {
val doc = PdfDocument()
var pageSeq = 0
fun openPage(): PdfDocument.Page {
pageSeq++
return doc.startPage(PdfDocument.PageInfo.Builder(PAGE_W, PAGE_H, pageSeq).create())
}
var page = openPage()
var canvas = page.canvas
var y = MARGIN
val titlePaint = newTitlePaint()
val bodyPaint = newBodyPaint()
fun newPageIfNeeded(extra: Float) {
if (y + extra > PAGE_H - MARGIN) {
doc.finishPage(page)
page = openPage()
canvas = page.canvas
y = MARGIN
}
}
y = canvas.drawStatic("Trainingsplan $clubName", titlePaint, MARGIN, y)
y = canvas.drawStatic("Datum: $dateLabel", bodyPaint, MARGIN, y)
y = canvas.drawStatic("Zeiten: $timeLabel", bodyPaint, MARGIN, y)
y = canvas.drawStatic("", bodyPaint, MARGIN, y)
val sorted = planItems.sortedWith(compareBy({ it.groupId ?: 0 }, { it.orderId }, { it.id }))
sorted.forEach { item ->
val line = buildString {
append(item.displayTitle(timeblockFallback).ifBlank { "Eintrag ${item.id}" })
item.durationText?.takeIf { it.isNotBlank() }?.let { append("$it") }
?: item.duration?.let { append("${it} min") }
item.planGroup?.name?.takeIf { it.isNotBlank() }?.let { append(" — Gruppe: $it") }
}
newPageIfNeeded(40f)
y = canvas.drawStatic("$line", bodyPaint, MARGIN, y)
}
doc.finishPage(page)
FileOutputStream(outFile).use { doc.writeTo(it) }
doc.close()
}
fun writeTrainingDaySummaryPdf(
outFile: File,
clubName: String,
dateLabel: String,
timeLabel: String,
activeMembers: List<Member>,
participants: List<DiaryTrainingParticipant>,
freeform: List<DiaryFreeformActivity>,
planItems: List<DiaryDateActivityItem>,
timeblockFallback: String,
) {
val doc = PdfDocument()
var pageSeq = 0
fun openPage(): PdfDocument.Page {
pageSeq++
return doc.startPage(PdfDocument.PageInfo.Builder(PAGE_W, PAGE_H, pageSeq).create())
}
var page = openPage()
var canvas = page.canvas
var y = MARGIN
val titlePaint = newTitlePaint()
val bodyPaint = newBodyPaint()
fun newPageIfNeeded(extra: Float) {
if (y + extra > PAGE_H - MARGIN) {
doc.finishPage(page)
page = openPage()
canvas = page.canvas
y = MARGIN
}
}
y = canvas.drawStatic("Trainingstag $clubName", titlePaint, MARGIN, y)
y = canvas.drawStatic("Datum: $dateLabel", bodyPaint, MARGIN, y)
y = canvas.drawStatic("Zeiten: $timeLabel", bodyPaint, MARGIN, y)
y = canvas.drawStatic("", bodyPaint, MARGIN, y)
y = canvas.drawStatic("Teilnehmer (laut App-Stand)", titlePaint, MARGIN, y)
val partLines = activeMembers.map { m ->
val row = participants.find { it.memberId == m.id }
val base = "${m.lastName}, ${m.firstName}"
if (row == null) {
"$base — (keine Teilnahme-Zeile)"
} else if (row.isPresentParticipant()) {
"$base — anwesend"
} else {
when (row.attendanceStatus?.lowercase()) {
"excused" -> "$base — entschuldigt"
"cancelled" -> "$base — abgesagt"
else -> "$base — (${row.attendanceStatus ?: "?"})"
}
}
}
if (partLines.isEmpty()) {
y = canvas.drawStatic("", bodyPaint, MARGIN, y)
} else {
partLines.forEach { n ->
newPageIfNeeded(30f)
y = canvas.drawStatic("$n", bodyPaint, MARGIN, y)
}
}
y = canvas.drawStatic("", bodyPaint, MARGIN, y)
y = canvas.drawStatic("Weitere Tages-Aktivitäten", titlePaint, MARGIN, y)
if (freeform.isEmpty()) {
y = canvas.drawStatic("", bodyPaint, MARGIN, y)
} else {
freeform.forEach { f ->
newPageIfNeeded(30f)
y = canvas.drawStatic("${f.description}", bodyPaint, MARGIN, y)
}
}
y = canvas.drawStatic("", bodyPaint, MARGIN, y)
y = canvas.drawStatic("Trainingsplan (Aktivitäten)", titlePaint, MARGIN, y)
val sorted = planItems.sortedWith(compareBy({ it.groupId ?: 0 }, { it.orderId }, { it.id }))
if (sorted.isEmpty()) {
y = canvas.drawStatic("", bodyPaint, MARGIN, y)
} else {
sorted.forEach { item ->
newPageIfNeeded(40f)
val line = buildString {
append(item.displayTitle(timeblockFallback).ifBlank { "Eintrag ${item.id}" })
item.durationText?.takeIf { it.isNotBlank() }?.let { append("$it") }
?: item.duration?.let { append("${it} min") }
}
y = canvas.drawStatic("$line", bodyPaint, MARGIN, y)
}
}
doc.finishPage(page)
FileOutputStream(outFile).use { doc.writeTo(it) }
doc.close()
}

View File

@@ -0,0 +1,22 @@
package de.tt_tagebuch.app.pdf
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import de.tt_tagebuch.app.BuildConfig
import java.io.File
fun sharePdfFile(context: Context, file: File, chooserTitle: String) {
val uri = FileProvider.getUriForFile(
context,
"${BuildConfig.APPLICATION_ID}.fileprovider",
file,
)
val send = Intent(Intent.ACTION_SEND).apply {
type = "application/pdf"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(send, chooserTitle).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(chooser)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
package de.tt_tagebuch.app.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import coil.compose.AsyncImage
import coil.request.ImageRequest
import okhttp3.Headers
@Composable
fun AuthenticatedAsyncImage(
imageUrl: String,
authHeaders: Map<String, String>,
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
val context = LocalContext.current
val request = remember(imageUrl, authHeaders) {
val hb = Headers.Builder()
authHeaders.forEach { (k, v) -> hb.add(k, v) }
ImageRequest.Builder(context)
.data(imageUrl)
.headers(hb.build())
.crossfade(true)
.build()
}
AsyncImage(
model = request,
contentDescription = contentDescription,
modifier = modifier,
contentScale = ContentScale.Fit,
)
}

View File

@@ -0,0 +1,92 @@
package de.tt_tagebuch.app.ui
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.FileProvider
import com.yalantis.ucrop.UCrop
import java.io.File
private fun cropCacheDir(context: Context): File =
File(context.cacheDir, "member_crop").also { it.mkdirs() }
/**
* Kopiert die gewählte [Uri] in eine Cache-Datei (FileProvider), damit UCrop zuverlässig lesen kann.
*/
fun copyPickedImageToCacheForCrop(context: Context, source: Uri, memberId: Int): Uri {
val dir = cropCacheDir(context)
val inFile = File(dir, "pick-$memberId-${System.currentTimeMillis()}.jpg")
context.contentResolver.openInputStream(source)?.use { input ->
inFile.outputStream().use { output -> input.copyTo(output) }
}
return FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
inFile,
)
}
fun createCropOutputUri(context: Context, memberId: Int): Pair<File, Uri> {
val dir = cropCacheDir(context)
val outFile = File(dir, "crop-out-$memberId-${System.currentTimeMillis()}.jpg")
if (outFile.exists()) outFile.delete()
outFile.createNewFile()
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
outFile,
)
return outFile to uri
}
fun buildMemberPortraitCropIntent(context: Context, sourceUri: Uri, destinationUri: Uri): Intent {
val options = UCrop.Options().apply {
setCompressionFormat(Bitmap.CompressFormat.JPEG)
setCompressionQuality(90)
setHideBottomControls(false)
setFreeStyleCropEnabled(true)
}
return UCrop.of(sourceUri, destinationUri)
.withAspectRatio(1f, 1f)
.withMaxResultSize(2048, 2048)
.withOptions(options)
.getIntent(context)
.apply {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
}
}
@Composable
fun rememberMemberPortraitCropLauncher(
onCropped: (ByteArray) -> Unit,
onCancelledOrError: (() -> Unit)? = null,
): ActivityResultLauncher<Intent> {
val context = LocalContext.current
val appContext = remember(context) { context.applicationContext }
return rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK || result.data == null) {
onCancelledOrError?.invoke()
return@rememberLauncherForActivityResult
}
val data = result.data!!
if (UCrop.getError(data) != null) {
onCancelledOrError?.invoke()
return@rememberLauncherForActivityResult
}
val outUri = UCrop.getOutput(data) ?: run {
onCancelledOrError?.invoke()
return@rememberLauncherForActivityResult
}
runCatching {
appContext.contentResolver.openInputStream(outUri)?.use { it.readBytes() }
}.getOrNull()?.let { onCropped(it) } ?: onCancelledOrError?.invoke()
}
}

View File

@@ -0,0 +1,28 @@
package de.tt_tagebuch.app.ui
import androidx.compose.material.MaterialTheme
import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/** Orientierung an `frontend/src/assets/css/main.scss` (:root). */
object TtAppColors {
val NavRailBackground = Color(0xFFEDF4F0)
}
private val TtLightColors = lightColors(
primary = Color(0xFF2F7A5F),
primaryVariant = Color(0xFF184636),
secondary = Color(0xFFB56E41),
background = Color(0xFFF4F6F3),
surface = Color(0xFFFFFFFF),
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = Color(0xFF333333),
onSurface = Color(0xFF333333),
)
@Composable
fun TtTagebuchTheme(content: @Composable () -> Unit) {
MaterialTheme(colors = TtLightColors, content = content)
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="pdf_cache" path="." />
</paths>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Lokales HTTP (Cleartext) für Entwicklung: ab API 28 sonst "CLEARTEXT ... not permitted". -->
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">10.0.2.2</domain>
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
</domain-config>
</network-security-config>

View File

@@ -10,6 +10,7 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.getScreenModel
import de.tt_tagebuch.app.viewmodel.DiaryScreenModel
@@ -18,6 +19,7 @@ import de.tt_tagebuch.app.viewmodel.DiaryState
class DiaryScreen(private val clubId: Int) : Screen {
@Composable
override fun Content() {
val navigator = LocalNavigator.current
val viewModel = getScreenModel<DiaryScreenModel>()
val state by viewModel.state.collectAsState()

View File

@@ -1,10 +1,13 @@
package de.tt_tagebuch.app.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Person
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.Navigator
@@ -30,7 +33,7 @@ class HomeScreen(private val clubId: Int) : Screen {
}
}
) { padding ->
Box(modifier = androidx.compose.foundation.layout.padding(padding)) {
Box(modifier = Modifier.padding(padding)) {
when (selectedItem) {
0 -> DiaryScreen(clubId).Content()
1 -> MemberScreen(clubId).Content()

View File

@@ -6,6 +6,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Phone
import androidx.compose.runtime.*
@@ -35,7 +36,7 @@ class MemberDetailScreen(private val member: Member) : Screen {
IconButton(onClick = {
navigator?.push(MemberEditScreen(member.clubId, member))
}) {
Icon(androidx.compose.material.icons.filled.Edit, contentDescription = "Edit")
Icon(Icons.Default.Edit, contentDescription = "Edit")
}
}
)
@@ -57,7 +58,7 @@ class MemberDetailScreen(private val member: Member) : Screen {
}
if (member.testMembership) {
Spacer(Modifier.width(8.dp))
StatusBadge("Test", Color.Orange)
StatusBadge("Test", Color(0xFFFFA500))
}
}

View File

@@ -34,7 +34,7 @@ class MemberEditScreen(private val clubId: Int, private val member: Member? = nu
var gender by remember { mutableStateOf(member?.gender ?: "unknown") }
var active by remember { mutableStateOf(member?.active ?: true) }
var testMembership by remember { mutableStateOf(member?.testMembership ?: true) }
var contacts by remember { mutableStateOf(member?.contacts?.toMutableList() ?: mutableListOf()) }
var contacts by remember { mutableStateOf<List<MemberContact>>(member?.contacts?.toMutableList() ?: mutableListOf<MemberContact>()) }
Scaffold(
topBar = {

View File

@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.*
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@@ -67,6 +68,7 @@ class MemberScreen(private val clubId: Int) : Screen {
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun MemberItem(member: de.tt_tagebuch.shared.models.Member, onClick: () -> Unit) {
ListItem(

View File

@@ -32,3 +32,13 @@ org.gradle.configuration-cache=true
kotlin.code.style=official
android.useAndroidX=true
android.nonTransitiveRClass=true
# Backend für die Standard-App-Installation.
# Hinweis: In Android Studio unter Settings → Build Tools → Gradle können „Command-line options“
# gesetzt sein (z. B. -PbackendBaseUrl=...) die überschreiben u. U. diese Zeile.
backendBaseUrl=https://tt-tagebuch.de
# Temporary workaround for AGP 9.x + Kotlin Multiplatform plugin incompatibility.
# (See AGP error message suggesting these flags.)
android.builtInKotlin=false
android.newDsl=false

View File

@@ -0,0 +1,13 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e99bae143b75f9a10ead10248f02055e/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/04e088f8677de3b384108493cc9481d0/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/56a19bc915b9ba2eb62ba7554c61b919/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/398ffe3949748bfb1d5636f023d228fd/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e55dccbfe27cb97945148c61a39c89c5/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/dbd05c4936d573642f94cd149e1356c8/redirect
toolchainVendor=JETBRAINS
toolchainVersion=21

View File

@@ -1,34 +1,41 @@
[versions]
agp = "8.2.2"
android-compileSdk = "34"
agp = "9.1.1"
android-compileSdk = "35"
android-minSdk = "24"
android-targetSdk = "34"
android-targetSdk = "35"
androidx-activityCompose = "1.8.2"
androidx-appcompat = "1.6.1"
androidx-constraintlayout = "2.1.4"
androidx-core-ktx = "1.12.0"
androidx-security-crypto = "1.1.0-alpha06"
androidx-espresso-core = "3.5.1"
androidx-material = "1.11.0"
androidx-test-junit = "1.1.5"
compose = "1.6.1"
compose-plugin = "1.6.1"
compose-plugin = "1.10.3"
junit = "4.13.2"
kotlin = "1.9.23"
kotlin = "2.1.21"
ktor = "2.3.10"
coroutines = "1.8.0"
koin = "3.5.3"
voyager = "1.0.0"
socket-io = "2.1.0"
coil = "2.6.0"
ucrop = "2.2.11"
[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core-ktx" }
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "androidx-security-crypto" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
@@ -38,6 +45,8 @@ voyager-screenmodel = { module = "cafe.adriel.voyager:voyager-screenmodel", vers
voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" }
voyager-koin = { module = "cafe.adriel.voyager:voyager-koin", version.ref = "voyager" }
socket-io-client = { module = "io.socket:socket.io-client", version.ref = "socket-io" }
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" }
yalantis-ucrop = { module = "com.github.yalantis:ucrop", version.ref = "ucrop" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://downloads.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

15
mobile-app/gradlew vendored Executable file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env sh
set -eu
APP_HOME=$(cd "$(dirname "$0")" && pwd)
CLASSPATH="$APP_HOME/gradle/wrapper/gradle-wrapper.jar"
if [ -n "${JAVA_HOME:-}" ] && [ -x "$JAVA_HOME/bin/java" ]; then
JAVA_CMD="$JAVA_HOME/bin/java"
else
JAVA_CMD="java"
fi
exec "$JAVA_CMD" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"

12
mobile-app/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,12 @@
@echo off
setlocal
set APP_HOME=%~dp0
set CLASSPATH=%APP_HOME%gradle\wrapper\gradle-wrapper.jar
if not "%JAVA_HOME%"=="" (
set JAVA_CMD=%JAVA_HOME%\bin\java.exe
) else (
set JAVA_CMD=java.exe
)
"%JAVA_CMD%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
endlocal

View File

@@ -0,0 +1,20 @@
# iOS App (SwiftUI) Setup (Planned)
Das Repo enthält aktuell den Shared-Code in `mobile-app/shared/` (KMP).
Dieser baut iOS-Frameworks (`TTShared`) für:
- `iosX64`
- `iosArm64`
- `iosSimulatorArm64`
## Nächste Schritte (wenn wir iOS aktivieren)
1. Xcode: neues SwiftUI App-Projekt anlegen (`iosApp/Trainingstagebuch.xcodeproj`).
2. Shared Framework einbinden:
- Option A (empfohlen): XCFramework Build Task in Gradle + Einbindung als binary target.
- Option B: CocoaPods Integration (KMP cocoapods plugin).
3. iOS Keychain Token Storage implementieren und in Shared injizieren.
4. SwiftUI Screens:
- Login
- Shell (Tabs)
Hinweis: Die konkrete Einbindung hängt davon ab, ob ihr SPM vs CocoaPods bevorzugt.

View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Installiert composeApp im Debug-Modus mit Emulator-tauglicher Backend-URL (Host-PC).
# Nutzung: von mobile-app/ aus: ./scripts/install-debug-emulator.sh
set -euo pipefail
cd "$(dirname "$0")/.."
adb uninstall de.tt_tagebuch.app 2>/dev/null || true
./gradlew :composeApp:clean :composeApp:installDebug \
-PbackendBaseUrl=http://10.0.2.2:3005 \
--no-configuration-cache

View File

@@ -6,11 +6,15 @@ pluginManagement {
maven("https://maven.pkg.jetbrains.space/public/p/compose/patch")
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven("https://jitpack.io")
maven("https://maven.pkg.jetbrains.space/public/p/compose/patch")
}
}

View File

@@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
@@ -7,11 +9,23 @@ plugins {
kotlin {
androidTarget {
compilations.all {
kotlinOptions {
jvmTarget = "1.8"
compilerOptions.configure {
jvmTarget.set(JvmTarget.JVM_17)
}
}
}
val iosTargets = listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64(),
)
iosTargets.forEach {
it.binaries.framework {
baseName = "TTShared"
isStatic = true
}
}
sourceSets {
commonMain.dependencies {
@@ -24,6 +38,10 @@ kotlin {
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
implementation(libs.androidx.security.crypto)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
}
}

View File

@@ -0,0 +1,9 @@
package de.tt_tagebuch.shared.api.http
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.okhttp.OkHttp
class AndroidHttpClientEngineFactory : HttpClientEngineFactory {
override fun create(): HttpClientEngine = OkHttp.create()
}

View File

@@ -0,0 +1,38 @@
package de.tt_tagebuch.shared.state
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class AndroidClubStorage(
context: Context,
) : ClubStorage {
private val prefs = EncryptedSharedPreferences.create(
context,
PREFS_NAME,
MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
override suspend fun loadCurrentClubId(): Int? = withContext(Dispatchers.Default) {
val value = prefs.getString(KEY_CURRENT_CLUB_ID, null) ?: return@withContext null
value.toIntOrNull()
}
override suspend fun saveCurrentClubId(clubId: Int?) = withContext(Dispatchers.Default) {
val editor = prefs.edit()
if (clubId == null) editor.remove(KEY_CURRENT_CLUB_ID) else editor.putString(KEY_CURRENT_CLUB_ID, clubId.toString())
editor.apply()
}
private companion object {
private const val PREFS_NAME = "tttagebuch_club"
private const val KEY_CURRENT_CLUB_ID = "currentClubId"
}
}

View File

@@ -0,0 +1,24 @@
package de.tt_tagebuch.shared.state
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class AndroidLanguageStorage(
context: Context,
) : LanguageStorage {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
override suspend fun loadLanguageCode(): String? = withContext(Dispatchers.Default) {
prefs.getString(KEY_LANGUAGE_CODE, null)
}
override suspend fun saveLanguageCode(languageCode: String) = withContext(Dispatchers.Default) {
prefs.edit().putString(KEY_LANGUAGE_CODE, languageCode).apply()
}
private companion object {
private const val PREFS_NAME = "tttagebuch_language"
private const val KEY_LANGUAGE_CODE = "languageCode"
}
}

View File

@@ -0,0 +1,49 @@
package de.tt_tagebuch.shared.state
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class AndroidTokenStorage(
context: Context,
) : TokenStorage {
private val prefs = EncryptedSharedPreferences.create(
context,
PREFS_NAME,
MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
override suspend fun load(): AuthTokens? = withContext(Dispatchers.Default) {
val token = prefs.getString(KEY_TOKEN, null)
val username = prefs.getString(KEY_USERNAME, null)
if (token.isNullOrBlank() || username.isNullOrBlank()) null else AuthTokens(token, username)
}
override suspend fun save(tokens: AuthTokens) = withContext(Dispatchers.Default) {
prefs.edit()
.putString(KEY_TOKEN, tokens.token)
.putString(KEY_USERNAME, tokens.username)
.apply()
}
override suspend fun clear() = withContext(Dispatchers.Default) {
prefs.edit()
.remove(KEY_TOKEN)
.remove(KEY_USERNAME)
.apply()
}
private companion object {
private const val PREFS_NAME = "tttagebuch_auth"
private const val KEY_TOKEN = "token"
private const val KEY_USERNAME = "username"
}
}

View File

@@ -0,0 +1,23 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.AccidentReportDto
import de.tt_tagebuch.shared.api.models.CreateAccidentBody
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
class AccidentApi(
private val client: AuthedHttpClient,
) {
suspend fun list(clubId: Int, diaryDateId: Int): List<AccidentReportDto> {
return client.http.get("/api/accident/$clubId/$diaryDateId").body()
}
suspend fun create(body: CreateAccidentBody) {
client.http.post("/api/accident") {
setBody(body)
}
}
}

View File

@@ -0,0 +1,18 @@
package de.tt_tagebuch.shared.api
data class ApiConfig(
val baseUrl: String,
)
fun ApiConfig.toAbsoluteUrl(relativeOrAbsolute: String): String {
val t = relativeOrAbsolute.trim()
if (t.startsWith("http://", ignoreCase = true) || t.startsWith("https://", ignoreCase = true)) {
return t
}
val base = baseUrl.trimEnd('/')
val path = if (t.startsWith("/")) t else "/$t"
return base + path
}
fun memberProfileImagePath(clubId: Int, memberId: Int) = "/api/clubmembers/image/$clubId/$memberId"

View File

@@ -0,0 +1,23 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.LoginRequest
import de.tt_tagebuch.shared.api.models.LoginResponse
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
class AuthApi(
private val client: AuthedHttpClient,
) {
suspend fun login(email: String, password: String): LoginResponse {
return client.http.post("/api/auth/login") {
setBody(LoginRequest(email = email, password = password))
}.body()
}
suspend fun logout() {
client.http.post("/api/auth/logout")
}
}

View File

@@ -0,0 +1,35 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.Club
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import kotlinx.serialization.Serializable
@Serializable
private data class CreateClubBody(val name: String)
class ClubsApi(
private val client: AuthedHttpClient,
) {
suspend fun listClubs(): List<Club> {
return client.http.get("/api/clubs").body()
}
suspend fun createClub(name: String): Club {
return client.http.post("/api/clubs") {
setBody(CreateClubBody(name.trim()))
}.body()
}
suspend fun getClub(clubId: Int): Club {
return client.http.get("/api/clubs/$clubId").body()
}
suspend fun requestAccess(clubId: Int) {
client.http.get("/api/clubs/request/$clubId")
}
}

View File

@@ -0,0 +1,133 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.AddDiaryNoteRequest
import de.tt_tagebuch.shared.api.models.AddFreeformActivityBody
import de.tt_tagebuch.shared.api.models.AddDiaryPlanGroupActivityRequest
import de.tt_tagebuch.shared.api.models.CreateDiaryDateRequest
import de.tt_tagebuch.shared.api.models.CreateDiaryPlanActivityRequest
import de.tt_tagebuch.shared.api.models.DiaryDate
import de.tt_tagebuch.shared.api.models.DiaryDateActivityItem
import de.tt_tagebuch.shared.api.models.DiaryFreeformActivity
import de.tt_tagebuch.shared.api.models.DiaryNote
import de.tt_tagebuch.shared.api.models.DiaryTag
import de.tt_tagebuch.shared.api.models.LinkDiaryTagRequest
import de.tt_tagebuch.shared.api.models.UpdateDiaryPlanActivityOrderRequest
import de.tt_tagebuch.shared.api.models.UpdateDiaryPlanActivityRequest
import de.tt_tagebuch.shared.api.models.UpdateDiaryTimesRequest
import de.tt_tagebuch.shared.api.models.UpdateNestedPlanGroupActivityRequest
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.put
import io.ktor.client.request.setBody
class DiaryApi(
private val client: AuthedHttpClient,
) {
suspend fun listDates(clubId: Int): List<DiaryDate> {
return client.http.get("/api/diary/$clubId").body()
}
suspend fun listFreeformActivities(diaryDateId: Int): List<DiaryFreeformActivity> {
return client.http.get("/api/activities/$diaryDateId").body()
}
suspend fun addFreeformActivity(diaryDateId: Int, description: String): DiaryFreeformActivity {
return client.http.post("/api/activities/add") {
setBody(AddFreeformActivityBody(diaryDateId = diaryDateId, description = description.trim()))
}.body()
}
suspend fun listDateActivities(clubId: Int, diaryDateId: Int): List<DiaryDateActivityItem> {
return client.http.get("/api/diary-date-activities/$clubId/$diaryDateId").body()
}
suspend fun createDateActivity(clubId: Int, body: CreateDiaryPlanActivityRequest) {
client.http.post("/api/diary-date-activities/$clubId") {
setBody(body)
}
}
suspend fun addDateGroupActivity(body: AddDiaryPlanGroupActivityRequest) {
client.http.post("/api/diary-date-activities/group") {
setBody(body)
}
}
suspend fun updateDateActivity(clubId: Int, activityId: Int, body: UpdateDiaryPlanActivityRequest) {
client.http.put("/api/diary-date-activities/$clubId/$activityId") {
setBody(body)
}
}
suspend fun updateDateActivityOrder(clubId: Int, activityId: Int, orderId: Int) {
client.http.put("/api/diary-date-activities/$clubId/$activityId/order") {
setBody(UpdateDiaryPlanActivityOrderRequest(orderId))
}
}
suspend fun deleteDateActivity(clubId: Int, activityId: Int) {
client.http.delete("/api/diary-date-activities/$clubId/$activityId")
}
suspend fun updateNestedGroupActivity(clubId: Int, groupActivityId: Int, body: UpdateNestedPlanGroupActivityRequest) {
client.http.put("/api/diary-date-activities/group/$clubId/$groupActivityId") {
setBody(body)
}
}
suspend fun deleteNestedGroupActivity(clubId: Int, groupActivityId: Int) {
client.http.delete("/api/diary-date-activities/group/$clubId/$groupActivityId")
}
suspend fun createDate(clubId: Int, date: String, trainingStart: String?, trainingEnd: String?): DiaryDate {
return client.http.post("/api/diary/$clubId") {
setBody(CreateDiaryDateRequest(date, trainingStart, trainingEnd))
}.body()
}
suspend fun updateTimes(clubId: Int, dateId: Int, trainingStart: String?, trainingEnd: String?): DiaryDate {
return client.http.put("/api/diary/$clubId") {
setBody(UpdateDiaryTimesRequest(dateId, trainingStart, trainingEnd))
}.body()
}
suspend fun deleteDate(clubId: Int, dateId: Int) {
client.http.delete("/api/diary/$clubId/$dateId")
}
suspend fun addNote(diaryDateId: Int, content: String): List<DiaryNote> {
return client.http.post("/api/diary/note") {
setBody(AddDiaryNoteRequest(diaryDateId, content))
}.body()
}
suspend fun deleteNote(noteId: Int): List<DiaryNote> {
return client.http.delete("/api/diary/note/$noteId").body()
}
suspend fun createTag(name: String): DiaryTag {
return client.http.post("/api/tags") {
setBody(mapOf("name" to name))
}.body()
}
suspend fun listTags(): List<DiaryTag> {
return client.http.get("/api/tags").body()
}
suspend fun linkTag(clubId: Int, diaryDateId: Int, tagId: Int): List<DiaryTag> {
return client.http.post("/api/diary/tag/$clubId/add-tag") {
setBody(LinkDiaryTagRequest(diaryDateId, tagId))
}.body()
}
suspend fun removeTag(clubId: Int, tagId: Int) {
client.http.delete("/api/diary/$clubId/tag") {
parameter("tagId", tagId)
}
}
}

View File

@@ -0,0 +1,28 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.AddMemberActivityParticipantsBody
import de.tt_tagebuch.shared.api.models.DiaryMemberActivityLink
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
class DiaryMemberActivitiesApi(
private val client: AuthedHttpClient,
) {
suspend fun list(clubId: Int, diaryDateOrGroupActivityId: Int): List<DiaryMemberActivityLink> {
return client.http.get("/api/diary-member-activities/$clubId/$diaryDateOrGroupActivityId").body()
}
suspend fun add(clubId: Int, diaryDateOrGroupActivityId: Int, participantIds: List<Int>) {
client.http.post("/api/diary-member-activities/$clubId/$diaryDateOrGroupActivityId") {
setBody(AddMemberActivityParticipantsBody(participantIds = participantIds))
}
}
suspend fun remove(clubId: Int, diaryDateOrGroupActivityId: Int, participantRowId: Int) {
client.http.delete("/api/diary-member-activities/$clubId/$diaryDateOrGroupActivityId/$participantRowId")
}
}

View File

@@ -0,0 +1,56 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.AddDiaryMemberNoteBody
import de.tt_tagebuch.shared.api.models.DiaryMemberNoteDto
import de.tt_tagebuch.shared.api.models.DiaryMemberTagLinkDto
import de.tt_tagebuch.shared.api.models.DiaryMemberTagMutationBody
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
class DiaryMemberApi(
private val client: AuthedHttpClient,
) {
suspend fun listNotes(clubId: Int, diaryDateId: Int, memberId: Int): List<DiaryMemberNoteDto> {
return client.http.get("/api/diarymember/$clubId/note") {
parameter("diaryDateId", diaryDateId)
parameter("memberId", memberId)
}.body()
}
suspend fun addNote(clubId: Int, body: AddDiaryMemberNoteBody): List<DiaryMemberNoteDto> {
return client.http.post("/api/diarymember/$clubId/note") {
setBody(body)
}.body()
}
suspend fun deleteNote(clubId: Int, noteId: Int, diaryDateId: Int, memberId: Int): List<DiaryMemberNoteDto> {
return client.http.delete("/api/diarymember/$clubId/note/$noteId") {
parameter("diaryDateId", diaryDateId)
parameter("memberId", memberId)
}.body()
}
suspend fun listTags(clubId: Int, diaryDateId: Int, memberId: Int): List<DiaryMemberTagLinkDto> {
return client.http.get("/api/diarymember/$clubId/tag") {
parameter("diaryDateId", diaryDateId)
parameter("memberId", memberId)
}.body()
}
suspend fun addTag(clubId: Int, body: DiaryMemberTagMutationBody): List<DiaryMemberTagLinkDto> {
return client.http.post("/api/diarymember/$clubId/tag") {
setBody(body)
}.body()
}
suspend fun removeTag(clubId: Int, body: DiaryMemberTagMutationBody): List<DiaryMemberTagLinkDto> {
return client.http.post("/api/diarymember/$clubId/tag/remove") {
setBody(body)
}.body()
}
}

View File

@@ -0,0 +1,42 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.CreateTrainingGroupBody
import de.tt_tagebuch.shared.api.models.DeleteTrainingGroupBody
import de.tt_tagebuch.shared.api.models.DiaryPlanGroup
import de.tt_tagebuch.shared.api.models.UpdateTrainingGroupBody
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.put
import io.ktor.client.request.setBody
/**
* Trainingsgruppen am Tagebuch-Tag (`/api/group`), für gruppenbezogene Plan-Einträge.
*/
class GroupApi(
private val client: AuthedHttpClient,
) {
suspend fun listForDiaryDate(clubId: Int, diaryDateId: Int): List<DiaryPlanGroup> {
return client.http.get("/api/group/$clubId/$diaryDateId").body()
}
suspend fun create(body: CreateTrainingGroupBody): DiaryPlanGroup {
return client.http.post("/api/group") {
setBody(body)
}.body()
}
suspend fun update(groupId: Int, body: UpdateTrainingGroupBody): DiaryPlanGroup {
return client.http.put("/api/group/$groupId") {
setBody(body)
}.body()
}
suspend fun delete(groupId: Int, body: DeleteTrainingGroupBody) {
client.http.delete("/api/group/$groupId") {
setBody(body)
}
}
}

View File

@@ -0,0 +1,24 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.MemberActivityStatDto
import de.tt_tagebuch.shared.api.models.MemberLastParticipationDto
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.parameter
class MemberActivitiesApi(
private val client: AuthedHttpClient,
) {
suspend fun listActivityStats(clubId: Int, memberId: Int, period: String = "year"): List<MemberActivityStatDto> {
return client.http.get("/api/member-activities/$clubId/$memberId") {
parameter("period", period)
}.body()
}
suspend fun listLastParticipations(clubId: Int, memberId: Int, limit: Int = 12): List<MemberLastParticipationDto> {
return client.http.get("/api/member-activities/$clubId/$memberId/last-participations") {
parameter("limit", limit)
}.body()
}
}

View File

@@ -0,0 +1,51 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.MemberGroupPhotoDto
import de.tt_tagebuch.shared.api.models.MemberGroupPhotoListResponse
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.forms.MultiPartFormDataContent
import io.ktor.client.request.forms.formData
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.Headers
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
class MemberGroupPhotosApi(
private val client: AuthedHttpClient,
) {
suspend fun list(clubId: Int): List<MemberGroupPhotoDto> {
val res: MemberGroupPhotoListResponse = client.http.get("/api/member-group-photos/$clubId").body()
return res.photos
}
suspend fun upload(clubId: Int, imageBytes: ByteArray, title: String, description: String) {
client.http.post("/api/member-group-photos/$clubId") {
contentType(ContentType.MultiPart.FormData)
setBody(
MultiPartFormDataContent(
formData {
append("title", title.ifBlank { "Gruppenfoto" })
append("description", description)
append(
"image",
imageBytes,
Headers.build {
append(HttpHeaders.ContentType, "image/jpeg")
append(HttpHeaders.ContentDisposition, "filename=\"group.jpg\"")
},
)
},
),
)
}
}
suspend fun delete(clubId: Int, photoId: Int) {
client.http.delete("/api/member-group-photos/$clubId/$photoId")
}
}

View File

@@ -0,0 +1,54 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.Member
import de.tt_tagebuch.shared.api.models.MemberSetBody
import io.ktor.client.call.body
import io.ktor.client.request.forms.formData
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.Headers
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import io.ktor.client.request.forms.MultiPartFormDataContent
class MembersApi(
private val client: AuthedHttpClient,
) {
suspend fun listMembers(clubId: Int, showAll: Boolean = true): List<Member> {
return client.http.get("/api/clubmembers/get/$clubId/$showAll").body()
}
suspend fun setMember(clubId: Int, body: MemberSetBody) {
client.http.post("/api/clubmembers/set/$clubId") {
setBody(body)
}
}
suspend fun uploadMemberImage(clubId: Int, memberId: Int, imageBytes: ByteArray, makePrimary: Boolean = true) {
client.http.post("/api/clubmembers/image/$clubId/$memberId") {
if (makePrimary) {
parameter("makePrimary", "true")
}
contentType(ContentType.MultiPart.FormData)
setBody(
MultiPartFormDataContent(
formData {
append(
"image",
imageBytes,
Headers.build {
append(HttpHeaders.ContentType, "image/jpeg")
append(HttpHeaders.ContentDisposition, "filename=\"member.jpg\"")
},
)
},
),
)
}
}
}

View File

@@ -0,0 +1,44 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.DiaryTrainingParticipant
import de.tt_tagebuch.shared.api.models.ParticipantGroupRequest
import de.tt_tagebuch.shared.api.models.ParticipantMutationRequest
import io.ktor.client.call.body
import de.tt_tagebuch.shared.api.models.ParticipantStatusRequest
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.put
import io.ktor.client.request.setBody
class ParticipantsApi(
private val client: AuthedHttpClient,
) {
suspend fun listForDate(diaryDateId: Int): List<DiaryTrainingParticipant> {
return client.http.get("/api/participants/$diaryDateId").body()
}
suspend fun add(diaryDateId: Int, memberId: Int): DiaryTrainingParticipant {
return client.http.post("/api/participants/add") {
setBody(ParticipantMutationRequest(diaryDateId, memberId))
}.body()
}
suspend fun remove(diaryDateId: Int, memberId: Int) {
client.http.post("/api/participants/remove") {
setBody(ParticipantMutationRequest(diaryDateId, memberId))
}
}
suspend fun updateAttendanceStatus(diaryDateId: Int, memberId: Int, attendanceStatus: String) {
client.http.put("/api/participants/$diaryDateId/$memberId/status") {
setBody(ParticipantStatusRequest(attendanceStatus = attendanceStatus))
}
}
suspend fun updateParticipantGroup(diaryDateId: Int, memberId: Int, groupId: Int?) {
client.http.put("/api/participants/$diaryDateId/$memberId/group") {
setBody(ParticipantGroupRequest(groupId = groupId))
}
}
}

View File

@@ -0,0 +1,15 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.UserClubPermissions
import io.ktor.client.call.body
import io.ktor.client.request.get
class PermissionsApi(
private val client: AuthedHttpClient,
) {
suspend fun getUserPermissions(clubId: Int): UserClubPermissions {
return client.http.get("/api/permissions/$clubId").body()
}
}

View File

@@ -0,0 +1,28 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.PredefinedActivityDto
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.parameter
class PredefinedActivitiesApi(
private val client: AuthedHttpClient,
) {
suspend fun list(scope: String? = null): List<PredefinedActivityDto> {
return client.http.get("/api/predefined-activities") {
if (scope != null) parameter("scope", scope)
}.body()
}
suspend fun search(query: String, limit: Int = 20): List<PredefinedActivityDto> {
return client.http.get("/api/predefined-activities/search/query") {
parameter("q", query)
parameter("limit", limit)
}.body()
}
suspend fun getById(id: Int): PredefinedActivityDto {
return client.http.get("/api/predefined-activities/$id").body()
}
}

View File

@@ -0,0 +1,37 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.PublicHttpClient
import de.tt_tagebuch.shared.api.models.ForgotPasswordRequest
import de.tt_tagebuch.shared.api.models.MessageResponse
import de.tt_tagebuch.shared.api.models.RegisterRequest
import de.tt_tagebuch.shared.api.models.ResetPasswordRequest
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
class PublicAuthApi(
private val client: PublicHttpClient,
) {
suspend fun register(email: String, password: String) {
client.http.post("/api/auth/register") {
setBody(RegisterRequest(email = email, password = password))
}
}
suspend fun activate(activationCode: String) {
client.http.get("/api/auth/activate/$activationCode")
}
suspend fun forgotPassword(email: String): MessageResponse {
return client.http.post("/api/auth/forgot-password") {
setBody(ForgotPasswordRequest(email = email))
}.body()
}
suspend fun resetPassword(token: String, password: String): MessageResponse {
return client.http.post("/api/auth/reset-password") {
setBody(ResetPasswordRequest(token = token, password = password))
}.body()
}
}

View File

@@ -0,0 +1,15 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.SessionStatusResponse
import io.ktor.client.call.body
import io.ktor.client.request.get
class SessionApi(
private val client: AuthedHttpClient,
) {
suspend fun status(): SessionStatusResponse {
return client.http.get("/api/session/status").body()
}
}

View File

@@ -0,0 +1,34 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.TrainingGroupDto
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
class TrainingGroupsApi(
private val client: AuthedHttpClient,
) {
suspend fun listGroups(clubId: Int): List<TrainingGroupDto> {
return client.http.get("/api/training-groups/$clubId").body()
}
suspend fun listMemberGroups(clubId: Int, memberId: Int): List<TrainingGroupDto> {
return client.http.get("/api/training-groups/$clubId/member/$memberId").body()
}
suspend fun addMemberToGroup(clubId: Int, groupId: Int, memberId: Int) {
client.http.post("/api/training-groups/$clubId/$groupId/member/$memberId") {
contentType(ContentType.Application.Json)
setBody("{}")
}
}
suspend fun removeMemberFromGroup(clubId: Int, groupId: Int, memberId: Int) {
client.http.delete("/api/training-groups/$clubId/$groupId/member/$memberId")
}
}

View File

@@ -0,0 +1,15 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.TrainingStats
import io.ktor.client.call.body
import io.ktor.client.request.get
class TrainingStatsApi(
private val client: AuthedHttpClient,
) {
suspend fun getStats(clubId: Int): TrainingStats {
return client.http.get("/api/training-stats/$clubId").body()
}
}

View File

@@ -0,0 +1,14 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.TrainingGroupDto
import io.ktor.client.call.body
import io.ktor.client.request.get
class TrainingTimesApi(
private val client: AuthedHttpClient,
) {
suspend fun listGroupsWithTimes(clubId: Int): List<TrainingGroupDto> {
return client.http.get("/api/training-times/$clubId").body()
}
}

View File

@@ -0,0 +1,35 @@
package de.tt_tagebuch.shared.api.http
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
private val looseJson = Json { ignoreUnknownKeys = true }
/**
* Liest JSON-Fehlerantworten (`error`, `message`) wie die Node-API sie oft liefert.
*/
internal suspend fun HttpResponse.userFacingErrorOr(fallback: String): String {
val raw = runCatching { bodyAsText() }.getOrNull().orEmpty().trim()
if (raw.isEmpty()) return fallback
val extracted = runCatching {
val el = looseJson.parseToJsonElement(raw)
if (el !is JsonObject) return@runCatching null
el["error"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() }
?: el["message"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() }
}.getOrNull()
val text = extracted ?: raw.take(500).let { if (raw.length > 500) "$it" else it }
return mapKnownBackendErrorTokens(text)
}
private fun mapKnownBackendErrorTokens(raw: String): String {
return when (raw.trim().lowercase()) {
"alreadyexists" -> "Ein Verein mit diesem Namen existiert bereits."
"noaccess" -> "Kein Zugriff auf diese Ressource."
"internalerror" -> "Serverfehler. Bitte später erneut versuchen."
"notrequested" -> "Für diesen Verein wurde kein Zugriff beantragt."
else -> raw
}
}

View File

@@ -0,0 +1,7 @@
package de.tt_tagebuch.shared.api.http
class ApiException(
val statusCode: Int,
message: String,
) : RuntimeException(message)

View File

@@ -0,0 +1,61 @@
package de.tt_tagebuch.shared.api.http
import de.tt_tagebuch.shared.api.ApiConfig
import de.tt_tagebuch.shared.state.TokenProvider
import io.ktor.client.HttpClient
import io.ktor.client.plugins.DefaultRequest
import io.ktor.client.plugins.HttpResponseValidator
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.header
import io.ktor.client.request.url
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
class AuthedHttpClient(
apiConfig: ApiConfig,
private val tokenProvider: TokenProvider,
httpClientEngineFactory: HttpClientEngineFactory,
private val onUnauthorized: () -> Unit = {},
) {
val http: HttpClient = HttpClient(httpClientEngineFactory.create()) {
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
}
)
}
install(HttpTimeout) {
requestTimeoutMillis = 60_000
connectTimeoutMillis = 15_000
socketTimeoutMillis = 60_000
}
HttpResponseValidator {
validateResponse { response ->
val statusCode = response.status.value
if (statusCode == 401) {
onUnauthorized()
val detail = response.userFacingErrorOr("Session abgelaufen")
throw ApiException(statusCode, detail)
}
if (statusCode >= 400) {
val detail = response.userFacingErrorOr("API Fehler $statusCode")
throw ApiException(statusCode, detail)
}
}
}
install(DefaultRequest) {
url(apiConfig.baseUrl)
contentType(ContentType.Application.Json)
tokenProvider.token?.let { token ->
header("authcode", token)
}
tokenProvider.username?.let { username ->
header("userid", username)
}
}
}
}

View File

@@ -0,0 +1,8 @@
package de.tt_tagebuch.shared.api.http
import io.ktor.client.engine.HttpClientEngine
interface HttpClientEngineFactory {
fun create(): HttpClientEngine
}

View File

@@ -0,0 +1,50 @@
package de.tt_tagebuch.shared.api.http
import de.tt_tagebuch.shared.api.ApiConfig
import io.ktor.client.HttpClient
import io.ktor.client.plugins.DefaultRequest
import io.ktor.client.plugins.HttpResponseValidator
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.url
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
/**
* HTTP-Client ohne Auth-Header für Login, Registrierung, Passwort-Reset.
* 401 löst **keinen** globalen Logout aus (nur [ApiException]).
*/
class PublicHttpClient(
apiConfig: ApiConfig,
httpClientEngineFactory: HttpClientEngineFactory,
) {
val http: HttpClient = HttpClient(httpClientEngineFactory.create()) {
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
},
)
}
install(HttpTimeout) {
requestTimeoutMillis = 60_000
connectTimeoutMillis = 15_000
socketTimeoutMillis = 60_000
}
HttpResponseValidator {
validateResponse { response ->
val statusCode = response.status.value
if (statusCode >= 400) {
val detail = response.userFacingErrorOr("API Fehler $statusCode")
throw ApiException(statusCode, detail)
}
}
}
install(DefaultRequest) {
url(apiConfig.baseUrl)
contentType(ContentType.Application.Json)
}
}
}

View File

@@ -0,0 +1,23 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class AccidentReportDto(
val accident: String,
val firstName: String? = null,
val lastName: String? = null,
)
fun AccidentReportDto.memberLabel(): String {
val parts = listOfNotNull(firstName?.trim()?.takeIf { it.isNotEmpty() }, lastName?.trim()?.takeIf { it.isNotEmpty() })
return if (parts.isEmpty()) "" else parts.joinToString(" ")
}
@Serializable
data class CreateAccidentBody(
val clubId: Int,
val memberId: Int,
val diaryDateId: Int,
val accident: String,
)

View File

@@ -0,0 +1,14 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class Club(
val id: Int,
val name: String,
val greetingText: String? = null,
val associationMemberNumber: String? = null,
val myTischtennisFedNickname: String? = null,
val autoFetchRankings: Boolean? = null,
)

View File

@@ -0,0 +1,32 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
private fun JsonObject.boolAt(module: String, key: String): Boolean {
val mod = this[module]?.jsonObject ?: return false
return mod[key]?.jsonPrimitive?.booleanOrNull == true
}
/** Vereinsbesitzer haben volle Rechte. */
fun UserClubPermissions.canReadDiary(): Boolean {
if (isOwner) return true
return permissions.boolAt("diary", "read")
}
fun UserClubPermissions.canWriteDiary(): Boolean {
if (isOwner) return true
return permissions.boolAt("diary", "write")
}
fun UserClubPermissions.canReadMembers(): Boolean {
if (isOwner) return true
return permissions.boolAt("members", "read")
}
fun UserClubPermissions.canWriteMembers(): Boolean {
if (isOwner) return true
return permissions.boolAt("members", "write")
}

View File

@@ -0,0 +1,30 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
/** Freitext-Aktivitäten zum Tag (`/api/activities`), nicht der Trainingsplan. */
@Serializable
data class DiaryFreeformActivity(
val id: Int,
val description: String,
val diaryDateId: Int,
)
@Serializable
data class AddFreeformActivityBody(
val diaryDateId: Int,
val description: String,
)
/** Verknüpfung Teilnehmer-Zeile ↔ Plan-/Gruppen-Aktivität (`/api/diary-member-activities`). */
@Serializable
data class DiaryMemberActivityLink(
val id: Int,
val diaryDateActivityId: Int,
val participantId: Int,
)
@Serializable
data class AddMemberActivityParticipantsBody(
val participantIds: List<Int>,
)

View File

@@ -0,0 +1,53 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class DiaryDate(
val id: Int,
val clubId: Int,
val date: String,
val trainingStart: String? = null,
val trainingEnd: String? = null,
val diaryNotes: List<DiaryNote> = emptyList(),
val diaryTags: List<DiaryTag> = emptyList(),
)
@Serializable
data class DiaryNote(
val id: Int,
val content: String? = null,
val createdAt: String? = null,
)
@Serializable
data class DiaryTag(
val id: Int,
val name: String,
)
@Serializable
data class CreateDiaryDateRequest(
val date: String,
val trainingStart: String? = null,
val trainingEnd: String? = null,
)
@Serializable
data class UpdateDiaryTimesRequest(
val dateId: Int,
val trainingStart: String? = null,
val trainingEnd: String? = null,
)
@Serializable
data class LinkDiaryTagRequest(
val diaryDateId: Int,
val tagId: Int,
)
@Serializable
data class AddDiaryNoteRequest(
val diaryDateId: Int,
val content: String,
)

View File

@@ -0,0 +1,55 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class DiaryDateActivityItem(
val id: Int,
val orderId: Int = 0,
val isTimeblock: Boolean = false,
val duration: Int? = null,
val durationText: String? = null,
/** Trainingsgruppe (Tabellen `Group`) für getrennte Pläne am selben Tag. */
val groupId: Int? = null,
val planGroup: DiaryPlanGroupSummary? = null,
val predefinedActivity: PredefinedActivitySummary? = null,
val groupActivities: List<GroupActivitySummary> = emptyList(),
)
@Serializable
data class DiaryPlanGroupSummary(
val id: Int? = null,
val name: String? = null,
)
@Serializable
data class PredefinedActivitySummary(
val id: Int? = null,
val name: String? = null,
val code: String? = null,
/** Wie Backend: z. B. `/api/predefined-activities/…/image/…` */
val imageLink: String? = null,
val imageUrl: String? = null,
)
@Serializable
data class GroupActivitySummary(
val id: Int? = null,
val orderId: Int? = null,
val groupPredefinedActivity: PredefinedActivitySummary? = null,
)
fun PredefinedActivitySummary?.displayLabel(): String {
if (this == null) return ""
val n = name?.trim().orEmpty()
if (n.isNotEmpty()) return n
val c = code?.trim().orEmpty()
if (c.isNotEmpty()) return c
return ""
}
fun DiaryDateActivityItem.displayTitle(fallbackTimeblock: String): String {
val label = predefinedActivity.displayLabel()
if (label.isNotEmpty()) return label
return if (isTimeblock) fallbackTimeblock else ""
}

View File

@@ -0,0 +1,23 @@
package de.tt_tagebuch.shared.api.models
private val imageIdInPath = Regex("/image/(\\d+)")
fun PredefinedActivitySummary?.hasDisplayableImage(): Boolean {
if (this == null) return false
val link = imageLink?.trim().orEmpty()
if (link.isEmpty()) return false
return imageIdInPath.containsMatchIn(link)
}
/** Relativer API-Pfad (beginnt mit `/`) oder `null`. */
fun PredefinedActivitySummary?.imageRelativePath(): String? {
if (!hasDisplayableImage()) return null
val link = this!!.imageLink!!.trim()
return if (link.startsWith("/")) link else "/$link"
}
fun DiaryDateActivityItem.mainActivityImagePath(): String? =
predefinedActivity.imageRelativePath()
fun GroupActivitySummary.nestedActivityImagePath(): String? =
groupPredefinedActivity.imageRelativePath()

View File

@@ -0,0 +1,51 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class DiaryMemberNoteDto(
val id: Int,
val memberId: Int? = null,
val diaryDateId: Int? = null,
val content: String? = null,
val createdAt: String? = null,
)
@Serializable
data class DiaryTagNested(
val id: Int,
val name: String? = null,
val label: String? = null,
)
@Serializable
data class DiaryMemberTagLinkDto(
val id: Int,
val memberId: Int? = null,
val diaryDateId: Int? = null,
val tagId: Int? = null,
val tag: DiaryTagNested? = null,
)
fun DiaryMemberTagLinkDto.tagDefinitionId(): Int = tag?.id ?: tagId ?: 0
fun DiaryMemberTagLinkDto.tagDisplayName(): String {
val fromNested = tag?.label?.takeIf { it.isNotBlank() } ?: tag?.name?.takeIf { it.isNotBlank() }
if (!fromNested.isNullOrBlank()) return fromNested
val tid = tagDefinitionId()
return if (tid > 0) "Tag $tid" else "Tag"
}
@Serializable
data class AddDiaryMemberNoteBody(
val memberId: Int,
val diaryDateId: Int,
val content: String,
)
@Serializable
data class DiaryMemberTagMutationBody(
val diaryDateId: Int,
val memberId: Int,
val tagId: Int,
)

View File

@@ -0,0 +1,80 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class DiaryPlanGroup(
val id: Int,
val name: String? = null,
val lead: String? = null,
val diaryDateId: Int? = null,
)
@Serializable
data class CreateDiaryPlanActivityRequest(
val diaryDateId: Int,
val activity: String = "",
val predefinedActivityId: Int? = null,
val duration: Int? = null,
val durationText: String? = null,
val isTimeblock: Boolean = false,
val groupId: Int? = null,
)
@Serializable
data class AddDiaryPlanGroupActivityRequest(
val clubId: Int,
val diaryDateId: Int,
val groupId: Int,
val activity: String,
val predefinedActivityId: Int? = null,
val timeblockId: Int? = null,
val duration: Int? = null,
val durationText: String? = null,
)
@Serializable
data class UpdateDiaryPlanActivityRequest(
val predefinedActivityId: Int? = null,
val customActivityName: String? = null,
val duration: Int? = null,
val durationText: String? = null,
val orderId: Int? = null,
val groupId: Int? = null,
)
@Serializable
data class UpdateDiaryPlanActivityOrderRequest(
val orderId: Int,
)
@Serializable
data class UpdateNestedPlanGroupActivityRequest(
val predefinedActivityId: Int? = null,
val duration: Int? = null,
val durationText: String? = null,
val orderId: Int? = null,
val groupId: Int? = null,
)
@Serializable
data class CreateTrainingGroupBody(
val clubid: Int,
val dateid: Int,
val name: String,
val lead: String? = null,
)
@Serializable
data class UpdateTrainingGroupBody(
val clubid: Int,
val dateid: Int,
val name: String,
val lead: String? = null,
)
@Serializable
data class DeleteTrainingGroupBody(
val clubid: Int,
val dateid: Int,
)

View File

@@ -0,0 +1,30 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class DiaryTrainingParticipant(
val id: Int,
val diaryDateId: Int,
val memberId: Int,
val attendanceStatus: String? = null,
val groupId: Int? = null,
val notes: String? = null,
)
@Serializable
data class ParticipantMutationRequest(
val diaryDateId: Int,
val memberId: Int,
)
@Serializable
data class ParticipantGroupRequest(
val groupId: Int? = null,
)
/** Wie im Web: nur diese gelten als „nimmt teil“ für die Auswahl. */
fun DiaryTrainingParticipant.isPresentParticipant(): Boolean {
val s = attendanceStatus
return s.isNullOrBlank() || s == "present"
}

View File

@@ -0,0 +1,10 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class LoginRequest(
val email: String,
val password: String,
)

View File

@@ -0,0 +1,9 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class LoginResponse(
val token: String,
)

View File

@@ -0,0 +1,38 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class Member(
val id: Int,
val firstName: String = "",
val lastName: String = "",
val clubId: Int? = null,
val active: Boolean = true,
val birthDate: String? = null,
val gender: String? = null,
val ttr: Int? = null,
val qttr: Int? = null,
val street: String? = null,
val city: String? = null,
val postalCode: String? = null,
val phone: String? = null,
val email: String? = null,
val testMembership: Boolean? = null,
val picsInInternetAllowed: Boolean? = null,
val memberFormHandedOver: Boolean? = null,
val adultReleaseApproved: Boolean? = null,
val adultReserveApproved: Boolean? = null,
val lastTraining: String? = null,
val trainingParticipations: Int? = null,
val notInTraining: Boolean? = null,
val missedTrainingWeeks: Int? = null,
val contacts: List<MemberContactDto> = emptyList(),
val images: List<MemberImageDto> = emptyList(),
val primaryImageId: Int? = null,
val primaryImageUrl: String? = null,
val imageUrl: String? = null,
val hasImage: Boolean? = null,
val myTischtennisPlayerId: String? = null,
val myTischtennisHistoryPlayerId: String? = null,
)

View File

@@ -0,0 +1,20 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonArray
@Serializable
data class MemberActivityStatDto(
val name: String? = null,
val code: String? = null,
val count: Int = 0,
val dates: JsonArray? = null,
)
@Serializable
data class MemberLastParticipationDto(
val activityName: String? = null,
val activityFullName: String? = null,
val date: String? = null,
val diaryDateId: Int? = null,
)

View File

@@ -0,0 +1,14 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class MemberContactDto(
val id: Int? = null,
val memberId: Int? = null,
val type: String,
val value: String = "",
val isParent: Boolean = false,
val parentName: String? = null,
val isPrimary: Boolean = false,
)

View File

@@ -0,0 +1,19 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class MemberGroupPhotoListResponse(
val success: Boolean = true,
val photos: List<MemberGroupPhotoDto> = emptyList(),
)
@Serializable
data class MemberGroupPhotoDto(
val id: Int,
val clubId: Int? = null,
val title: String? = null,
val description: String? = null,
/** Relativer Pfad inkl. Query (Cache-Buster), z. B. `/api/member-group-photos/1/2/image?t=…` */
val imageUrl: String? = null,
)

View File

@@ -0,0 +1,12 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class MemberImageDto(
val id: Int? = null,
val memberId: Int? = null,
val fileName: String? = null,
val sortOrder: Int? = null,
val url: String? = null,
)

View File

@@ -0,0 +1,39 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class MemberContactSetBody(
val type: String,
val value: String,
val isParent: Boolean = false,
val parentName: String? = null,
val isPrimary: Boolean = false,
)
/**
* Body für [POST /api/clubmembers/set/:clubId] Feldnamen wie Web (`firstname`/`lastname`/`birthdate`).
*/
@Serializable
data class MemberSetBody(
val id: Int? = null,
@SerialName("firstname") val firstname: String,
@SerialName("lastname") val lastname: String,
val street: String? = "",
val city: String? = "",
val postalCode: String? = null,
@SerialName("birthdate") val birthdate: String? = null,
val phone: String? = "",
val email: String? = "",
val active: Boolean = true,
val testMembership: Boolean = false,
val picsInInternetAllowed: Boolean = false,
val gender: String? = "unknown",
val ttr: Int? = null,
val qttr: Int? = null,
val memberFormHandedOver: Boolean = false,
val adultReleaseApproved: Boolean = false,
val adultReserveApproved: Boolean = false,
val contacts: List<MemberContactSetBody> = emptyList(),
)

View File

@@ -0,0 +1,49 @@
package de.tt_tagebuch.shared.api.models
fun Member.toSetBody(
firstname: String,
lastname: String,
street: String?,
city: String?,
postalCode: String?,
birthdate: String?,
phone: String?,
email: String?,
active: Boolean,
testMembership: Boolean,
picsInInternetAllowed: Boolean,
gender: String?,
memberFormHandedOver: Boolean,
adultReleaseApproved: Boolean,
adultReserveApproved: Boolean,
contacts: List<MemberContactSetBody>,
): MemberSetBody = MemberSetBody(
id = if (id <= 0) null else id,
firstname = firstname,
lastname = lastname,
street = street.orEmpty(),
city = city.orEmpty(),
postalCode = postalCode?.ifBlank { null },
birthdate = birthdate?.ifBlank { null },
phone = phone.orEmpty(),
email = email.orEmpty(),
active = active,
testMembership = testMembership,
picsInInternetAllowed = picsInInternetAllowed,
gender = gender?.ifBlank { null } ?: "unknown",
ttr = ttr,
qttr = qttr,
memberFormHandedOver = memberFormHandedOver,
adultReleaseApproved = adultReleaseApproved,
adultReserveApproved = adultReserveApproved,
contacts = contacts,
)
fun MemberContactDto.toSetBody(): MemberContactSetBody =
MemberContactSetBody(
type = type,
value = value,
isParent = isParent,
parentName = parentName,
isPrimary = isPrimary,
)

View File

@@ -0,0 +1,8 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class ParticipantStatusRequest(
val attendanceStatus: String,
)

View File

@@ -0,0 +1,22 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class PredefinedActivityDto(
val id: Int,
val name: String? = null,
val code: String? = null,
val description: String? = null,
val duration: Int? = null,
val durationText: String? = null,
val excludeFromStats: Boolean? = null,
)
fun PredefinedActivityDto.displayLabel(): String {
val n = name?.trim().orEmpty()
if (n.isNotEmpty()) return n
val c = code?.trim().orEmpty()
if (c.isNotEmpty()) return c
return "Übung $id"
}

View File

@@ -0,0 +1,25 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class RegisterRequest(
val email: String,
val password: String,
)
@Serializable
data class ForgotPasswordRequest(
val email: String,
)
@Serializable
data class MessageResponse(
val message: String? = null,
)
@Serializable
data class ResetPasswordRequest(
val token: String,
val password: String,
)

View File

@@ -0,0 +1,10 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class SessionStatusResponse(
val valid: Boolean,
val message: String? = null,
)

View File

@@ -0,0 +1,24 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class TrainingTimeDto(
val id: Int = 0,
val trainingGroupId: Int? = null,
val weekday: Int = 0,
val startTime: String = "",
val endTime: String = "",
val sortOrder: Int = 0,
)
@Serializable
data class TrainingGroupDto(
val id: Int = 0,
val clubId: Int? = null,
val name: String = "",
val sortOrder: Int = 0,
val isPreset: Boolean = false,
val presetType: String? = null,
val trainingTimes: List<TrainingTimeDto> = emptyList(),
)

View File

@@ -0,0 +1,101 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class TrainingStats(
val members: List<TrainingStatsMember> = emptyList(),
val trainingsCount12Months: Int = 0,
val trainingsCount3Months: Int = 0,
val trainingDays: List<TrainingStatsDay> = emptyList(),
val overview: TrainingStatsOverview = TrainingStatsOverview(),
val weekdayStats: List<TrainingStatsWeekdayBucket> = emptyList(),
val monthlyTrend: List<TrainingStatsMonthlyTrend> = emptyList(),
val memberDistribution: TrainingStatsMemberDistribution = TrainingStatsMemberDistribution(),
)
@Serializable
data class TrainingStatsOverview(
val activeMembersCount: Int = 0,
val totalParticipants12Months: Int = 0,
val averageParticipants12Months: Double = 0.0,
val attendanceRate12Months: Double = 0.0,
val inactiveMembersCount: Int = 0,
val bestTrainingDay: TrainingStatsDay? = null,
)
@Serializable
data class TrainingStatsMemberDistribution(
val highlyActive: Int = 0,
val regular: Int = 0,
val occasional: Int = 0,
val inactive: Int = 0,
)
@Serializable
data class TrainingStatsWeekdayBucket(
val weekday: String = "",
val weekdayIndex: Int = 0,
val trainingCount: Int = 0,
val participantCount: Int = 0,
val averageParticipants: Double = 0.0,
)
@Serializable
data class TrainingStatsMonthlyTrend(
val key: String = "",
val label: String = "",
val trainingCount: Int = 0,
val participantCount: Int = 0,
val averageParticipants: Double = 0.0,
)
@Serializable
data class TrainingStatsTrainingGroup(
val id: Int = 0,
val name: String = "",
)
@Serializable
data class TrainingStatsTrainingDetail(
val id: Int = 0,
val date: String? = null,
val activityName: String? = null,
val startTime: String? = null,
val endTime: String? = null,
)
@Serializable
data class TrainingStatsMember(
val id: Int,
val firstName: String = "",
val lastName: String = "",
val birthDate: String? = null,
val ttr: Int? = null,
val qttr: Int? = null,
val participation12Months: Int = 0,
val participation3Months: Int = 0,
val participationTotal: Int = 0,
val participationRate12Months: Double = 0.0,
val lastTraining: String? = null,
val lastTrainingTs: Long = 0,
val missedTrainingWeeks: Int = 0,
val notInTraining: Boolean = false,
val trainingGroups: List<TrainingStatsTrainingGroup> = emptyList(),
val trainingDetails: List<TrainingStatsTrainingDetail> = emptyList(),
)
@Serializable
data class TrainingStatsDay(
val id: Int,
val date: String,
val participantCount: Int = 0,
val participants: List<TrainingStatsParticipant> = emptyList(),
)
@Serializable
data class TrainingStatsParticipant(
val id: Int,
val firstName: String = "",
val lastName: String = "",
)

View File

@@ -0,0 +1,12 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
@Serializable
data class UserClubPermissions(
val role: String,
val isOwner: Boolean,
val permissions: JsonObject = JsonObject(emptyMap()),
)

View File

@@ -0,0 +1,13 @@
# i18n
Source of truth is the webapp locale JSONs in `frontend/src/i18n/locales/`.
The mobile app generates a KMP-safe translation bundle into this package:
- `MobileStrings.kt` (generated)
German (`de.json`) is the canonical key set. Missing keys in other locales are filled with German fallback values during generation so every supported mobile language has the same keys.
Generate via:
```bash
node scripts/generate-mobile-i18n.js
```

View File

@@ -0,0 +1,63 @@
package de.tt_tagebuch.shared.state
import de.tt_tagebuch.shared.api.AuthApi
import de.tt_tagebuch.shared.api.SessionApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class AuthManager(
private val tokenProvider: MutableTokenProvider,
private val tokenStorage: TokenStorage,
private val authApi: AuthApi,
private val sessionApi: SessionApi,
) : TokenProvider {
private val _state = MutableStateFlow(AuthState(isHydrating = true))
val state: StateFlow<AuthState> = _state.asStateFlow()
override val token: String? get() = tokenProvider.token
override val username: String? get() = tokenProvider.username
suspend fun hydrate() {
_state.value = _state.value.copy(isHydrating = true)
try {
val stored = tokenStorage.load()
if (stored != null) {
tokenProvider.token = stored.token
tokenProvider.username = stored.username
_state.value = AuthState(token = stored.token, username = stored.username, isHydrating = true)
val status = runCatching { sessionApi.status() }.getOrNull()
if (status != null && !status.valid) {
clearLocal()
}
} else {
clearLocal()
}
} finally {
_state.value = _state.value.copy(isHydrating = false)
}
}
suspend fun login(email: String, password: String) {
val response = authApi.login(email = email, password = password)
val tokens = AuthTokens(token = response.token, username = email)
tokenProvider.token = tokens.token
tokenProvider.username = tokens.username
tokenStorage.save(tokens)
_state.value = AuthState(token = tokens.token, username = tokens.username, isHydrating = false)
}
suspend fun logout() {
runCatching { authApi.logout() }
clearLocal()
}
suspend fun clearLocal() {
tokenProvider.token = null
tokenProvider.username = null
tokenStorage.clear()
_state.value = AuthState(token = null, username = null, isHydrating = false)
}
}

View File

@@ -0,0 +1,10 @@
package de.tt_tagebuch.shared.state
data class AuthState(
val token: String? = null,
val username: String? = null,
val isHydrating: Boolean = false,
) {
val isLoggedIn: Boolean get() = !token.isNullOrBlank()
}

View File

@@ -0,0 +1,7 @@
package de.tt_tagebuch.shared.state
data class AuthTokens(
val token: String,
val username: String,
)

View File

@@ -0,0 +1,90 @@
package de.tt_tagebuch.shared.state
import de.tt_tagebuch.shared.api.ClubsApi
import de.tt_tagebuch.shared.api.PermissionsApi
import de.tt_tagebuch.shared.api.models.Club
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class ClubManager(
private val clubStorage: ClubStorage,
private val clubsApi: ClubsApi,
private val permissionsApi: PermissionsApi,
) {
private val _state = MutableStateFlow(ClubState())
val state: StateFlow<ClubState> = _state.asStateFlow()
suspend fun hydrate() {
val stored = clubStorage.loadCurrentClubId()
_state.value = _state.value.copy(currentClubId = stored)
}
suspend fun loadClubs() {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val clubs = clubsApi.listClubs()
_state.value = _state.value.copy(clubs = clubs, isLoading = false)
} catch (t: Throwable) {
if (t is CancellationException) throw t
_state.value = _state.value.copy(isLoading = false, error = t.toUserMessage("Fehler beim Laden der Clubs"))
}
}
suspend fun createClub(name: String) {
val trimmed = name.trim()
if (trimmed.isEmpty()) return
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val newClub = clubsApi.createClub(trimmed)
loadClubs()
selectClub(newClub.id)
} catch (t: Throwable) {
if (t is CancellationException) throw t
_state.value = _state.value.copy(isLoading = false, error = t.toUserMessage("Verein konnte nicht erstellt werden"))
}
}
suspend fun fetchClubDetail(clubId: Int): Club {
return clubsApi.getClub(clubId)
}
suspend fun selectClub(clubId: Int) {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val permissions = permissionsApi.getUserPermissions(clubId)
clubStorage.saveCurrentClubId(clubId)
_state.value = _state.value.copy(
currentClubId = clubId,
currentPermissions = permissions,
isLoading = false,
)
} catch (t: Throwable) {
if (t is CancellationException) throw t
_state.value = _state.value.copy(
isLoading = false,
error = t.toUserMessage("Keine Berechtigung oder Fehler beim Laden der Permissions"),
currentPermissions = null,
currentClubId = null,
)
clubStorage.saveCurrentClubId(null)
}
}
suspend fun requestAccess(clubId: Int) {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
clubsApi.requestAccess(clubId)
_state.value = _state.value.copy(isLoading = false)
} catch (t: Throwable) {
if (t is CancellationException) throw t
_state.value = _state.value.copy(isLoading = false, error = t.toUserMessage("Access request fehlgeschlagen"))
}
}
suspend fun clearSelection() {
clubStorage.saveCurrentClubId(null)
_state.value = _state.value.copy(currentClubId = null, currentPermissions = null)
}
}

View File

@@ -0,0 +1,13 @@
package de.tt_tagebuch.shared.state
import de.tt_tagebuch.shared.api.models.Club
import de.tt_tagebuch.shared.api.models.UserClubPermissions
data class ClubState(
val clubs: List<Club> = emptyList(),
val currentClubId: Int? = null,
val currentPermissions: UserClubPermissions? = null,
val isLoading: Boolean = false,
val error: String? = null,
)

View File

@@ -0,0 +1,7 @@
package de.tt_tagebuch.shared.state
interface ClubStorage {
suspend fun loadCurrentClubId(): Int?
suspend fun saveCurrentClubId(clubId: Int?)
}

Some files were not shown because too many files have changed in this diff Show More