chore: update .gitignore and enhance backend and mobile app functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
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:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
106
docs/MOBILE_APP_KMP_PLAN.md
Normal 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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
81
mobile-app/DEVELOPMENT.md
Normal 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
16
mobile-app/README.md
Normal 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
212
mobile-app/TODO.md
Normal 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.1–3.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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="pdf_cache" path="." />
|
||||
</paths>
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
13
mobile-app/gradle/gradle-daemon-jvm.properties
Normal file
13
mobile-app/gradle/gradle-daemon-jvm.properties
Normal 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
|
||||
@@ -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" }
|
||||
|
||||
BIN
mobile-app/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
mobile-app/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
mobile-app/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
mobile-app/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
15
mobile-app/gradlew
vendored
Executable 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
12
mobile-app/gradlew.bat
vendored
Normal 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
|
||||
|
||||
20
mobile-app/iosApp/README.md
Normal file
20
mobile-app/iosApp/README.md
Normal 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.
|
||||
|
||||
9
mobile-app/scripts/install-debug-emulator.sh
Executable file
9
mobile-app/scripts/install-debug-emulator.sh
Executable 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
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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\"")
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.tt_tagebuch.shared.api.http
|
||||
|
||||
class ApiException(
|
||||
val statusCode: Int,
|
||||
message: String,
|
||||
) : RuntimeException(message)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.tt_tagebuch.shared.api.http
|
||||
|
||||
import io.ktor.client.engine.HttpClientEngine
|
||||
|
||||
interface HttpClientEngineFactory {
|
||||
fun create(): HttpClientEngine
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package de.tt_tagebuch.shared.api.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class LoginResponse(
|
||||
val token: String,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.tt_tagebuch.shared.api.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ParticipantStatusRequest(
|
||||
val attendanceStatus: String,
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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 = "",
|
||||
)
|
||||
@@ -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()),
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
```
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.tt_tagebuch.shared.state
|
||||
|
||||
data class AuthTokens(
|
||||
val token: String,
|
||||
val username: String,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user