chore: initial import
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
.gradle/
|
||||
.idea/
|
||||
local.properties
|
||||
**/build/
|
||||
*.iml
|
||||
*.jks
|
||||
*.keystore
|
||||
*.properties.local
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
@@ -0,0 +1,9 @@
|
||||
Es wurde bewusst keine Datei app-debug.apk erzeugt.
|
||||
|
||||
Die drei vorgeschriebenen Gradle-Befehle wurden am 24.07.2026 ausgeführt,
|
||||
scheiterten jedoch bereits beim Download von Gradle 8.11.1, weil die
|
||||
Laufumgebung services.gradle.org nicht per DNS auflösen kann. Zusätzlich ist
|
||||
kein Android SDK installiert. Eine leere, umbenannte oder anderweitig
|
||||
vorgetäuschte APK wäre nicht installierbar und wurde deshalb nicht geliefert.
|
||||
|
||||
Siehe TEST-REPORT.md, BUILD.md und build-logs/gradle-assembleDebug.log.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Lokaler Build
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- Android Studio mit JDK 17
|
||||
- Android SDK Platform 35
|
||||
- Android SDK Build-Tools für API 35
|
||||
- Internetzugriff auf Google Maven, Maven Central und die Gradle-Distribution beim ersten Build
|
||||
|
||||
Minimum SDK der App ist 26. Zugangsdaten gehören nicht in `local.properties`, Gradle-Dateien oder Quellcode; sie werden erst in der App eingegeben.
|
||||
|
||||
## Projekt öffnen
|
||||
|
||||
1. Das Projektverzeichnis in Android Studio öffnen.
|
||||
2. Falls nötig, in `local.properties` ausschließlich den lokalen SDK-Pfad setzen, zum Beispiel:
|
||||
|
||||
```properties
|
||||
sdk.dir=/home/user/Android/Sdk
|
||||
```
|
||||
|
||||
3. Gradle synchronisieren.
|
||||
4. Die Build-Variante `debug` wählen.
|
||||
|
||||
## Kommandozeile
|
||||
|
||||
Linux/macOS:
|
||||
|
||||
```bash
|
||||
./gradlew test
|
||||
./gradlew lintDebug
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
Windows:
|
||||
|
||||
```bat
|
||||
gradlew.bat test
|
||||
gradlew.bat lintDebug
|
||||
gradlew.bat assembleDebug
|
||||
```
|
||||
|
||||
Das Debug-APK liegt anschließend unter:
|
||||
|
||||
```text
|
||||
app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
Bei verbundenem Gerät oder Emulator:
|
||||
|
||||
```bash
|
||||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
## Ersteinrichtung
|
||||
|
||||
1. App öffnen und `Einstellungen` wählen.
|
||||
2. Server-URL, Basic-Auth-Benutzername und Passwort eingeben.
|
||||
3. `Speichern & Verbindung testen` ausführen.
|
||||
4. Die App prüft zuerst den Host-Health-Endpunkt und danach den authentifizierten Tracker-Health-Endpunkt.
|
||||
|
||||
Debug-Builds erlauben für lokale Entwicklung auch HTTP. Release-Builds akzeptieren durch Manifest, Network Security Configuration und Laufzeitprüfung ausschließlich HTTPS.
|
||||
|
||||
## Gradle Wrapper
|
||||
|
||||
`gradle/wrapper/gradle-wrapper.jar` ist ein kleines, im Repository enthaltenes Bootstrap-Programm. Es lädt Gradle 8.11.1 in den üblichen Wrapper-Cache, prüft die Distribution anhand der mitgelieferten SHA-256-Datei und startet anschließend Gradle. Dadurch enthält das Projekt keine lokal installierte Gradle-Abhängigkeit.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Lieferumfang
|
||||
|
||||
Stand: 24. Juli 2026
|
||||
|
||||
## Enthalten
|
||||
|
||||
- vollständiges Android-Studio-Projekt für `de.drazz.boehmitools.training`
|
||||
- native Kotlin-/Jetpack-Compose-/Material-3-App ohne WebView
|
||||
- Tracker, Analyse, Planeditor, Offline-Queue und Konfliktbehandlung
|
||||
- beide gelieferten Plan-JSONs und beide Session-JSONs als Test-Fixtures
|
||||
- Analyse- und Vorschlags-Fixtures
|
||||
- Vertrags- und Architekturdokumentation unter `docs/`
|
||||
- Unit-, MockWebServer-, Repository- und Compose-UI-Tests
|
||||
- reproduzierbare Prüfwerkzeuge und vollständige Buildprotokolle unter `build-logs/`
|
||||
|
||||
## Nicht enthalten
|
||||
|
||||
Eine `app-debug.apk` konnte in der bereitgestellten Laufumgebung nicht erzeugt werden. Gradle 8.11.1 konnte wegen fehlender DNS-Auflösung für `services.gradle.org` nicht geladen werden; zusätzlich ist kein Android SDK installiert. Es wurde bewusst keine Attrappe oder ungeprüfte Datei als APK ausgegeben.
|
||||
|
||||
Der lokale Build ist in `BUILD.md` beschrieben. Der vollständige Prüfstatus steht in `TEST-REPORT.md`, der Infrastrukturblocker zusätzlich in `APK_BUILD_BLOCKED.txt`.
|
||||
|
||||
## Erwartete APK nach lokalem Build
|
||||
|
||||
```text
|
||||
app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
# Bekannte Einschränkungen
|
||||
|
||||
1. **Kein erzeugtes APK in dieser Ausführungsumgebung:** Der Container besitzt weder Android SDK noch eine vorinstallierte Gradle-Distribution und kann `services.gradle.org` per Shell-DNS nicht erreichen. Daher konnten die Android-Abhängigkeiten nicht aufgelöst und `app-debug.apk` hier nicht seriös erzeugt werden. Der genaue Befund und alle Befehlsprotokolle stehen in `TEST-REPORT.md`.
|
||||
2. **Keine gelieferten Screenshots:** In den drei Archiven sind keine Bilddateien enthalten. Die visuelle Umsetzung basiert auf den mobilen HTML-UIs der Plugins; Pixelgleichheit zu nicht vorhandenen Screenshots kann nicht geprüft werden.
|
||||
3. **Keine Geräte-/Emulatorausführung im Container:** Compose-Instrumentation, Keystore-Verhalten und reale 360-dp-Layouts konnten nur als Tests implementiert, nicht ausgeführt werden.
|
||||
4. **Offline-Mutationen sind bewusst auf Session-Patches begrenzt:** Planentwürfe sind im Cache lesbar, Editoränderungen werden aber nicht offline veröffentlicht oder in eine Mutationsqueue aufgenommen. Dafür existiert im Serververtrag kein sicherer Patch-/Merge-Mechanismus.
|
||||
5. **Konflikte werden nicht automatisch gemergt:** Bei `409` bleiben lokale Daten beziehungsweise Queue-Einträge sichtbar. Die App bietet bewusst nur Neuladen und explizites Verwerfen, kein riskantes Rebase.
|
||||
6. **Optionale Verwaltungsendpunkte nicht als Hauptscreens:** Planimport/-export, PDF-Erzeugung, Reset und globale Kompatibilitätskonfiguration existieren serverseitig, gehören aber nicht zu den geforderten Kernflüssen der beiden nativen Plugins und wurden nicht als eigene Android-Oberflächen umgesetzt.
|
||||
7. **Kein Timer:** Auch beim gelieferten Tabata-Plan wird nur das Trainingsformat angezeigt. Ein Tabata- oder Trainingstimer ist ausdrücklich nicht implementiert.
|
||||
@@ -0,0 +1,14 @@
|
||||
# boehmitools Training
|
||||
|
||||
Native Android-App für die boehmitools-Plugins `trainingsplan` und `trainingstracker` 2.0.
|
||||
|
||||
- Kotlin, Jetpack Compose und Material 3
|
||||
- Application ID `de.drazz.boehmitools.training`
|
||||
- Minimum SDK 26
|
||||
- konfigurierbarer Server, Standard `https://tools.d-razz.de/`
|
||||
- HTTP Basic Auth mit Android-Keystore-geschütztem Passwort
|
||||
- Room-Cache und FIFO-Queue für Session-Patches
|
||||
- WorkManager-Synchronisation mit sichtbarer `409`-Konfliktbehandlung
|
||||
- nativer Tracker, Fortschritt/Analyse und Planeditor
|
||||
|
||||
Der technische Vertrag steht in `docs/API_CONTRACT.md`, die Quellenanalyse in `docs/SOURCE_ANALYSIS.md`. Lokale Buildschritte stehen in `BUILD.md`.
|
||||
@@ -0,0 +1,125 @@
|
||||
# Testbericht
|
||||
|
||||
Stand: 24. Juli 2026
|
||||
|
||||
## Ergebnisübersicht
|
||||
|
||||
| Prüfung | Ergebnis | Nachweis |
|
||||
|---|---|---|
|
||||
| Vollständiger Quellen- und Fixture-Audit | bestanden | `docs/SOURCE_ANALYSIS.md`, `build-logs/source-audit.json` |
|
||||
| Vergleich aktueller Plugins gegen v2.0-Bundle | bestanden | nur erzeugte `__pycache__`-Dateien unterscheiden sich; `build-logs/source-diff.log` |
|
||||
| Vorhandene Backend-Plugin-Tests | **11 bestanden, 8 übersprungen** | `build-logs/backend-plugin-tests.log` |
|
||||
| Statische Android-Akzeptanzprüfung | **728 bestanden** | `build-logs/static-acceptance.log` |
|
||||
| Ressourcenprüfung | 8 JSON- und 7 XML-Dateien parsebar | `build-logs/resource-validation.log` |
|
||||
| Kotlin-Parserprüfung ohne Android-Classpath | keine Syntax-/Parserdiagnosen | `build-logs/kotlinc-syntax-summary.txt` |
|
||||
| `./gradlew test` | **nicht ausführbar in dieser Umgebung** | Wrapper-Download scheitert an nicht auflösbarem `services.gradle.org`; `build-logs/gradle-test.log` |
|
||||
| `./gradlew lintDebug` | **nicht ausführbar in dieser Umgebung** | gleicher Infrastrukturfehler; `build-logs/gradle-lintDebug.log` |
|
||||
| `./gradlew assembleDebug` | **nicht ausführbar in dieser Umgebung** | gleicher Infrastrukturfehler; `build-logs/gradle-assembleDebug.log` |
|
||||
| Debug-APK | **nicht erzeugt** | ohne Gradle-Distribution, Android SDK und aufgelöste Abhängigkeiten wäre ein APK nicht verifizierbar |
|
||||
|
||||
## Ausgeführte Backendtests
|
||||
|
||||
Ausgeführt im vollständigen boehmitools-Quellstand:
|
||||
|
||||
```bash
|
||||
python3 -m pytest -q \
|
||||
plugins/trainingsplan/tests \
|
||||
plugins/trainingstracker/tests
|
||||
```
|
||||
|
||||
Ergebnis:
|
||||
|
||||
```text
|
||||
11 passed, 8 skipped in 0.05s
|
||||
```
|
||||
|
||||
Die übersprungenen Tests stammen aus den gelieferten Testsets und wurden nicht von der Android-Implementierung ausgeblendet.
|
||||
|
||||
## Android-Tests im Projekt
|
||||
|
||||
### Unit- und Fixture-Tests
|
||||
|
||||
`FixtureContractTest` prüft unter anderem:
|
||||
|
||||
- beide gelieferten Pläne mit `schema_version = 3` und `contract_version = 2`,
|
||||
- vollständige und entitätsspezifisch eindeutige stabile IDs,
|
||||
- explizite `result_schema`-Definitionen aller Übungen und Progressionsstufen,
|
||||
- Laden beider Planvarianten,
|
||||
- Migration beider alten Sessiondateien ohne Itemverlust,
|
||||
- Priorität manuelle Anpassung → Progressionsstufe → Übung → Fallback,
|
||||
- Vererbung nicht überschriebener result_schema-Felder,
|
||||
- sichere Löschung inkompatibler Messwerte bei Einheit-/Seitenwechsel,
|
||||
- Normalisierung alter Freitextergebnisse zu `result_data` Version 2,
|
||||
- FIFO-Overlay ausstehender Session-Patches nach App-Neustart,
|
||||
- Überschreibmodell für genau eine Wochen- beziehungsweise Gesamtanalyse.
|
||||
|
||||
`ApiContractTest` verwendet MockWebServer und prüft:
|
||||
|
||||
- exakten Session-Patch-Pfad, `PATCH`, Basic-Auth-Header und `expected_revision`,
|
||||
- `202 Accepted` sowie `200` am selben Analyse-Endpunkt,
|
||||
- unveränderte Weitergabe von `401` und `409 revision_conflict`,
|
||||
- dokumentierte Editor-, Publish- und Vorschlagsstatus-Pfade.
|
||||
|
||||
### Repository- und Instrumentationstests
|
||||
|
||||
`RepositoryIntegrationTest` prüft mit Room, WorkManager und MockWebServer:
|
||||
|
||||
- Netzantwort und Room-Cache,
|
||||
- sichtbare `409`-Weitergabe ohne Umwandlung in einen scheinbaren Offline-Erfolg,
|
||||
- Erhalt des HTTP-Status `202` für die dauerhafte Analyse-Sperre.
|
||||
|
||||
`CompactEditorUiTest` setzt eine 360-dp-breite Compose-Oberfläche auf und prüft, dass lange Übungsnamen und strukturierte Ergebnisse innerhalb der verfügbaren Breite bleiben.
|
||||
|
||||
Diese Android-Tests sind vollständig im Projekt vorhanden, konnten in diesem Container aber nicht gestartet werden, weil bereits der Gradle-Wrapper-Download vor der Projektauswertung fehlschlägt.
|
||||
|
||||
## Statische Akzeptanzprüfung
|
||||
|
||||
Ausgeführt:
|
||||
|
||||
```bash
|
||||
python3 tools/static_acceptance.py
|
||||
```
|
||||
|
||||
Ergebnis:
|
||||
|
||||
```text
|
||||
PASS: 728 Prüfungen
|
||||
```
|
||||
|
||||
Die Prüfung deckt Buildparameter, Release-Netzwerksicherheit, Standardserver, verbotene WebView/OpenAI-Verbindungen, Basic Auth, zentrale API-Routen sowie alle gelieferten Plan- und Session-Fixtures ab.
|
||||
|
||||
## Kotlin-Parserprüfung
|
||||
|
||||
Alle Kotlin-Dateien wurden zusätzlich mit dem vorhandenen standalone `kotlinc` eingelesen. Da Android SDK und Maven-/Gradle-Abhängigkeiten fehlen, entstehen erwartungsgemäß nicht auflösbare Android-, Compose-, Hilt-, Retrofit- und Serialization-Symbole. Die separat gefilterte Prüfung meldet jedoch keine Parserfehler wie ungeschlossene Blöcke, illegale Escapes oder unerwartete Tokens.
|
||||
|
||||
Dies ersetzt keinen echten Gradle-Compile-Lauf. Das vollständige Rohprotokoll ist deshalb nur Diagnosematerial und kein Bestehensnachweis.
|
||||
|
||||
## Blocker der geforderten Gradle-Läufe
|
||||
|
||||
Alle drei vorgeschriebenen Befehle wurden separat ausgeführt und protokolliert. Die Laufumgebung enthält:
|
||||
|
||||
- keine installierte Gradle-Distribution,
|
||||
- kein Android SDK, kein `sdkmanager`, kein `adb`,
|
||||
- keinen vorgefüllten Gradle- oder Maven-Cache,
|
||||
- keine DNS-Auflösung für `services.gradle.org` aus Shell-Prozessen.
|
||||
|
||||
Der mitgelieferte Wrapper versucht korrekt Gradle 8.11.1 zu laden und beendet sich mit `UnknownHostException`. Der Fehler tritt vor der Auswertung von `settings.gradle.kts` und damit vor Kompilierung, Tests, Lint oder APK-Erzeugung auf. Details stehen in `build-logs/build-environment.log` und den drei Gradle-Protokollen.
|
||||
|
||||
## Lokal nachzuholende Abnahme
|
||||
|
||||
In einer normalen Android-Entwicklungsumgebung mit JDK 17, Android SDK 35 und Internetzugang:
|
||||
|
||||
```bash
|
||||
./gradlew test
|
||||
./gradlew lintDebug
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
Anschließend ist zusätzlich empfehlenswert:
|
||||
|
||||
```bash
|
||||
./gradlew connectedDebugAndroidTest
|
||||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
Die erwartete APK-Ausgabe ist `app/build/outputs/apk/debug/app-debug.apk`. Bis diese Befehle erfolgreich gelaufen sind, darf das Projekt nicht als vollständig buildverifiziert bezeichnet werden.
|
||||
@@ -0,0 +1,101 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.kotlin.kapt)
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "de.drazz.boehmitools.training"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "de.drazz.boehmitools.training"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "2.0.0"
|
||||
testInstrumentationRunner = "de.drazz.boehmitools.training.HiltTestRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
versionNameSuffix = "-debug"
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions { jvmTarget = "17" }
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
packaging.resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
testOptions.unitTests.isIncludeAndroidResources = true
|
||||
}
|
||||
|
||||
kapt {
|
||||
correctErrorTypes = true
|
||||
arguments { arg("room.schemaLocation", "$projectDir/schemas") }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.ui.tooling.preview)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.icons)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
|
||||
implementation(libs.hilt.android)
|
||||
kapt(libs.hilt.compiler)
|
||||
implementation(libs.hilt.navigation.compose)
|
||||
implementation(libs.hilt.work)
|
||||
kapt(libs.hilt.work.compiler)
|
||||
|
||||
implementation(libs.room.runtime)
|
||||
implementation(libs.room.ktx)
|
||||
kapt(libs.room.compiler)
|
||||
implementation(libs.datastore.preferences)
|
||||
implementation(libs.work.runtime.ktx)
|
||||
|
||||
implementation(libs.retrofit)
|
||||
implementation(libs.retrofit.kotlinx)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.okhttp.logging)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.mockwebserver)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.test.core.ktx)
|
||||
androidTestImplementation(libs.espresso.core)
|
||||
androidTestImplementation(libs.androidx.test.runner)
|
||||
androidTestImplementation(libs.hilt.android.testing)
|
||||
androidTestImplementation(libs.room.testing)
|
||||
androidTestImplementation(libs.work.testing)
|
||||
androidTestImplementation(libs.mockwebserver)
|
||||
kaptAndroidTest(libs.hilt.compiler)
|
||||
androidTestImplementation(platform(libs.compose.bom))
|
||||
androidTestImplementation(libs.compose.ui.test.junit4)
|
||||
debugImplementation(libs.compose.ui.test.manifest)
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
-keepattributes Signature,*Annotation*
|
||||
-keep class kotlinx.serialization.** { *; }
|
||||
-dontwarn javax.annotation.**
|
||||
@@ -0,0 +1,12 @@
|
||||
package de.drazz.boehmitools.training
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.test.runner.AndroidJUnitRunner
|
||||
import dagger.hilt.android.testing.HiltTestApplication
|
||||
|
||||
class HiltTestRunner : AndroidJUnitRunner() {
|
||||
override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application {
|
||||
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package de.drazz.boehmitools.training.ui
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertWidthIsAtMost
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class CompactEditorUiTest {
|
||||
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun longExerciseNamesWrapWithin360Dp() {
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
Column(Modifier.width(360.dp).testTag("root")) {
|
||||
Text(
|
||||
"Sehr langer Übungsname mit Progressionshinweis und mehreren Ergebniswerten, der sicher umbrechen muss",
|
||||
modifier = Modifier.fillMaxWidth().testTag("long-name"),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = "8/8/7 Wiederholungen je Seite bei optionalem Gewicht",
|
||||
onValueChange = {},
|
||||
modifier = Modifier.fillMaxWidth().testTag("field"),
|
||||
label = { Text("Strukturiertes Ergebnis") },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.onNodeWithTag("long-name").assertIsDisplayed().assertWidthIsAtMost(360.dp)
|
||||
compose.onNodeWithTag("field").assertIsDisplayed().assertWidthIsAtMost(360.dp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:networkSecurityConfig="@xml/network_security_config_debug"
|
||||
android:usesCleartextTraffic="true" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors><certificates src="system" /></trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:name=".TrainingApplication"
|
||||
android:allowBackup="false"
|
||||
android:icon="@drawable/ic_launcher_foreground"
|
||||
android:label="boehmitools Training"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.BoehmitoolsTraining"
|
||||
android:usesCleartextTraffic="false">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false"
|
||||
tools:node="merge">
|
||||
<meta-data
|
||||
android:name="androidx.work.WorkManagerInitializer"
|
||||
tools:node="remove" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.drazz.boehmitools.training
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.drazz.boehmitools.training.ui.AppRoot
|
||||
import de.drazz.boehmitools.training.ui.AppViewModel
|
||||
import de.drazz.boehmitools.training.ui.theme.TrainingTheme
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
val vm: AppViewModel = hiltViewModel()
|
||||
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||
TrainingTheme(settings.theme) {
|
||||
AppRoot(settings = settings, onTheme = vm::setTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.drazz.boehmitools.training
|
||||
|
||||
import android.app.Application
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp
|
||||
class TrainingApplication : Application(), Configuration.Provider {
|
||||
@Inject lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder()
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.drazz.boehmitools.training.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.work.WorkManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.drazz.boehmitools.training.data.local.TrainingDatabase
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun json(): Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
encodeDefaults = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun database(@ApplicationContext context: Context): TrainingDatabase =
|
||||
Room.databaseBuilder(context, TrainingDatabase::class.java, "training.db")
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun workManager(@ApplicationContext context: Context): WorkManager = WorkManager.getInstance(context)
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
package de.drazz.boehmitools.training.domain
|
||||
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
fun JsonObject.string(key: String, fallback: String = ""): String = this[key]?.jsonPrimitive?.contentOrNull ?: fallback
|
||||
fun JsonObject.int(key: String, fallback: Int = 0): Int = this[key]?.jsonPrimitive?.intOrNull ?: fallback
|
||||
fun JsonObject.bool(key: String, fallback: Boolean = false): Boolean = this[key]?.jsonPrimitive?.booleanOrNull ?: fallback
|
||||
fun JsonObject.obj(key: String): JsonObject = this[key] as? JsonObject ?: JsonObject(emptyMap())
|
||||
fun JsonObject.array(key: String): JsonArray = this[key] as? JsonArray ?: JsonArray(emptyList())
|
||||
|
||||
fun JsonObject.updated(key: String, value: JsonElement?): JsonObject = JsonObject(toMutableMap().apply {
|
||||
if (value == null) remove(key) else put(key, value)
|
||||
})
|
||||
|
||||
fun JsonObject.updatedString(key: String, value: String): JsonObject = updated(key, JsonPrimitive(value))
|
||||
fun JsonObject.updatedInt(key: String, value: Int): JsonObject = updated(key, JsonPrimitive(value))
|
||||
fun JsonObject.updatedBool(key: String, value: Boolean): JsonObject = updated(key, JsonPrimitive(value))
|
||||
|
||||
data class ResultSchemaUi(
|
||||
val mode: String = "reps",
|
||||
val weightMode: String = "none",
|
||||
val laterality: String = "bilateral",
|
||||
val sidesMode: String = "same",
|
||||
val defaultSets: Int = 3,
|
||||
val lockedSets: Boolean = false,
|
||||
val source: String = "fallback",
|
||||
)
|
||||
|
||||
data class ProgressionStepUi(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val resultSchema: ResultSchemaUi?,
|
||||
val raw: JsonObject,
|
||||
)
|
||||
|
||||
data class ProgressionUi(
|
||||
val id: String,
|
||||
val key: String,
|
||||
val name: String,
|
||||
val steps: List<ProgressionStepUi>,
|
||||
val raw: JsonObject,
|
||||
)
|
||||
|
||||
data class ExerciseUi(
|
||||
val id: String,
|
||||
val legacyId: String,
|
||||
val exerciseId: String,
|
||||
val progressionId: String,
|
||||
val name: String,
|
||||
val cue: String,
|
||||
val resultSchema: ResultSchemaUi?,
|
||||
val raw: JsonObject,
|
||||
)
|
||||
|
||||
data class RotationUi(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val exercises: List<ExerciseUi>,
|
||||
val raw: JsonObject,
|
||||
)
|
||||
|
||||
data class DayUi(
|
||||
val id: String,
|
||||
val num: Int,
|
||||
val focus: String,
|
||||
val light: Boolean,
|
||||
val warmup: List<String>,
|
||||
val cooldown: List<String>,
|
||||
val stretch: List<String>,
|
||||
val rotations: List<RotationUi>,
|
||||
val raw: JsonObject,
|
||||
)
|
||||
|
||||
data class PlanUi(
|
||||
val sourceFile: String,
|
||||
val planId: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val weeks: Int,
|
||||
val publishedRevision: Int,
|
||||
val days: List<DayUi>,
|
||||
val progressions: Map<String, ProgressionUi>,
|
||||
val trainingFormat: JsonObject,
|
||||
val exerciseCatalog: JsonObject,
|
||||
val front: JsonObject,
|
||||
val raw: JsonObject,
|
||||
)
|
||||
|
||||
fun parsePlan(plan: JsonObject): PlanUi {
|
||||
val progressions = parseProgressions(plan["stages"])
|
||||
val prepost = plan.obj("prepost")
|
||||
val meta = plan.obj("meta")
|
||||
val days = plan.array("days").mapNotNull { it as? JsonObject }.map { day ->
|
||||
val dayNum = day.int("num", 1)
|
||||
val rotations = day.array("rotations").mapNotNull { it as? JsonObject }.mapIndexed { ri, rotation ->
|
||||
val exercises = rotation.array("exercises").mapNotNull { it as? JsonObject }.mapIndexed { ei, exercise ->
|
||||
ExerciseUi(
|
||||
id = exercise.string("id", "exercise-$dayNum-$ri-$ei"),
|
||||
legacyId = exercise.string("legacy_id", "d$dayNum-r$ri-e$ei"),
|
||||
exerciseId = exercise.string("exercise_id"),
|
||||
progressionId = exercise.string("progression_id", exercise.string("key")),
|
||||
name = exercise.string("name", "Übung ${ei + 1}"),
|
||||
cue = exercise.string("cue"),
|
||||
resultSchema = parseResultSchema(exercise["result_schema"] as? JsonObject, "exercise"),
|
||||
raw = exercise,
|
||||
)
|
||||
}
|
||||
RotationUi(
|
||||
id = rotation.string("id", "rotation-$dayNum-$ri"),
|
||||
label = rotation.string("label", "Block ${ri + 1}"),
|
||||
exercises = exercises,
|
||||
raw = rotation,
|
||||
)
|
||||
}
|
||||
val legacyPrePost = prepost.obj(dayNum.toString())
|
||||
fun sequence(name: String): List<String> {
|
||||
val normalized = day.obj(name).array("items").mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||
if (normalized.isNotEmpty()) return normalized
|
||||
return legacyPrePost.string(name)
|
||||
.split(Regex("""<br\s*/?>|\n""", RegexOption.IGNORE_CASE))
|
||||
.map { it.replace(Regex("<[^>]+>"), "").trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
}
|
||||
DayUi(
|
||||
id = day.string("id", "day-$dayNum"),
|
||||
num = dayNum,
|
||||
focus = day.string("focus", "Tag $dayNum"),
|
||||
light = day.bool("light"),
|
||||
warmup = sequence("warmup"),
|
||||
cooldown = sequence("cooldown"),
|
||||
stretch = sequence("stretch"),
|
||||
rotations = rotations,
|
||||
raw = day,
|
||||
)
|
||||
}.sortedBy { it.num }
|
||||
return PlanUi(
|
||||
sourceFile = plan.string("source_file"),
|
||||
planId = plan.string("plan_id"),
|
||||
title = plan.string("title", meta.string("title", plan.string("name"))),
|
||||
subtitle = plan.string("subtitle", meta.string("subtitle")),
|
||||
weeks = plan.int("weeks", meta.int("weeks", 1)).coerceAtLeast(1),
|
||||
publishedRevision = plan.int("published_revision", 1),
|
||||
days = days,
|
||||
progressions = progressions,
|
||||
trainingFormat = plan.obj("training_format"),
|
||||
exerciseCatalog = plan.obj("exercise_catalog"),
|
||||
front = plan.obj("front"),
|
||||
raw = plan,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseProgressions(element: JsonElement?): Map<String, ProgressionUi> {
|
||||
val entries: List<Pair<String, JsonObject>> = when (element) {
|
||||
is JsonArray -> element.mapNotNull { value ->
|
||||
val stage = value as? JsonObject ?: return@mapNotNull null
|
||||
val key = stage.string("key", stage.string("id"))
|
||||
key to stage
|
||||
}
|
||||
is JsonObject -> element.mapNotNull { (mapKey, value) ->
|
||||
val stage = value as? JsonObject ?: return@mapNotNull null
|
||||
mapKey to stage
|
||||
}
|
||||
else -> emptyList()
|
||||
}
|
||||
return entries.associate { (mapKey, stage) ->
|
||||
val key = stage.string("key", mapKey.ifBlank { stage.string("id") })
|
||||
val steps = stage.array("steps").mapNotNull { it as? JsonObject }.map { step ->
|
||||
ProgressionStepUi(
|
||||
id = step.string("id"),
|
||||
name = step.string("name"),
|
||||
resultSchema = parseResultSchema(step["result_schema"] as? JsonObject, "progression"),
|
||||
raw = step,
|
||||
)
|
||||
}
|
||||
key to ProgressionUi(
|
||||
id = stage.string("id", key),
|
||||
key = key,
|
||||
name = stage.string("name", key),
|
||||
steps = steps,
|
||||
raw = stage,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun parseResultSchema(raw: JsonObject?, source: String): ResultSchemaUi? {
|
||||
if (raw == null || raw.isEmpty()) return null
|
||||
val mode = raw.string("mode", "auto")
|
||||
val normalizedMode = if (mode in setOf("reps", "seconds", "minutes", "none", "auto")) mode else "auto"
|
||||
return ResultSchemaUi(
|
||||
mode = normalizedMode,
|
||||
weightMode = raw.string("weight_mode", "none").takeIf { it in setOf("none", "optional", "required") } ?: "none",
|
||||
laterality = raw.string("laterality", "bilateral").takeIf { it in setOf("bilateral", "unilateral") } ?: "bilateral",
|
||||
sidesMode = raw.string("sides_mode", "same").takeIf { it in setOf("same", "separate") } ?: "same",
|
||||
defaultSets = (raw.int("default_sets", raw.int("sets", 3))).coerceIn(1, 20),
|
||||
lockedSets = raw.bool("locked_sets"),
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
|
||||
fun mergeResultSchemaLayers(
|
||||
exerciseSchema: JsonObject?,
|
||||
progressionSchema: JsonObject?,
|
||||
manualSchema: JsonObject?,
|
||||
): ResultSchemaUi {
|
||||
fun apply(base: ResultSchemaUi, raw: JsonObject?, source: String): ResultSchemaUi {
|
||||
if (raw == null || raw.isEmpty()) return base
|
||||
val mode = raw["mode"]?.jsonPrimitive?.contentOrNull
|
||||
?.takeIf { it in setOf("reps", "seconds", "minutes", "none", "auto") }
|
||||
?: base.mode
|
||||
val weightMode = raw["weight_mode"]?.jsonPrimitive?.contentOrNull
|
||||
?.takeIf { it in setOf("none", "optional", "required") }
|
||||
?: base.weightMode
|
||||
val laterality = raw["laterality"]?.jsonPrimitive?.contentOrNull
|
||||
?.takeIf { it in setOf("bilateral", "unilateral") }
|
||||
?: base.laterality
|
||||
val sidesMode = raw["sides_mode"]?.jsonPrimitive?.contentOrNull
|
||||
?.takeIf { it in setOf("same", "separate") }
|
||||
?: base.sidesMode
|
||||
val sets = when {
|
||||
raw["sets"]?.jsonPrimitive?.intOrNull != null -> raw["sets"]!!.jsonPrimitive.intOrNull
|
||||
raw["default_sets"]?.jsonPrimitive?.intOrNull != null -> raw["default_sets"]!!.jsonPrimitive.intOrNull
|
||||
else -> null
|
||||
}?.coerceIn(1, 20) ?: base.defaultSets
|
||||
val locked = raw["locked_sets"]?.jsonPrimitive?.booleanOrNull ?: base.lockedSets
|
||||
return base.copy(
|
||||
mode = mode,
|
||||
weightMode = weightMode,
|
||||
laterality = laterality,
|
||||
sidesMode = if (laterality == "bilateral") "same" else sidesMode,
|
||||
defaultSets = sets,
|
||||
lockedSets = locked,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
|
||||
var merged = ResultSchemaUi()
|
||||
merged = apply(merged, exerciseSchema, "exercise")
|
||||
merged = apply(merged, progressionSchema, "progression")
|
||||
merged = apply(merged, manualSchema, "session")
|
||||
if ((exerciseSchema == null || exerciseSchema.isEmpty()) &&
|
||||
(progressionSchema == null || progressionSchema.isEmpty()) &&
|
||||
(manualSchema == null || manualSchema.isEmpty())
|
||||
) {
|
||||
merged = merged.copy(source = "fallback")
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
fun resolveResultSchema(
|
||||
exercise: ExerciseUi,
|
||||
item: JsonObject,
|
||||
progressionText: String,
|
||||
plan: PlanUi,
|
||||
): ResultSchemaUi {
|
||||
val manualRaw = item["result_format"] as? JsonObject
|
||||
val progression = plan.progressions[exercise.progressionId]
|
||||
val stepId = item.string("progression_step_id")
|
||||
val step = progression?.steps?.firstOrNull { it.id == stepId }
|
||||
?: progression?.steps?.firstOrNull { it.name.equals(progressionText, ignoreCase = true) }
|
||||
val stepRaw = step?.raw?.get("result_schema") as? JsonObject
|
||||
val exerciseRaw = exercise.raw["result_schema"] as? JsonObject
|
||||
val base = mergeResultSchemaLayers(exerciseRaw, stepRaw, manualRaw)
|
||||
|
||||
val text = "${exercise.name} $progressionText ${exercise.cue}".lowercase()
|
||||
val static = Regex("hold|plank|hang|wall sit|wandsitz|isometr|stützposition").containsMatchIn(text)
|
||||
val dynamic = Regex("push.?up|pull.?up|klimmzug|liegestütz|senkung|negativ|reps|rudern|row|squat|kniebeuge|dip|curl|deadlift|rdl|hinge|swing|raise|heben|climber|crunch|twist|lunge|ausfallschritt").containsMatchIn(progressionText.lowercase())
|
||||
val mode = when {
|
||||
base.mode == "auto" -> if (static && !dynamic) "seconds" else "reps"
|
||||
base.source == "exercise" && base.mode !in setOf("none", "minutes") && static && !dynamic -> "seconds"
|
||||
else -> base.mode
|
||||
}
|
||||
val inferredLaterality = when {
|
||||
base.source !in setOf("progression", "session") && Regex("einarm|einbein|je seite|pro seite").containsMatchIn(progressionText.lowercase()) -> "unilateral"
|
||||
base.source !in setOf("progression", "session") && Regex("beidbeinig|zweiarmig|beide seiten gemeinsam").containsMatchIn(progressionText.lowercase()) -> "bilateral"
|
||||
else -> base.laterality
|
||||
}
|
||||
val inferredWeight = if (base.source in setOf("progression", "session")) base.weightMode else {
|
||||
val low = progressionText.lowercase()
|
||||
val unweighted = Regex("ohne gewicht|ungewichtet|körpergewicht|koerpergewicht|\\bbw\\b").containsMatchIn(low)
|
||||
val loaded = Regex("gewicht|gewichtet|zusatzlast|\\blast\\b|\\bkg\\b|kettlebell|\\bkb\\b|rucksack").containsMatchIn(low)
|
||||
when {
|
||||
unweighted && loaded -> "optional"
|
||||
unweighted -> "none"
|
||||
loaded -> if (Regex("möglich|optional|bei 8").containsMatchIn(low)) "optional" else "required"
|
||||
else -> base.weightMode
|
||||
}
|
||||
}
|
||||
return base.copy(
|
||||
mode = mode,
|
||||
laterality = inferredLaterality,
|
||||
sidesMode = if (inferredLaterality == "unilateral") base.sidesMode else "same",
|
||||
weightMode = inferredWeight,
|
||||
)
|
||||
}
|
||||
|
||||
fun sessionKey(week: Int, day: Int): String = "w%02d-d%02d".format(week, day)
|
||||
|
||||
fun blankSession(plan: PlanUi): JsonObject = buildJsonObject {
|
||||
put("status", "planned")
|
||||
put("plan_id", plan.planId)
|
||||
put("plan_revision", plan.publishedRevision)
|
||||
put("items", JsonObject(emptyMap()))
|
||||
put("note", "")
|
||||
}
|
||||
|
||||
fun trackerSession(tracker: JsonObject, key: String, plan: PlanUi): JsonObject {
|
||||
return (tracker.obj("sessions")[key] as? JsonObject) ?: blankSession(plan)
|
||||
}
|
||||
|
||||
fun migrateLegacyItems(session: JsonObject, day: DayUi): Pair<JsonObject, Boolean> {
|
||||
val items = session.obj("items").toMutableMap()
|
||||
var changed = false
|
||||
day.rotations.flatMap { it.exercises }.forEach { exercise ->
|
||||
if (items[exercise.id] == null && exercise.legacyId.isNotBlank() && items[exercise.legacyId] != null) {
|
||||
items[exercise.id] = items.getValue(exercise.legacyId)
|
||||
items.remove(exercise.legacyId)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return session.updated("items", JsonObject(items)) to changed
|
||||
}
|
||||
|
||||
fun itemFor(session: JsonObject, exerciseId: String): JsonObject = session.obj("items")[exerciseId] as? JsonObject
|
||||
?: buildJsonObject {
|
||||
put("completion_status", "planned")
|
||||
put("done", false)
|
||||
put("progression", "")
|
||||
put("result", "")
|
||||
put("note", "")
|
||||
}
|
||||
|
||||
fun updateItem(session: JsonObject, exerciseId: String, item: JsonObject): JsonObject {
|
||||
val items = session.obj("items").toMutableMap().apply { put(exerciseId, item) }
|
||||
return session.updated("items", JsonObject(items))
|
||||
}
|
||||
|
||||
fun updateTrackerSession(tracker: JsonObject, key: String, session: JsonObject): JsonObject {
|
||||
val sessions = tracker.obj("sessions").toMutableMap().apply { put(key, session) }
|
||||
return tracker.updated("sessions", JsonObject(sessions))
|
||||
}
|
||||
|
||||
fun sessionPatch(tracker: JsonObject, key: String, session: JsonObject): JsonObject = buildJsonObject {
|
||||
put("expected_revision", tracker.int("revision", 1))
|
||||
put("profile", tracker.obj("profile"))
|
||||
put("week_statuses", tracker.obj("week_statuses"))
|
||||
put("session_key", key)
|
||||
put("session", session)
|
||||
}
|
||||
|
||||
fun overlaySessionPatches(base: JsonObject, patches: List<JsonObject>): JsonObject {
|
||||
var tracker = base
|
||||
patches.forEach { patch ->
|
||||
patch["profile"]?.let { tracker = tracker.updated("profile", it) }
|
||||
patch["week_statuses"]?.let { tracker = tracker.updated("week_statuses", it) }
|
||||
val key = patch.string("session_key")
|
||||
val session = patch["session"] as? JsonObject
|
||||
if (key.isNotBlank() && session != null) tracker = updateTrackerSession(tracker, key, session)
|
||||
val localRevision = patch.int("expected_revision", tracker.int("revision", 1)) + 1
|
||||
if (localRevision > tracker.int("revision", 1)) tracker = tracker.updatedInt("revision", localRevision)
|
||||
}
|
||||
return tracker
|
||||
}
|
||||
|
||||
fun adaptResultDataToSchema(current: JsonObject, schema: ResultSchemaUi): JsonObject? {
|
||||
if (schema.mode == "none") return null
|
||||
val oldMode = current.string("mode", schema.mode)
|
||||
val oldLaterality = current.string("laterality", schema.laterality)
|
||||
val oldSides = current.string("sides_mode", schema.sidesMode)
|
||||
val incompatible = oldMode != schema.mode || oldLaterality != schema.laterality ||
|
||||
(schema.laterality == "unilateral" && oldSides != schema.sidesMode)
|
||||
var data = current
|
||||
.updatedString("mode", schema.mode)
|
||||
.updatedString("laterality", schema.laterality)
|
||||
.updatedString("sides_mode", if (schema.laterality == "unilateral") schema.sidesMode else "same")
|
||||
if (incompatible) {
|
||||
data = data
|
||||
.updated("values", JsonArray(emptyList()))
|
||||
.updated("left_values", JsonArray(emptyList()))
|
||||
.updated("right_values", JsonArray(emptyList()))
|
||||
}
|
||||
if (schema.lockedSets) data = resultDataWithSets(data, schema.defaultSets)
|
||||
if (schema.weightMode == "none") data = data.updated("weight_kg", null)
|
||||
return normalizeResultData(data, schema)
|
||||
}
|
||||
|
||||
fun emptyResultData(schema: ResultSchemaUi): JsonObject {
|
||||
val sets = schema.defaultSets.coerceIn(1, 20)
|
||||
return buildJsonObject {
|
||||
put("version", 2)
|
||||
put("mode", schema.mode)
|
||||
put("laterality", schema.laterality)
|
||||
put("sides_mode", if (schema.laterality == "unilateral") schema.sidesMode else "same")
|
||||
put("sets", sets)
|
||||
put("weight_kg", JsonNull)
|
||||
put("values", buildJsonArray { repeat(sets) { add(JsonNull) } })
|
||||
put("left_values", buildJsonArray { repeat(sets) { add(JsonNull) } })
|
||||
put("right_values", buildJsonArray { repeat(sets) { add(JsonNull) } })
|
||||
}
|
||||
}
|
||||
|
||||
fun normalizedResultData(item: JsonObject, schema: ResultSchemaUi): JsonObject {
|
||||
val structured = item["result_data"] as? JsonObject
|
||||
if (structured != null) return normalizeResultData(structured, schema)
|
||||
return parseLegacyResult(item.string("result"), schema) ?: emptyResultData(schema)
|
||||
}
|
||||
|
||||
private fun normalizeResultData(raw: JsonObject, schema: ResultSchemaUi): JsonObject {
|
||||
val sets = raw.int("sets", schema.defaultSets).coerceIn(1, 20)
|
||||
fun normalizedArray(key: String): JsonArray {
|
||||
val values = raw.array(key).take(sets).toMutableList()
|
||||
while (values.size < sets) values.add(JsonNull)
|
||||
return JsonArray(values)
|
||||
}
|
||||
return buildJsonObject {
|
||||
put("version", 2)
|
||||
put("mode", raw.string("mode", schema.mode))
|
||||
put("laterality", raw.string("laterality", schema.laterality))
|
||||
put("sides_mode", raw.string("sides_mode", schema.sidesMode))
|
||||
put("sets", sets)
|
||||
put("weight_kg", raw["weight_kg"] ?: JsonNull)
|
||||
put("values", normalizedArray("values"))
|
||||
put("left_values", normalizedArray("left_values"))
|
||||
put("right_values", normalizedArray("right_values"))
|
||||
}
|
||||
}
|
||||
|
||||
fun parseLegacyResult(text: String, schema: ResultSchemaUi): JsonObject? {
|
||||
val raw = text.trim()
|
||||
if (raw.isBlank()) return null
|
||||
val weight = Regex("(\\d+(?:[.,]\\d+)?)\\s*kg", RegexOption.IGNORE_CASE)
|
||||
.find(raw)?.groupValues?.getOrNull(1)?.replace(',', '.')?.toDoubleOrNull()
|
||||
val repeatedSeconds = Regex("(\\d+)\\s*[x×]\\s*(\\d+(?:[.,]\\d+)?)\\s*s", RegexOption.IGNORE_CASE).find(raw)
|
||||
val values: List<Double> = when {
|
||||
repeatedSeconds != null -> {
|
||||
val count = repeatedSeconds.groupValues[1].toIntOrNull()?.coerceIn(1, 20) ?: 1
|
||||
val value = repeatedSeconds.groupValues[2].replace(',', '.').toDoubleOrNull() ?: return null
|
||||
List(count) { value }
|
||||
}
|
||||
else -> raw.replace(Regex("\\d+(?:[.,]\\d+)?\\s*kg", RegexOption.IGNORE_CASE), "")
|
||||
.split('/', ';')
|
||||
.mapNotNull { Regex("-?\\d+(?:[.,]\\d+)?").find(it)?.value?.replace(',', '.')?.toDoubleOrNull() }
|
||||
.take(20)
|
||||
}
|
||||
if (values.isEmpty() && weight == null) return null
|
||||
val mode = when {
|
||||
repeatedSeconds != null || Regex("\\bsek|\\bs\\b|second", RegexOption.IGNORE_CASE).containsMatchIn(raw) -> "seconds"
|
||||
Regex("min", RegexOption.IGNORE_CASE).containsMatchIn(raw) -> "minutes"
|
||||
else -> schema.mode.takeIf { it in setOf("reps", "seconds", "minutes") } ?: "reps"
|
||||
}
|
||||
val sets = values.size.coerceAtLeast(1)
|
||||
return buildJsonObject {
|
||||
put("version", 2)
|
||||
put("mode", mode)
|
||||
put("laterality", schema.laterality)
|
||||
put("sides_mode", schema.sidesMode)
|
||||
put("sets", sets)
|
||||
put("weight_kg", weight?.let(::JsonPrimitive) ?: JsonNull)
|
||||
put("values", JsonArray(values.map(::JsonPrimitive)))
|
||||
put("left_values", buildJsonArray { repeat(sets) { add(JsonNull) } })
|
||||
put("right_values", buildJsonArray { repeat(sets) { add(JsonNull) } })
|
||||
}
|
||||
}
|
||||
|
||||
fun formatResultData(data: JsonObject): String {
|
||||
fun num(element: JsonElement?): String = element?.jsonPrimitive?.doubleOrNull?.let {
|
||||
if (it % 1.0 == 0.0) it.toInt().toString() else it.toString().trimEnd('0').trimEnd('.')
|
||||
} ?: "–"
|
||||
val mode = data.string("mode", "reps")
|
||||
val unit = when (mode) { "seconds" -> " s"; "minutes" -> " min"; else -> " Reps" }
|
||||
val weight = data["weight_kg"]?.jsonPrimitive?.doubleOrNull?.let { "${num(JsonPrimitive(it))} kg · " }.orEmpty()
|
||||
val laterality = data.string("laterality", "bilateral")
|
||||
val separate = laterality == "unilateral" && data.string("sides_mode") == "separate"
|
||||
return if (separate) {
|
||||
val left = data.array("left_values").joinToString("/") { num(it) }
|
||||
val right = data.array("right_values").joinToString("/") { num(it) }
|
||||
"$weight L $left · R $right$unit"
|
||||
} else {
|
||||
val values = data.array("values").joinToString("/") { num(it) }
|
||||
"$weight$values$unit${if (laterality == "unilateral") " je Seite" else ""}"
|
||||
}.trim()
|
||||
}
|
||||
|
||||
fun resultDataWithValue(data: JsonObject, field: String, index: Int, value: Double?): JsonObject {
|
||||
val list = data.array(field).toMutableList()
|
||||
while (list.size <= index) list.add(JsonNull)
|
||||
list[index] = value?.let(::JsonPrimitive) ?: JsonNull
|
||||
return data.updated(field, JsonArray(list))
|
||||
}
|
||||
|
||||
fun resultDataWithSets(data: JsonObject, sets: Int): JsonObject {
|
||||
val count = sets.coerceIn(1, 20)
|
||||
var result = data.updatedInt("sets", count)
|
||||
for (field in listOf("values", "left_values", "right_values")) {
|
||||
val list = result.array(field).take(count).toMutableList()
|
||||
while (list.size < count) list.add(JsonNull)
|
||||
result = result.updated(field, JsonArray(list))
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package de.drazz.boehmitools.training.ui
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.FitnessCenter
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import de.drazz.boehmitools.training.data.settings.SettingsRepository
|
||||
import de.drazz.boehmitools.training.ui.editor.EditorScreen
|
||||
import de.drazz.boehmitools.training.ui.setup.SettingsScreen
|
||||
import de.drazz.boehmitools.training.ui.tracker.TrackerScreen
|
||||
|
||||
private data class AppDestination(val route: String, val label: String, val icon: @Composable () -> Unit)
|
||||
|
||||
@Composable
|
||||
fun AppRoot(
|
||||
settings: SettingsRepository.PublicSettings,
|
||||
onTheme: (String) -> Unit,
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val destinations = listOf(
|
||||
AppDestination("tracker", "Tracker") { Icon(Icons.Default.FitnessCenter, null) },
|
||||
AppDestination("editor", "Planeditor") { Icon(Icons.Default.Edit, null) },
|
||||
AppDestination("settings", "Einstellungen") { Icon(Icons.Default.Settings, null) },
|
||||
)
|
||||
val backStack by navController.currentBackStackEntryAsState()
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
destinations.forEach { destination ->
|
||||
val selected = backStack?.destination?.hierarchy?.any { it.route == destination.route } == true
|
||||
NavigationBarItem(
|
||||
selected = selected,
|
||||
onClick = {
|
||||
navController.navigate(destination.route) {
|
||||
popUpTo("tracker") { saveState = true }
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
icon = destination.icon,
|
||||
label = { Text(destination.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
NavHost(navController = navController, startDestination = "tracker", modifier = Modifier) {
|
||||
composable("tracker") { TrackerScreen(contentPadding = padding) }
|
||||
composable("editor") { EditorScreen(contentPadding = padding) }
|
||||
composable("settings") {
|
||||
SettingsScreen(
|
||||
contentPadding = padding,
|
||||
current = settings,
|
||||
onTheme = onTheme,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package de.drazz.boehmitools.training.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.drazz.boehmitools.training.data.settings.SettingsRepository
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@HiltViewModel
|
||||
class AppViewModel @Inject constructor(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
) : ViewModel() {
|
||||
val settings = settingsRepository.publicSettings.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5_000),
|
||||
SettingsRepository.PublicSettings(
|
||||
SettingsRepository.DEFAULT_SERVER,
|
||||
"admin",
|
||||
false,
|
||||
"system",
|
||||
),
|
||||
)
|
||||
|
||||
fun setTheme(theme: String) {
|
||||
viewModelScope.launch { settingsRepository.setTheme(theme) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
package de.drazz.boehmitools.training.ui.editor
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.drazz.boehmitools.training.domain.array
|
||||
import de.drazz.boehmitools.training.domain.bool
|
||||
import de.drazz.boehmitools.training.domain.int
|
||||
import de.drazz.boehmitools.training.domain.obj
|
||||
import de.drazz.boehmitools.training.domain.string
|
||||
import de.drazz.boehmitools.training.domain.updatedBool
|
||||
import de.drazz.boehmitools.training.domain.updatedInt
|
||||
import de.drazz.boehmitools.training.domain.updatedString
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
private val editorTabs = listOf("Grunddaten", "Tage", "Progressionen", "Bibliothek", "Prüfen", "Vorschläge")
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EditorScreen(
|
||||
contentPadding: PaddingValues,
|
||||
viewModel: EditorViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
var planMenu by remember { mutableStateOf(false) }
|
||||
var createDialog by remember { mutableStateOf(false) }
|
||||
var renameDialog by remember { mutableStateOf(false) }
|
||||
var deleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
state.conflict?.let { conflict ->
|
||||
AlertDialog(
|
||||
onDismissRequest = viewModel::dismissConflict,
|
||||
title = { Text("Revisionskonflikt") },
|
||||
text = { Text("Der Serverplan liegt inzwischen bei Revision ${conflict.currentRevision}. Deine Änderungen wurden nicht überschrieben.") },
|
||||
confirmButton = { TextButton(onClick = viewModel::reloadAfterConflict) { Text("Serverstand neu laden") } },
|
||||
dismissButton = { TextButton(onClick = viewModel::dismissConflict) { Text("Bearbeitung behalten") } },
|
||||
)
|
||||
}
|
||||
|
||||
if (createDialog) NameDialog(
|
||||
title = "Neuen Plan anlegen",
|
||||
initial = "",
|
||||
allowClone = state.selectedPlanId.isNotBlank(),
|
||||
onDismiss = { createDialog = false },
|
||||
onConfirm = { name, clone -> createDialog = false; viewModel.createPlan(name, clone) },
|
||||
)
|
||||
if (renameDialog) NameDialog(
|
||||
title = "Plan umbenennen",
|
||||
initial = state.wrapper.string("name"),
|
||||
allowClone = false,
|
||||
onDismiss = { renameDialog = false },
|
||||
onConfirm = { name, _ -> renameDialog = false; viewModel.renamePlan(name) },
|
||||
)
|
||||
if (deleteDialog) AlertDialog(
|
||||
onDismissRequest = { deleteDialog = false },
|
||||
title = { Text("Plan löschen?") },
|
||||
text = { Text("Der Server legt vor dem Löschen ein Backup an. Der letzte verbleibende Plan kann nicht gelöscht werden.") },
|
||||
confirmButton = { TextButton(onClick = { deleteDialog = false; viewModel.deletePlan() }) { Text("Löschen") } },
|
||||
dismissButton = { TextButton(onClick = { deleteDialog = false }) { Text("Abbrechen") } },
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.padding(contentPadding),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Box {
|
||||
TextButton(onClick = { planMenu = true }) {
|
||||
Text(state.wrapper.string("name", "Planeditor"), maxLines = 2)
|
||||
}
|
||||
DropdownMenu(expanded = planMenu, onDismissRequest = { planMenu = false }) {
|
||||
state.plans.forEach { plan ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("${plan.name}${if (plan.hasUnpublishedChanges) " •" else ""}") },
|
||||
onClick = { planMenu = false; viewModel.selectPlan(plan.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { createDialog = true }) { Icon(Icons.Default.Add, "Plan anlegen") }
|
||||
Box {
|
||||
IconButton(onClick = { planMenu = true }) { Icon(Icons.Default.MoreVert, "Planaktionen") }
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { inner ->
|
||||
Column(Modifier.fillMaxSize().padding(inner)) {
|
||||
ScrollableTabRow(selectedTabIndex = selectedTab, edgePadding = 8.dp) {
|
||||
editorTabs.forEachIndexed { index, title ->
|
||||
Tab(selected = index == selectedTab, onClick = { selectedTab = index }, text = { Text(title) })
|
||||
}
|
||||
}
|
||||
if (state.loading) {
|
||||
Box(Modifier.fillMaxSize().padding(32.dp)) { CircularProgressIndicator() }
|
||||
} else if (state.selectedPlanId.isBlank()) {
|
||||
EmptyEditor(onCreate = { createDialog = true })
|
||||
} else {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
StatusStrip(state)
|
||||
when (selectedTab) {
|
||||
0 -> BasicsPane(state, viewModel, onRename = { renameDialog = true }, onDelete = { deleteDialog = true })
|
||||
1 -> DaysPane(state, viewModel)
|
||||
2 -> ProgressionsPane(state, viewModel)
|
||||
3 -> LibraryPane(state)
|
||||
4 -> ValidationPane(state, viewModel)
|
||||
else -> ProposalsPane(state, viewModel) { selectedTab = 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusStrip(state: EditorUiState) {
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(
|
||||
"Entwurf R${state.wrapper.int("revision", 1)} · veröffentlicht R${state.wrapper.int("published_revision", 1)}${if (state.dirty) " · ungespeichert" else ""}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
when {
|
||||
state.error.isNotBlank() -> Text(state.error, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
state.message.isNotBlank() -> Text(state.message, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyEditor(onCreate: () -> Unit) {
|
||||
Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Noch kein Trainingsplan verfügbar.", style = MaterialTheme.typography.headlineSmall)
|
||||
Button(onClick = onCreate) { Text("Plan anlegen") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BasicsPane(state: EditorUiState, vm: EditorViewModel, onRename: () -> Unit, onDelete: () -> Unit) {
|
||||
val meta = state.config.obj("meta")
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text("Plan und Entwurf", style = MaterialTheme.typography.headlineSmall)
|
||||
OutlinedTextField(meta.string("title"), { vm.updateMeta("title", it) }, Modifier.fillMaxWidth(), label = { Text("Titel") })
|
||||
OutlinedTextField(meta.string("subtitle"), { vm.updateMeta("subtitle", it) }, Modifier.fillMaxWidth(), label = { Text("Untertitel") }, minLines = 2)
|
||||
OutlinedTextField(
|
||||
value = meta.int("weeks", 1).toString(),
|
||||
onValueChange = { it.toIntOrNull()?.let(vm::updateWeeks) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Wochen") },
|
||||
singleLine = true,
|
||||
)
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Stabile Identität", fontWeight = FontWeight.Bold)
|
||||
Text("Plan-ID: ${state.wrapper.string("plan_id", state.selectedPlanId)}")
|
||||
Text("Datei-ID: ${state.selectedPlanId}")
|
||||
Text("Vorhandene Sessions: ${state.wrapper.int("tracked_sessions")}")
|
||||
}
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = vm::save, enabled = !state.saving && state.dirty, modifier = Modifier.weight(1f)) { Text("Entwurf speichern") }
|
||||
OutlinedButton(onClick = vm::validate, enabled = !state.saving, modifier = Modifier.weight(1f)) { Text("Prüfen") }
|
||||
}
|
||||
Button(onClick = vm::publish, enabled = !state.saving && !state.dirty, modifier = Modifier.fillMaxWidth()) { Text("Entwurf veröffentlichen") }
|
||||
OutlinedButton(onClick = onRename, modifier = Modifier.fillMaxWidth()) { Text("Plan umbenennen") }
|
||||
TextButton(onClick = onDelete, modifier = Modifier.fillMaxWidth()) { Text("Plan löschen", color = MaterialTheme.colorScheme.error) }
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DaysPane(state: EditorUiState, vm: EditorViewModel) {
|
||||
val target = state.proposalTargetId
|
||||
val prepost = state.config.obj("prepost")
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp).testTag("editor-days"),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text("Trainingstage", style = MaterialTheme.typography.headlineSmall)
|
||||
state.config.array("days").mapNotNull { it as? JsonObject }.forEach { day ->
|
||||
DayEditor(day, prepost.obj(day.int("num").toString()), vm, highlightExerciseId = target)
|
||||
}
|
||||
OutlinedButton(onClick = vm::addDay, modifier = Modifier.fillMaxWidth()) { Icon(Icons.Default.Add, null); Text(" Tag hinzufügen") }
|
||||
Spacer(Modifier.height(80.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayEditor(day: JsonObject, prepost: JsonObject, vm: EditorViewModel, highlightExerciseId: String) {
|
||||
val dayId = day.string("id")
|
||||
OutlinedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text("Tag ${day.int("num", 1)}", style = MaterialTheme.typography.titleLarge)
|
||||
IconButton(onClick = { vm.removeDay(dayId) }) { Icon(Icons.Default.Delete, "Tag löschen") }
|
||||
}
|
||||
OutlinedTextField(day.string("focus"), { value -> vm.updateDay(dayId) { it.updatedString("focus", value) } }, Modifier.fillMaxWidth(), label = { Text("Fokus") })
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
Checkbox(day.bool("light"), { value -> vm.updateDay(dayId) { it.updatedBool("light", value) } })
|
||||
Text("Leichter Trainingstag")
|
||||
}
|
||||
OutlinedTextField(
|
||||
prepost.string("warmup"),
|
||||
{ vm.updatePrePost(day.int("num"), "warmup", it) },
|
||||
Modifier.fillMaxWidth(),
|
||||
label = { Text("Warm-up") },
|
||||
minLines = 2,
|
||||
)
|
||||
OutlinedTextField(
|
||||
prepost.string("cooldown"),
|
||||
{ vm.updatePrePost(day.int("num"), "cooldown", it) },
|
||||
Modifier.fillMaxWidth(),
|
||||
label = { Text("Cool-down") },
|
||||
minLines = 2,
|
||||
)
|
||||
OutlinedTextField(
|
||||
prepost.string("stretch"),
|
||||
{ vm.updatePrePost(day.int("num"), "stretch", it) },
|
||||
Modifier.fillMaxWidth(),
|
||||
label = { Text("Stretching") },
|
||||
minLines = 2,
|
||||
)
|
||||
day.array("rotations").mapNotNull { it as? JsonObject }.forEach { rotation ->
|
||||
RotationEditor(dayId, rotation, vm, highlightExerciseId)
|
||||
}
|
||||
OutlinedButton(onClick = { vm.addRotation(dayId) }, modifier = Modifier.fillMaxWidth()) { Text("Block hinzufügen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RotationEditor(dayId: String, rotation: JsonObject, vm: EditorViewModel, highlightExerciseId: String) {
|
||||
val rotationId = rotation.string("id")
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
OutlinedTextField(
|
||||
rotation.string("label"),
|
||||
{ value -> vm.updateRotation(dayId, rotationId) { it.updatedString("label", value) } },
|
||||
Modifier.weight(1f),
|
||||
label = { Text("Block") },
|
||||
)
|
||||
IconButton(onClick = { vm.removeRotation(dayId, rotationId) }) { Icon(Icons.Default.Delete, "Block löschen") }
|
||||
}
|
||||
rotation.array("exercises").mapNotNull { it as? JsonObject }.forEach { exercise ->
|
||||
ExerciseEditor(dayId, rotationId, exercise, vm, highlightExerciseId == exercise.string("exercise_id"))
|
||||
}
|
||||
OutlinedButton(onClick = { vm.addExercise(dayId, rotationId) }, modifier = Modifier.fillMaxWidth()) { Text("Übung hinzufügen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExerciseEditor(dayId: String, rotationId: String, exercise: JsonObject, vm: EditorViewModel, highlighted: Boolean) {
|
||||
val placementId = exercise.string("id")
|
||||
OutlinedCard(Modifier.fillMaxWidth().testTag("exercise-$placementId")) {
|
||||
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (highlighted) AssistChip(onClick = {}, label = { Text("Ziel des ausgewählten Vorschlags") })
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(exercise.string("name", "Übung"), style = MaterialTheme.typography.titleMedium)
|
||||
IconButton(onClick = { vm.removeExercise(dayId, rotationId, placementId) }) { Icon(Icons.Default.Delete, "Übung löschen") }
|
||||
}
|
||||
OutlinedTextField(exercise.string("name"), { value -> vm.updateExercise(dayId, rotationId, placementId) { it.updatedString("name", value) } }, Modifier.fillMaxWidth(), label = { Text("Name") })
|
||||
OutlinedTextField(exercise.string("cue"), { value -> vm.updateExercise(dayId, rotationId, placementId) { it.updatedString("cue", value) } }, Modifier.fillMaxWidth(), label = { Text("Hinweis") }, minLines = 2)
|
||||
OutlinedTextField(exercise.string("exercise_id"), {}, Modifier.fillMaxWidth(), readOnly = true, label = { Text("Stabile Bewegungs-ID") })
|
||||
OutlinedTextField(exercise.string("progression_id"), { value -> vm.updateExercise(dayId, rotationId, placementId) { it.updatedString("progression_id", value) } }, Modifier.fillMaxWidth(), label = { Text("Progressions-ID") })
|
||||
ResultSchemaEditor(
|
||||
schema = exercise.obj("result_schema"),
|
||||
onField = { field, value -> vm.updateExerciseSchema(dayId, rotationId, placementId, field, value) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProgressionsPane(state: EditorUiState, vm: EditorViewModel) {
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Progressionsstufen", style = MaterialTheme.typography.headlineSmall)
|
||||
state.config.obj("stages").forEach { (key, element) ->
|
||||
val stage = element as? JsonObject ?: return@forEach
|
||||
OutlinedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(stage.string("name", key), style = MaterialTheme.typography.titleLarge)
|
||||
Text("ID: ${stage.string("id", key)} · Schlüssel: $key", style = MaterialTheme.typography.bodySmall)
|
||||
stage.array("steps").mapNotNull { it as? JsonObject }.forEach { step ->
|
||||
val stepId = step.string("id")
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(step.string("name"), { value -> vm.updateProgressionStep(key, stepId) { it.updatedString("name", value) } }, Modifier.fillMaxWidth(), label = { Text("Stufe") })
|
||||
OutlinedTextField(step.string("phase_id"), { value -> vm.updateProgressionStep(key, stepId) { it.updatedString("phase_id", value) } }, Modifier.fillMaxWidth(), label = { Text("Phasen-ID") })
|
||||
ResultSchemaEditor(step.obj("result_schema")) { field, value -> vm.updateStepSchema(key, stepId, field, value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedButton(onClick = { vm.addProgressionStep(key) }, modifier = Modifier.fillMaxWidth()) { Text("Stufe hinzufügen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(80.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultSchemaEditor(schema: JsonObject, onField: (String, JsonPrimitive) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
Text("Ergebnisschema", fontWeight = FontWeight.Bold)
|
||||
ChoiceRow("Modus", listOf("reps", "seconds", "minutes", "none"), schema.string("mode", "reps")) { onField("mode", JsonPrimitive(it)) }
|
||||
ChoiceRow("Gewicht", listOf("none", "optional", "required"), schema.string("weight_mode", "none")) { onField("weight_mode", JsonPrimitive(it)) }
|
||||
ChoiceRow("Seiten", listOf("bilateral", "unilateral"), schema.string("laterality", "bilateral")) { onField("laterality", JsonPrimitive(it)) }
|
||||
if (schema.string("laterality") == "unilateral") {
|
||||
ChoiceRow("Seitenwerte", listOf("same", "separate"), schema.string("sides_mode", "same")) { onField("sides_mode", JsonPrimitive(it)) }
|
||||
}
|
||||
OutlinedTextField(
|
||||
schema.int("sets", 3).toString(),
|
||||
{ it.toIntOrNull()?.coerceIn(1, 20)?.let { count -> onField("sets", JsonPrimitive(count)) } },
|
||||
Modifier.fillMaxWidth(),
|
||||
label = { Text("Standardsätze") },
|
||||
singleLine = true,
|
||||
)
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
Checkbox(schema.bool("locked_sets"), { onField("locked_sets", JsonPrimitive(it)) })
|
||||
Text("Satzanzahl sperren")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChoiceRow(label: String, choices: List<String>, selected: String, onSelect: (String) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.labelMedium)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
choices.forEach { value ->
|
||||
FilterChip(
|
||||
selected = selected == value,
|
||||
onClick = { onSelect(value) },
|
||||
label = { Text(value) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LibraryPane(state: EditorUiState) {
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Übungsbibliothek", style = MaterialTheme.typography.headlineSmall)
|
||||
Text("Die Bibliothek wird beim Speichern serverseitig aus stabilen Bewegungs- und Progressions-IDs neu aufgebaut.")
|
||||
val catalog = state.config.obj("exercise_catalog")
|
||||
if (catalog.isEmpty()) Text("Noch kein Katalog im Entwurf. Speichern oder validieren erzeugt ihn.")
|
||||
catalog.forEach { (id, element) ->
|
||||
val entry = element as? JsonObject ?: return@forEach
|
||||
OutlinedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(entry.string("movement_label", entry.string("name", id)), fontWeight = FontWeight.Bold)
|
||||
Text(id, style = MaterialTheme.typography.bodySmall)
|
||||
Text("Cluster: ${entry.string("movement_cluster", "general")}")
|
||||
Text("Varianten: ${entry.array("variants").size}")
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(80.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ValidationPane(state: EditorUiState, vm: EditorViewModel) {
|
||||
val validation = state.validation
|
||||
val errors = validation.array("errors")
|
||||
val warnings = validation.array("warnings")
|
||||
val diff = validation.obj("diff")
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Validierung und Vorschau", style = MaterialTheme.typography.headlineSmall)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = vm::validate, enabled = !state.saving, modifier = Modifier.weight(1f)) { Text("Validieren") }
|
||||
OutlinedButton(onClick = vm::save, enabled = !state.saving && state.dirty, modifier = Modifier.weight(1f)) { Text("Speichern") }
|
||||
}
|
||||
if (validation.isEmpty()) Text("Noch keine Prüfung für den aktuellen Entwurf ausgeführt.")
|
||||
errors.forEach { Text("Fehler: ${it.toString().trim('"')}", color = MaterialTheme.colorScheme.error) }
|
||||
warnings.forEach { Text("Warnung: ${it.toString().trim('"')}") }
|
||||
if (validation.isNotEmpty() && errors.isEmpty()) AssistChip(onClick = {}, label = { Text("Keine Validierungsfehler") })
|
||||
if (diff.isNotEmpty()) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Änderungsübersicht", style = MaterialTheme.typography.titleMedium)
|
||||
Text("Bestehende Sessions: ${diff.int("tracked_sessions")}")
|
||||
listOf("days" to "Tage", "rotations" to "Blöcke", "exercises" to "Übungen", "progression_steps" to "Progressionsstufen").forEach { (key, label) ->
|
||||
val section = diff.obj(key)
|
||||
Text("$label: +${section.array("added").size} / −${section.array("removed").size} / ~${section.array("changed").size}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TrackerPreview(state.config)
|
||||
Button(onClick = vm::publish, enabled = !state.saving && !state.dirty && errors.isEmpty(), modifier = Modifier.fillMaxWidth()) { Text("Geprüften Entwurf veröffentlichen") }
|
||||
Spacer(Modifier.height(80.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackerPreview(config: JsonObject) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Tracker-Vorschau", style = MaterialTheme.typography.titleMedium)
|
||||
config.array("days").mapNotNull { it as? JsonObject }.forEach { day ->
|
||||
Text("Tag ${day.int("num")}: ${day.string("focus")}", fontWeight = FontWeight.Bold)
|
||||
day.array("rotations").mapNotNull { it as? JsonObject }.forEach { rotation ->
|
||||
Text(rotation.string("label"))
|
||||
rotation.array("exercises").mapNotNull { it as? JsonObject }.forEach { exercise -> Text("• ${exercise.string("name")}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProposalsPane(state: EditorUiState, vm: EditorViewModel, showDays: () -> Unit) {
|
||||
val proposals = state.proposals.array("proposals").mapNotNull { it as? JsonObject }
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("KI-Vorschläge", style = MaterialTheme.typography.headlineSmall)
|
||||
Text("Vorschläge verändern den Plan niemals automatisch. Annahme und Ablehnung markieren ausschließlich den Prüfstatus.")
|
||||
if (proposals.isEmpty()) Text("Keine Vorschläge vorhanden.")
|
||||
proposals.forEach { proposal ->
|
||||
OutlinedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(proposal.string("target_label", proposal.string("target")), style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
|
||||
AssistChip(onClick = {}, label = { Text(proposal.string("status", "open")) })
|
||||
}
|
||||
Text(proposal.string("suggested_change"), fontWeight = FontWeight.Bold)
|
||||
Text(proposal.string("reason"))
|
||||
if (proposal.string("condition").isNotBlank()) Text("Bedingung: ${proposal.string("condition")}")
|
||||
if (proposal.string("manual_step").isNotBlank()) Text("Manueller Schritt: ${proposal.string("manual_step")}")
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
vm.navigateToProposal(proposal.string("target_exercise_id"))
|
||||
showDays()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Zum Ziel navigieren") }
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { vm.setProposalStatus(proposal.string("id"), "accepted") }, modifier = Modifier.weight(1f)) { Text("Annehmen") }
|
||||
OutlinedButton(onClick = { vm.setProposalStatus(proposal.string("id"), "rejected") }, modifier = Modifier.weight(1f)) { Text("Ablehnen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(80.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NameDialog(
|
||||
title: String,
|
||||
initial: String,
|
||||
allowClone: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (String, Boolean) -> Unit,
|
||||
) {
|
||||
var name by remember(initial) { mutableStateOf(initial) }
|
||||
var clone by remember { mutableStateOf(false) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text("Name") })
|
||||
if (allowClone) Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
Checkbox(clone, { clone = it })
|
||||
Text("Aktuellen Plan als Vorlage verwenden")
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onConfirm(name, clone) }, enabled = name.isNotBlank()) { Text("Übernehmen") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Abbrechen") } },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
package de.drazz.boehmitools.training.ui.editor
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.drazz.boehmitools.training.data.repository.RevisionConflictException
|
||||
import de.drazz.boehmitools.training.data.repository.TrainingRepository
|
||||
import de.drazz.boehmitools.training.data.settings.SettingsRepository
|
||||
import de.drazz.boehmitools.training.domain.array
|
||||
import de.drazz.boehmitools.training.domain.int
|
||||
import de.drazz.boehmitools.training.domain.obj
|
||||
import de.drazz.boehmitools.training.domain.string
|
||||
import de.drazz.boehmitools.training.domain.updated
|
||||
import de.drazz.boehmitools.training.domain.updatedBool
|
||||
import de.drazz.boehmitools.training.domain.updatedInt
|
||||
import de.drazz.boehmitools.training.domain.updatedString
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
data class EditorPlanSummary(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val revision: Int,
|
||||
val publishedRevision: Int,
|
||||
val active: Boolean,
|
||||
val hasUnpublishedChanges: Boolean,
|
||||
)
|
||||
|
||||
data class EditorUiState(
|
||||
val loading: Boolean = true,
|
||||
val saving: Boolean = false,
|
||||
val plans: List<EditorPlanSummary> = emptyList(),
|
||||
val selectedPlanId: String = "",
|
||||
val wrapper: JsonObject = JsonObject(emptyMap()),
|
||||
val config: JsonObject = JsonObject(emptyMap()),
|
||||
val validation: JsonObject = JsonObject(emptyMap()),
|
||||
val proposals: JsonObject = JsonObject(emptyMap()),
|
||||
val dirty: Boolean = false,
|
||||
val message: String = "",
|
||||
val error: String = "",
|
||||
val conflict: RevisionConflictException? = null,
|
||||
val proposalTargetId: String = "",
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class EditorViewModel @Inject constructor(
|
||||
private val repository: TrainingRepository,
|
||||
private val settings: SettingsRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(EditorUiState())
|
||||
val state: StateFlow<EditorUiState> = _state.asStateFlow()
|
||||
|
||||
init { loadPlans() }
|
||||
|
||||
fun loadPlans(force: Boolean = false) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = "", message = "")
|
||||
runCatching { repository.editorPlans(force) }
|
||||
.onSuccess { body ->
|
||||
val plans = body.array("plans").mapNotNull { it as? JsonObject }.map {
|
||||
EditorPlanSummary(
|
||||
id = it.string("id"),
|
||||
name = it.string("name", it.string("id")),
|
||||
revision = it.int("revision", 1),
|
||||
publishedRevision = it.int("published_revision", 1),
|
||||
active = it["active"]?.toString() == "true",
|
||||
hasUnpublishedChanges = it["has_unpublished_changes"]?.toString() == "true",
|
||||
)
|
||||
}
|
||||
val stored = settings.selectedEditorPlan()
|
||||
val selected = _state.value.selectedPlanId.takeIf { id -> plans.any { it.id == id } }
|
||||
?: stored?.takeIf { id -> plans.any { it.id == id } }
|
||||
?: body.string("active").takeIf { id -> plans.any { it.id == id } }
|
||||
?: plans.firstOrNull()?.id.orEmpty()
|
||||
_state.value = _state.value.copy(loading = false, plans = plans)
|
||||
if (selected.isNotBlank()) selectPlan(selected, persistServer = false)
|
||||
}
|
||||
.onFailure { error ->
|
||||
_state.value = _state.value.copy(loading = false, error = error.message ?: "Planliste konnte nicht geladen werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectPlan(planId: String, persistServer: Boolean = true) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, selectedPlanId = planId, error = "", conflict = null)
|
||||
runCatching {
|
||||
if (persistServer) repository.selectEditorPlan(planId)
|
||||
settings.setSelectedEditorPlan(planId)
|
||||
val wrapper = repository.editorPlan(planId, force = true)
|
||||
val proposals = repository.proposals(planId)
|
||||
wrapper to proposals
|
||||
}.onSuccess { (wrapper, proposals) ->
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
wrapper = wrapper,
|
||||
config = wrapper.obj("config"),
|
||||
proposals = proposals,
|
||||
validation = JsonObject(emptyMap()),
|
||||
dirty = false,
|
||||
message = "",
|
||||
)
|
||||
}.onFailure { error ->
|
||||
_state.value = _state.value.copy(loading = false, error = error.message ?: "Plan konnte nicht geladen werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createPlan(name: String, cloneCurrent: Boolean) {
|
||||
if (name.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = "")
|
||||
runCatching { repository.createPlan(name.trim(), _state.value.selectedPlanId.takeIf { cloneCurrent }) }
|
||||
.onSuccess { created ->
|
||||
_state.value = _state.value.copy(
|
||||
saving = false,
|
||||
selectedPlanId = created.string("id"),
|
||||
wrapper = created,
|
||||
config = created.obj("config"),
|
||||
message = "Plan angelegt.",
|
||||
)
|
||||
loadPlans(force = true)
|
||||
}
|
||||
.onFailure { error -> _state.value = _state.value.copy(saving = false, error = error.message ?: "Plan konnte nicht angelegt werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun renamePlan(name: String) {
|
||||
val id = _state.value.selectedPlanId
|
||||
if (id.isBlank() || name.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.renamePlan(id, name.trim()) }
|
||||
.onSuccess {
|
||||
_state.value = _state.value.copy(wrapper = _state.value.wrapper.updatedString("name", name.trim()), message = "Name geändert.")
|
||||
loadPlans(force = true)
|
||||
}
|
||||
.onFailure { error -> _state.value = _state.value.copy(error = error.message ?: "Umbenennen fehlgeschlagen.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun deletePlan() {
|
||||
val id = _state.value.selectedPlanId
|
||||
if (id.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.deletePlan(id) }
|
||||
.onSuccess {
|
||||
_state.value = EditorUiState(message = "Plan gelöscht.")
|
||||
loadPlans(force = true)
|
||||
}
|
||||
.onFailure { error -> _state.value = _state.value.copy(error = error.message ?: "Löschen fehlgeschlagen.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMeta(field: String, value: String) {
|
||||
val meta = _state.value.config.obj("meta").updatedString(field, value)
|
||||
mutateConfig(_state.value.config.updated("meta", meta))
|
||||
}
|
||||
|
||||
fun updateWeeks(value: Int) {
|
||||
val meta = _state.value.config.obj("meta").updatedInt("weeks", value.coerceAtLeast(1))
|
||||
mutateConfig(_state.value.config.updated("meta", meta))
|
||||
}
|
||||
|
||||
fun updatePrePost(dayNumber: Int, field: String, value: String) {
|
||||
val config = _state.value.config
|
||||
val prepost = config.obj("prepost").toMutableMap()
|
||||
val dayKey = dayNumber.toString()
|
||||
val entry = (prepost[dayKey] as? JsonObject ?: JsonObject(emptyMap())).updatedString(field, value)
|
||||
prepost[dayKey] = entry
|
||||
mutateConfig(config.updated("prepost", JsonObject(prepost)))
|
||||
}
|
||||
|
||||
fun updateDay(dayId: String, transform: (JsonObject) -> JsonObject) {
|
||||
val days = _state.value.config.array("days").map { element ->
|
||||
val day = element as? JsonObject ?: return@map element
|
||||
if (day.string("id") == dayId) transform(day) else day
|
||||
}
|
||||
mutateConfig(_state.value.config.updated("days", JsonArray(days)))
|
||||
}
|
||||
|
||||
fun addDay() {
|
||||
val config = _state.value.config
|
||||
val days = config.array("days").toMutableList()
|
||||
val number = (days.mapNotNull { (it as? JsonObject)?.int("num") }.maxOrNull() ?: 0) + 1
|
||||
days += buildJsonObject {
|
||||
put("id", stableId("day"))
|
||||
put("num", number)
|
||||
put("focus", "Neuer Trainingstag")
|
||||
put("light", false)
|
||||
put("rotations", JsonArray(emptyList()))
|
||||
}
|
||||
val prepost = config.obj("prepost").toMutableMap()
|
||||
prepost[number.toString()] = buildJsonObject {
|
||||
put("warmup", "")
|
||||
put("cooldown", "")
|
||||
put("stretch", "")
|
||||
}
|
||||
mutateConfig(config.updated("days", JsonArray(days)).updated("prepost", JsonObject(prepost)))
|
||||
}
|
||||
|
||||
fun removeDay(dayId: String) {
|
||||
val config = _state.value.config
|
||||
val removed = config.array("days").mapNotNull { it as? JsonObject }.firstOrNull { it.string("id") == dayId }
|
||||
val days = config.array("days").filterNot { (it as? JsonObject)?.string("id") == dayId }
|
||||
val prepost = config.obj("prepost").toMutableMap()
|
||||
removed?.let { prepost.remove(it.int("num").toString()) }
|
||||
mutateConfig(config.updated("days", JsonArray(days)).updated("prepost", JsonObject(prepost)))
|
||||
}
|
||||
|
||||
fun addRotation(dayId: String) {
|
||||
updateDay(dayId) { day ->
|
||||
val rotations = day.array("rotations").toMutableList()
|
||||
rotations += buildJsonObject {
|
||||
put("id", stableId("rotation"))
|
||||
put("label", "Neuer Block")
|
||||
put("exercises", JsonArray(emptyList()))
|
||||
}
|
||||
day.updated("rotations", JsonArray(rotations))
|
||||
}
|
||||
}
|
||||
|
||||
fun updateRotation(dayId: String, rotationId: String, transform: (JsonObject) -> JsonObject) {
|
||||
updateDay(dayId) { day ->
|
||||
val rotations = day.array("rotations").map { element ->
|
||||
val rotation = element as? JsonObject ?: return@map element
|
||||
if (rotation.string("id") == rotationId) transform(rotation) else rotation
|
||||
}
|
||||
day.updated("rotations", JsonArray(rotations))
|
||||
}
|
||||
}
|
||||
|
||||
fun removeRotation(dayId: String, rotationId: String) {
|
||||
updateDay(dayId) { day ->
|
||||
day.updated("rotations", JsonArray(day.array("rotations").filterNot { (it as? JsonObject)?.string("id") == rotationId }))
|
||||
}
|
||||
}
|
||||
|
||||
fun addExercise(dayId: String, rotationId: String) {
|
||||
updateRotation(dayId, rotationId) { rotation ->
|
||||
val exercises = rotation.array("exercises").toMutableList()
|
||||
val movementId = stableId("movement")
|
||||
exercises += buildJsonObject {
|
||||
put("id", stableId("exercise-placement"))
|
||||
put("legacy_id", "")
|
||||
put("exercise_id", movementId)
|
||||
put("progression_id", "")
|
||||
put("name", "Neue Übung")
|
||||
put("cue", "")
|
||||
put("movement_cluster", "general")
|
||||
put("result_schema", defaultSchema())
|
||||
}
|
||||
rotation.updated("exercises", JsonArray(exercises))
|
||||
}
|
||||
}
|
||||
|
||||
fun updateExercise(dayId: String, rotationId: String, exerciseId: String, transform: (JsonObject) -> JsonObject) {
|
||||
updateRotation(dayId, rotationId) { rotation ->
|
||||
val exercises = rotation.array("exercises").map { element ->
|
||||
val exercise = element as? JsonObject ?: return@map element
|
||||
if (exercise.string("id") == exerciseId) transform(exercise) else exercise
|
||||
}
|
||||
rotation.updated("exercises", JsonArray(exercises))
|
||||
}
|
||||
}
|
||||
|
||||
fun removeExercise(dayId: String, rotationId: String, exerciseId: String) {
|
||||
updateRotation(dayId, rotationId) { rotation ->
|
||||
rotation.updated("exercises", JsonArray(rotation.array("exercises").filterNot { (it as? JsonObject)?.string("id") == exerciseId }))
|
||||
}
|
||||
}
|
||||
|
||||
fun updateExerciseSchema(dayId: String, rotationId: String, exerciseId: String, field: String, value: JsonElement) {
|
||||
updateExercise(dayId, rotationId, exerciseId) { exercise ->
|
||||
val schema = (exercise["result_schema"] as? JsonObject ?: defaultSchema()).updated(field, value)
|
||||
exercise.updated("result_schema", schema)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateProgressionStep(progressionKey: String, stepId: String, transform: (JsonObject) -> JsonObject) {
|
||||
val stages = _state.value.config.obj("stages")
|
||||
val stage = stages[progressionKey] as? JsonObject ?: return
|
||||
val steps = stage.array("steps").map { element ->
|
||||
val step = element as? JsonObject ?: return@map element
|
||||
if (step.string("id") == stepId) transform(step) else step
|
||||
}
|
||||
val nextStages = stages.toMutableMap().apply { put(progressionKey, stage.updated("steps", JsonArray(steps))) }
|
||||
mutateConfig(_state.value.config.updated("stages", JsonObject(nextStages)))
|
||||
}
|
||||
|
||||
fun addProgressionStep(progressionKey: String) {
|
||||
val stages = _state.value.config.obj("stages")
|
||||
val stage = stages[progressionKey] as? JsonObject ?: return
|
||||
val steps = stage.array("steps").toMutableList()
|
||||
steps += buildJsonObject {
|
||||
put("id", stableId("step"))
|
||||
put("name", "Neue Stufe")
|
||||
put("phase_id", "")
|
||||
put("factor", 1.0)
|
||||
put("movement_cluster", "general")
|
||||
put("result_schema", defaultSchema())
|
||||
}
|
||||
val nextStages = stages.toMutableMap().apply { put(progressionKey, stage.updated("steps", JsonArray(steps))) }
|
||||
mutateConfig(_state.value.config.updated("stages", JsonObject(nextStages)))
|
||||
}
|
||||
|
||||
fun updateStepSchema(progressionKey: String, stepId: String, field: String, value: JsonElement) {
|
||||
updateProgressionStep(progressionKey, stepId) { step ->
|
||||
val schema = (step["result_schema"] as? JsonObject ?: defaultSchema()).updated(field, value)
|
||||
step.updated("result_schema", schema)
|
||||
}
|
||||
}
|
||||
|
||||
fun save() {
|
||||
val s = _state.value
|
||||
if (s.selectedPlanId.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
_state.value = s.copy(saving = true, error = "", conflict = null)
|
||||
runCatching { repository.savePlan(s.selectedPlanId, s.wrapper.int("revision", 1), s.config) }
|
||||
.onSuccess { body ->
|
||||
_state.value = _state.value.copy(
|
||||
saving = false,
|
||||
wrapper = body,
|
||||
config = body.obj("config"),
|
||||
validation = body.obj("validation"),
|
||||
dirty = false,
|
||||
message = "Entwurf gespeichert.",
|
||||
)
|
||||
loadPlans(force = true)
|
||||
}
|
||||
.onFailure(::handleFailure)
|
||||
}
|
||||
}
|
||||
|
||||
fun validate() {
|
||||
val s = _state.value
|
||||
if (s.selectedPlanId.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
_state.value = s.copy(saving = true, error = "")
|
||||
runCatching { repository.validatePlan(s.selectedPlanId, s.config) }
|
||||
.onSuccess { validation ->
|
||||
_state.value = _state.value.copy(
|
||||
saving = false,
|
||||
validation = validation,
|
||||
config = validation.obj("config").takeIf { it.isNotEmpty() } ?: s.config,
|
||||
message = if (validation.array("errors").isEmpty()) "Validierung erfolgreich." else "Validierung enthält Fehler.",
|
||||
)
|
||||
}
|
||||
.onFailure(::handleFailure)
|
||||
}
|
||||
}
|
||||
|
||||
fun publish() {
|
||||
val s = _state.value
|
||||
if (s.selectedPlanId.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
_state.value = s.copy(saving = true, error = "", conflict = null)
|
||||
runCatching { repository.publishPlan(s.selectedPlanId, s.wrapper.int("revision", 1)) }
|
||||
.onSuccess { body ->
|
||||
_state.value = _state.value.copy(
|
||||
saving = false,
|
||||
wrapper = body,
|
||||
config = body.obj("config"),
|
||||
validation = body.obj("validation"),
|
||||
dirty = false,
|
||||
message = "Entwurf veröffentlicht.",
|
||||
)
|
||||
loadPlans(force = true)
|
||||
}
|
||||
.onFailure(::handleFailure)
|
||||
}
|
||||
}
|
||||
|
||||
fun setProposalStatus(proposalId: String, status: String) {
|
||||
val planId = _state.value.selectedPlanId
|
||||
if (planId.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.setProposalStatus(planId, proposalId, status) }
|
||||
.onSuccess { body ->
|
||||
val proposals = _state.value.proposals.updated("proposals", body["proposals"] ?: JsonArray(emptyList()))
|
||||
_state.value = _state.value.copy(proposals = proposals, message = "Vorschlag als $status markiert.")
|
||||
}
|
||||
.onFailure { error -> _state.value = _state.value.copy(error = error.message ?: "Vorschlag konnte nicht aktualisiert werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateToProposal(targetExerciseId: String) {
|
||||
_state.value = _state.value.copy(proposalTargetId = targetExerciseId)
|
||||
}
|
||||
|
||||
fun clearProposalTarget() { _state.value = _state.value.copy(proposalTargetId = "") }
|
||||
fun dismissConflict() { _state.value = _state.value.copy(conflict = null) }
|
||||
fun reloadAfterConflict() { selectPlan(_state.value.selectedPlanId) }
|
||||
|
||||
private fun mutateConfig(config: JsonObject) {
|
||||
_state.value = _state.value.copy(config = config, dirty = true, message = "Ungespeicherte Änderungen", error = "")
|
||||
}
|
||||
|
||||
private fun handleFailure(error: Throwable) {
|
||||
_state.value = if (error is RevisionConflictException) {
|
||||
_state.value.copy(saving = false, conflict = error, error = "")
|
||||
} else {
|
||||
_state.value.copy(saving = false, error = error.message ?: "Aktion fehlgeschlagen.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun stableId(prefix: String): String = "$prefix-${UUID.randomUUID().toString().replace("-", "").take(12)}"
|
||||
|
||||
private fun defaultSchema(): JsonObject = buildJsonObject {
|
||||
put("mode", "reps")
|
||||
put("weight_mode", "none")
|
||||
put("laterality", "bilateral")
|
||||
put("sides_mode", "same")
|
||||
put("sets", 3)
|
||||
put("locked_sets", false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package de.drazz.boehmitools.training.ui.setup
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CloudDone
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.drazz.boehmitools.training.data.settings.SettingsRepository
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
contentPadding: PaddingValues,
|
||||
current: SettingsRepository.PublicSettings,
|
||||
onTheme: (String) -> Unit,
|
||||
viewModel: SettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(contentPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text("Einstellungen", style = MaterialTheme.typography.headlineMedium)
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Server & Anmeldung", style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
"Basic-Auth-Zugangsdaten werden mit einem Android-Keystore-Schlüssel verschlüsselt. Release-Builds akzeptieren ausschließlich HTTPS.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.serverUrl,
|
||||
onValueChange = viewModel::updateServer,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Server-URL") },
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.username,
|
||||
onValueChange = viewModel::updateUsername,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Benutzername") },
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.password,
|
||||
onValueChange = viewModel::updatePassword,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Passwort") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
)
|
||||
Button(onClick = viewModel::saveAndTest, enabled = !state.loading) {
|
||||
if (state.loading) CircularProgressIndicator(modifier = Modifier.height(18.dp), strokeWidth = 2.dp)
|
||||
else Icon(Icons.Default.CloudDone, null)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text("Speichern & Verbindung testen")
|
||||
}
|
||||
if (state.message.isNotBlank()) Text(state.message, color = MaterialTheme.colorScheme.primary)
|
||||
if (state.error.isNotBlank()) Text(state.error, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Darstellung", style = MaterialTheme.typography.titleLarge)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
listOf("system" to "System", "light" to "Hell", "dark" to "Dunkel").forEach { (value, label) ->
|
||||
FilterChip(
|
||||
selected = current.theme == value,
|
||||
onClick = { onTheme(value) },
|
||||
label = { Text(label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package de.drazz.boehmitools.training.ui.setup
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.drazz.boehmitools.training.data.repository.TrainingRepository
|
||||
import de.drazz.boehmitools.training.data.settings.SettingsRepository
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class SettingsUiState(
|
||||
val serverUrl: String = SettingsRepository.DEFAULT_SERVER,
|
||||
val username: String = "admin",
|
||||
val password: String = "",
|
||||
val loading: Boolean = false,
|
||||
val message: String = "",
|
||||
val error: String = "",
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val settings: SettingsRepository,
|
||||
private val repository: TrainingRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(SettingsUiState())
|
||||
val state: StateFlow<SettingsUiState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val snapshot = settings.snapshot()
|
||||
_state.value = _state.value.copy(
|
||||
serverUrl = snapshot.serverUrl,
|
||||
username = snapshot.username,
|
||||
password = snapshot.password,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateServer(value: String) { _state.value = _state.value.copy(serverUrl = value, message = "", error = "") }
|
||||
fun updateUsername(value: String) { _state.value = _state.value.copy(username = value, message = "", error = "") }
|
||||
fun updatePassword(value: String) { _state.value = _state.value.copy(password = value, message = "", error = "") }
|
||||
|
||||
fun saveAndTest() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, message = "", error = "")
|
||||
runCatching {
|
||||
settings.saveConnection(_state.value.serverUrl, _state.value.username, _state.value.password)
|
||||
repository.testConnection()
|
||||
}.onSuccess { health ->
|
||||
val versions = health["versions"]?.toString().orEmpty()
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
message = if (versions.isBlank()) "Verbindung erfolgreich." else "Verbindung erfolgreich · Vertrag erkannt.",
|
||||
)
|
||||
}.onFailure { error ->
|
||||
_state.value = _state.value.copy(loading = false, error = error.message ?: "Verbindung fehlgeschlagen.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package de.drazz.boehmitools.training.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val Light = lightColorScheme(
|
||||
primary = Color(0xFF566600),
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFD9F26A),
|
||||
onPrimaryContainer = Color(0xFF172000),
|
||||
secondary = Color(0xFF53643C),
|
||||
tertiary = Color(0xFF00696C),
|
||||
background = Color(0xFFF9FAF2),
|
||||
surface = Color(0xFFF9FAF2),
|
||||
surfaceVariant = Color(0xFFE5E8D8),
|
||||
)
|
||||
|
||||
private val Dark = darkColorScheme(
|
||||
primary = Color(0xFFC0D852),
|
||||
onPrimary = Color(0xFF2B3400),
|
||||
primaryContainer = Color(0xFF414D00),
|
||||
onPrimaryContainer = Color(0xFFD9F26A),
|
||||
secondary = Color(0xFFBACCA2),
|
||||
tertiary = Color(0xFF80D4D6),
|
||||
background = Color(0xFF11140E),
|
||||
surface = Color(0xFF11140E),
|
||||
surfaceVariant = Color(0xFF41443A),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun TrainingTheme(theme: String, content: @Composable () -> Unit) {
|
||||
val dark = when (theme) {
|
||||
"dark" -> true
|
||||
"light" -> false
|
||||
else -> isSystemInDarkTheme()
|
||||
}
|
||||
MaterialTheme(colorScheme = if (dark) Dark else Light, content = content)
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
package de.drazz.boehmitools.training.ui.tracker
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.weight
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.CloudOff
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.drazz.boehmitools.training.data.local.PendingMutation
|
||||
import de.drazz.boehmitools.training.domain.DayUi
|
||||
import de.drazz.boehmitools.training.domain.ExerciseUi
|
||||
import de.drazz.boehmitools.training.domain.PlanUi
|
||||
import de.drazz.boehmitools.training.domain.ResultSchemaUi
|
||||
import de.drazz.boehmitools.training.domain.array
|
||||
import de.drazz.boehmitools.training.domain.bool
|
||||
import de.drazz.boehmitools.training.domain.emptyResultData
|
||||
import de.drazz.boehmitools.training.domain.formatResultData
|
||||
import de.drazz.boehmitools.training.domain.int
|
||||
import de.drazz.boehmitools.training.domain.itemFor
|
||||
import de.drazz.boehmitools.training.domain.normalizedResultData
|
||||
import de.drazz.boehmitools.training.domain.obj
|
||||
import de.drazz.boehmitools.training.domain.resolveResultSchema
|
||||
import de.drazz.boehmitools.training.domain.resultDataWithSets
|
||||
import de.drazz.boehmitools.training.domain.resultDataWithValue
|
||||
import de.drazz.boehmitools.training.domain.string
|
||||
import de.drazz.boehmitools.training.domain.trackerSession
|
||||
import de.drazz.boehmitools.training.domain.updated
|
||||
import de.drazz.boehmitools.training.domain.updatedString
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TrackerScreen(
|
||||
contentPadding: PaddingValues,
|
||||
viewModel: TrackerViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val pending by viewModel.pending.collectAsStateWithLifecycle()
|
||||
val currentPending = pending.filter { it.planId == state.selectedPlanId }
|
||||
var planMenu by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.padding(contentPadding),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(state.plan?.title ?: "boehmitools Training", maxLines = 2)
|
||||
if (state.saveState.isNotBlank()) Text(state.saveState, style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
Box {
|
||||
IconButton(onClick = { planMenu = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = "Plan wechseln")
|
||||
}
|
||||
DropdownMenu(expanded = planMenu, onDismissRequest = { planMenu = false }) {
|
||||
state.plans.forEach { plan ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(plan.title) },
|
||||
onClick = { planMenu = false; viewModel.selectPlan(plan.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { inner ->
|
||||
Column(Modifier.fillMaxSize().padding(inner)) {
|
||||
val tabs = listOf("session" to "Session", "progress" to "Fortschritt", "plan" to "Plan", "faq" to "FAQ")
|
||||
ScrollableTabRow(selectedTabIndex = tabs.indexOfFirst { it.first == state.tab }.coerceAtLeast(0)) {
|
||||
tabs.forEach { (id, label) ->
|
||||
Tab(selected = state.tab == id, onClick = { viewModel.setTab(id) }, text = { Text(label) })
|
||||
}
|
||||
}
|
||||
when {
|
||||
state.loading && state.plan == null -> Box(Modifier.fillMaxSize().padding(32.dp)) { CircularProgressIndicator() }
|
||||
state.error.isNotBlank() && state.plan == null -> ErrorPane(state.error) { viewModel.loadPlans(force = true) }
|
||||
state.plan == null -> ErrorPane("Kein Trainingsplan verfügbar.") { viewModel.loadPlans(force = true) }
|
||||
else -> when (state.tab) {
|
||||
"progress" -> ProgressPane(state, currentPending, viewModel)
|
||||
"plan" -> PlanPane(state.plan!!)
|
||||
"faq" -> FaqPane(state)
|
||||
else -> SessionPane(state, currentPending, viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.conflict?.let { conflict ->
|
||||
AlertDialog(
|
||||
onDismissRequest = viewModel::dismissConflict,
|
||||
title = { Text("Revisionskonflikt") },
|
||||
text = { Text("Der Server steht bereits auf Revision ${conflict.currentRevision}. Deine lokale Änderung wird nicht automatisch überschrieben. Lade den Serverstand neu und wende die Änderung bewusst erneut an.") },
|
||||
confirmButton = { TextButton(onClick = viewModel::reloadAfterConflict) { Text("Serverstand neu laden") } },
|
||||
dismissButton = { TextButton(onClick = viewModel::dismissConflict) { Text("Lokale Ansicht behalten") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorPane(message: String, retry: () -> Unit) {
|
||||
Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(message, color = MaterialTheme.colorScheme.error)
|
||||
Button(onClick = retry) { Icon(Icons.Default.Refresh, null); Spacer(Modifier.width(8.dp)); Text("Neu laden") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionPane(state: TrackerUiState, pending: List<PendingMutation>, vm: TrackerViewModel) {
|
||||
val plan = state.plan ?: return
|
||||
val day = plan.days.firstOrNull { it.num == state.day } ?: return
|
||||
val session = trackerSession(state.tracker, de.drazz.boehmitools.training.domain.sessionKey(state.week, state.day), plan)
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (state.error.isNotBlank()) Text(state.error, color = MaterialTheme.colorScheme.error)
|
||||
if (pending.isNotEmpty()) PendingQueue(pending, vm)
|
||||
WeekDayPicker(plan, state.week, state.day, vm)
|
||||
SessionHeader(plan, day, session, state.week, vm)
|
||||
|
||||
SequenceCard("Aufwärmen", "warmup", day.warmup, session, vm)
|
||||
day.rotations.forEach { rotation ->
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(rotation.label, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
rotation.exercises.forEach { exercise ->
|
||||
ExerciseCard(exercise, session, plan, vm)
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SequenceCard("Cooldown", "cooldown", day.cooldown, session, vm)
|
||||
SequenceCard("Stretch", "stretch", day.stretch, session, vm)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PendingQueue(pending: List<PendingMutation>, vm: TrackerViewModel) {
|
||||
OutlinedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Default.CloudOff, null)
|
||||
Text("${pending.size} ausstehende Änderung(en)", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
pending.take(3).forEach { item ->
|
||||
Text("${item.sessionKey} · Revision ${item.expectedRevision} · ${item.state}", style = MaterialTheme.typography.bodySmall)
|
||||
if (item.state == PendingMutation.STATE_CONFLICT) {
|
||||
Text(item.lastError, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
TextButton(onClick = { vm.discardQueuedConflict(item.id) }) { Text("Konflikt-Patch verwerfen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekDayPicker(plan: PlanUi, week: Int, day: Int, vm: TrackerViewModel) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
(1..plan.weeks).forEach { value -> FilterChip(selected = value == week, onClick = { vm.setWeek(value) }, label = { Text("W$value") }) }
|
||||
}
|
||||
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
plan.days.forEach { value -> FilterChip(selected = value.num == day, onClick = { vm.setDay(value.num) }, label = { Text("Tag ${value.num}") }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionHeader(plan: PlanUi, day: DayUi, session: JsonObject, week: Int, vm: TrackerViewModel) {
|
||||
val status = session.string("status", "planned")
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Woche $week · Tag ${day.num}", style = MaterialTheme.typography.labelLarge)
|
||||
Text(day.focus, style = MaterialTheme.typography.headlineSmall)
|
||||
Text("Planrevision R${session.int("plan_revision", plan.publishedRevision)} · Status: $status", style = MaterialTheme.typography.bodySmall)
|
||||
when (status) {
|
||||
"in_progress" -> Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { vm.setSessionStatus("completed") }) { Icon(Icons.Default.CheckCircle, null); Spacer(Modifier.width(6.dp)); Text("Abschließen") }
|
||||
OutlinedButton(onClick = { vm.setSessionStatus("stopped") }) { Icon(Icons.Default.Stop, null); Spacer(Modifier.width(6.dp)); Text("Stoppen") }
|
||||
}
|
||||
"stopped" -> Button(onClick = { vm.setSessionStatus("in_progress") }) { Icon(Icons.Default.PlayArrow, null); Spacer(Modifier.width(6.dp)); Text("Fortsetzen") }
|
||||
"completed" -> OutlinedButton(onClick = { vm.setSessionStatus("in_progress") }) { Text("Wieder öffnen") }
|
||||
else -> Button(onClick = { vm.setSessionStatus("in_progress") }) { Icon(Icons.Default.PlayArrow, null); Spacer(Modifier.width(6.dp)); Text("Session starten") }
|
||||
}
|
||||
TextButton(onClick = vm::resetSession) { Text("Session zurücksetzen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SequenceCard(title: String, prefix: String, items: List<String>, session: JsonObject, vm: TrackerViewModel) {
|
||||
if (items.isEmpty()) return
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
items.forEachIndexed { index, label ->
|
||||
val id = "$prefix-$index"
|
||||
val item = itemFor(session, id)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Checkbox(checked = item.string("completion_status") == "completed" || item.bool("done"), onCheckedChange = { vm.setSequenceStatus(id, it) })
|
||||
Text(label, modifier = Modifier.padding(top = 12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExerciseCard(exercise: ExerciseUi, session: JsonObject, plan: PlanUi, vm: TrackerViewModel) {
|
||||
val item = itemFor(session, exercise.id)
|
||||
val status = item.string("completion_status", if (item.bool("done")) "completed" else "planned")
|
||||
val progression = item.string("progression")
|
||||
val progressionModel = plan.progressions[exercise.progressionId]
|
||||
val schema = resolveResultSchema(exercise, item, progression, plan)
|
||||
val data = normalizedResultData(item, schema)
|
||||
var progressionMenu by remember(exercise.id, progression) { mutableStateOf(false) }
|
||||
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(exercise.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
if (exercise.cue.isNotBlank()) Text(exercise.cue, style = MaterialTheme.typography.bodySmall)
|
||||
Text("ID ${exercise.id}", style = MaterialTheme.typography.labelSmall)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Status", style = MaterialTheme.typography.labelMedium)
|
||||
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
listOf("planned" to "Offen", "completed" to "Erledigt", "partial" to "Teilweise", "skipped" to "Übersprungen").forEach { (value, label) ->
|
||||
FilterChip(selected = status == value, onClick = { vm.setItemStatus(exercise.id, value) }, label = { Text(label) })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (status == "skipped") {
|
||||
OutlinedTextField(
|
||||
value = item.string("skip_reason"),
|
||||
onValueChange = { vm.setItemText(exercise.id, "skip_reason", it) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Grund") },
|
||||
)
|
||||
} else {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
OutlinedTextField(
|
||||
value = progression,
|
||||
onValueChange = { vm.setItemProgression(exercise.id, it) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Progressionsstufe") },
|
||||
supportingText = { Text("Stufe überschreibt bei Bedarf das Ergebnisschema") },
|
||||
)
|
||||
if (progressionModel?.steps?.isNotEmpty() == true) {
|
||||
Box {
|
||||
OutlinedButton(onClick = { progressionMenu = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Stufe aus Plan auswählen")
|
||||
}
|
||||
DropdownMenu(expanded = progressionMenu, onDismissRequest = { progressionMenu = false }) {
|
||||
progressionModel.steps.forEach { step ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(step.name) },
|
||||
onClick = { progressionMenu = false; vm.setItemProgression(exercise.id, step.name, step.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ManualResultFormatEditor(
|
||||
inherited = schema,
|
||||
manualOverride = item["result_format"] as? JsonObject,
|
||||
onChange = { vm.setItemResultFormat(exercise.id, it) },
|
||||
)
|
||||
ResultEditor(schema, data) { vm.setItemResultData(exercise.id, it) }
|
||||
OutlinedTextField(
|
||||
value = item.string("note"),
|
||||
onValueChange = { vm.setItemText(exercise.id, "note", it) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Sessionnotiz") },
|
||||
minLines = 2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun ManualResultFormatEditor(
|
||||
inherited: ResultSchemaUi,
|
||||
manualOverride: JsonObject?,
|
||||
onChange: (JsonObject?) -> Unit,
|
||||
) {
|
||||
var expanded by remember(manualOverride) { mutableStateOf(false) }
|
||||
val mode = manualOverride?.string("mode", inherited.mode) ?: inherited.mode
|
||||
val weightMode = manualOverride?.string("weight_mode", inherited.weightMode) ?: inherited.weightMode
|
||||
val laterality = manualOverride?.string("laterality", inherited.laterality) ?: inherited.laterality
|
||||
val sidesMode = if (laterality == "unilateral") {
|
||||
manualOverride?.string("sides_mode", inherited.sidesMode) ?: inherited.sidesMode
|
||||
} else {
|
||||
"same"
|
||||
}
|
||||
|
||||
fun changed(field: String, value: String): JsonObject {
|
||||
val values = linkedMapOf<String, kotlinx.serialization.json.JsonElement>(
|
||||
"mode" to JsonPrimitive(mode),
|
||||
"weight_mode" to JsonPrimitive(weightMode),
|
||||
"laterality" to JsonPrimitive(laterality),
|
||||
"sides_mode" to JsonPrimitive(sidesMode),
|
||||
)
|
||||
values[field] = JsonPrimitive(value)
|
||||
if ((values["laterality"] as JsonPrimitive).content == "bilateral") {
|
||||
values["sides_mode"] = JsonPrimitive("same")
|
||||
}
|
||||
return JsonObject(values)
|
||||
}
|
||||
|
||||
OutlinedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Manuelle Session-Anpassung", style = MaterialTheme.typography.labelLarge)
|
||||
Text(
|
||||
if (manualOverride == null) "Vererbt: ${inherited.source}" else "Überschreibt Progressionsstufe und Übung",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
TextButton(onClick = { expanded = !expanded }) { Text(if (expanded) "Schließen" else "Anpassen") }
|
||||
}
|
||||
if (expanded) {
|
||||
ChoiceChips("Messwert", listOf("reps", "seconds", "minutes", "none"), mode) { onChange(changed("mode", it)) }
|
||||
ChoiceChips("Gewicht", listOf("none", "optional", "required"), weightMode) { onChange(changed("weight_mode", it)) }
|
||||
ChoiceChips("Ausführung", listOf("bilateral", "unilateral"), laterality) { onChange(changed("laterality", it)) }
|
||||
if (laterality == "unilateral") {
|
||||
ChoiceChips("Seitenwerte", listOf("same", "separate"), sidesMode) { onChange(changed("sides_mode", it)) }
|
||||
}
|
||||
if (manualOverride != null) {
|
||||
OutlinedButton(onClick = { onChange(null) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Manuelle Anpassung zurücksetzen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChoiceChips(label: String, choices: List<String>, selected: String, onSelect: (String) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.labelMedium)
|
||||
choices.forEach { value ->
|
||||
FilterChip(
|
||||
selected = selected == value,
|
||||
onClick = { onSelect(value) },
|
||||
label = { Text(value) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultEditor(schema: ResultSchemaUi, data: JsonObject, onChange: (JsonObject) -> Unit) {
|
||||
OutlinedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Ergebnis · ${schema.source}", style = MaterialTheme.typography.labelLarge)
|
||||
Text("${schema.mode}, ${schema.laterality}, Gewicht ${schema.weightMode}", style = MaterialTheme.typography.bodySmall)
|
||||
if (schema.mode == "none") {
|
||||
Text("Für diese Stufe ist kein numerischer Messwert vorgesehen.")
|
||||
return@Column
|
||||
}
|
||||
if (schema.weightMode != "none") {
|
||||
val weight = data["weight_kg"]?.jsonPrimitive?.doubleOrNull?.toString().orEmpty()
|
||||
OutlinedTextField(
|
||||
value = weight,
|
||||
onValueChange = { raw -> onChange(data.updated("weight_kg", raw.replace(',', '.').toDoubleOrNull()?.let(::JsonPrimitive) ?: JsonNull)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text(if (schema.weightMode == "required") "Gewicht kg (Pflicht)" else "Gewicht kg (optional)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
if (!schema.lockedSets) {
|
||||
OutlinedTextField(
|
||||
value = data.int("sets", schema.defaultSets).toString(),
|
||||
onValueChange = { raw -> raw.toIntOrNull()?.let { onChange(resultDataWithSets(data, it)) } },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Sätze") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
val count = data.int("sets", schema.defaultSets).coerceIn(1, 20)
|
||||
repeat(count) { index ->
|
||||
if (data.string("laterality", schema.laterality) == "unilateral" && data.string("sides_mode", schema.sidesMode) == "separate") {
|
||||
OutlinedTextField(
|
||||
value = data.array("left_values").getOrNull(index)?.jsonPrimitive?.doubleOrNull?.toString().orEmpty(),
|
||||
onValueChange = { raw -> onChange(resultDataWithValue(data, "left_values", index, raw.replace(',', '.').toDoubleOrNull())) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Satz ${index + 1} links") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = data.array("right_values").getOrNull(index)?.jsonPrimitive?.doubleOrNull?.toString().orEmpty(),
|
||||
onValueChange = { raw -> onChange(resultDataWithValue(data, "right_values", index, raw.replace(',', '.').toDoubleOrNull())) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Satz ${index + 1} rechts") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
singleLine = true,
|
||||
)
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
value = data.array("values").getOrNull(index)?.jsonPrimitive?.doubleOrNull?.toString().orEmpty(),
|
||||
onValueChange = { raw -> onChange(resultDataWithValue(data, "values", index, raw.replace(',', '.').toDoubleOrNull())) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Satz ${index + 1}${if (schema.laterality == "unilateral") " je Seite" else ""}") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text("Gespeichert als: ${formatResultData(data)}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProgressPane(state: TrackerUiState, pending: List<PendingMutation>, vm: TrackerViewModel) {
|
||||
val plan = state.plan ?: return
|
||||
val sessions = state.tracker.obj("sessions").values.mapNotNull { it as? JsonObject }
|
||||
val completed = sessions.count { it.string("status") == "completed" }
|
||||
val stopped = sessions.count { it.string("status") == "stopped" }
|
||||
val analysisState = state.response.obj("analysis_state").takeIf { it.isNotEmpty() }
|
||||
?: state.tracker.obj("analysis_state")
|
||||
val running = analysisState.string("status") == "running"
|
||||
val cache = state.response.obj("analysis_cache").takeIf { it.isNotEmpty() }
|
||||
?: state.tracker.obj("analysis_cache")
|
||||
val weekRecord = cache.obj("weeks")[state.week.toString()] as? JsonObject
|
||||
val overall = cache["overall"] as? JsonObject
|
||||
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Fortschritt", style = MaterialTheme.typography.headlineSmall)
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Lokale Übersicht", style = MaterialTheme.typography.titleMedium)
|
||||
Text("$completed abgeschlossene Sessions · $stopped gestoppte Sessions · ${sessions.size} angelegte Sessions")
|
||||
Text("Trackerrevision ${state.tracker.int("revision", 1)} · Planrevision ${plan.publishedRevision}")
|
||||
}
|
||||
}
|
||||
if (pending.isNotEmpty()) PendingQueue(pending, vm)
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Wochenstatus", style = MaterialTheme.typography.titleMedium)
|
||||
val closed = state.tracker.obj("week_statuses").obj(state.week.toString()).string("status") == "closed"
|
||||
FilterChip(selected = closed, onClick = { vm.setWeekClosed(!closed) }, label = { Text(if (closed) "Woche ${state.week} abgeschlossen" else "Woche ${state.week} läuft") })
|
||||
Text("Eine laufende Woche erzeugt eine Zwischenanalyse, eine geschlossene Woche eine Abschlussanalyse.", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Manuelle Analyse", style = MaterialTheme.typography.titleMedium)
|
||||
Button(onClick = { vm.startAnalysis("week", state.week) }, enabled = !running) { Text("Woche ${state.week} analysieren") }
|
||||
OutlinedButton(onClick = { vm.startAnalysis("overall") }, enabled = !running) { Text("Gesamtanalyse") }
|
||||
if (running) {
|
||||
CircularProgressIndicator()
|
||||
Text(analysisState.string("message", "Analysejob läuft …"))
|
||||
} else if (state.analysisMessage.isNotBlank()) Text(state.analysisMessage)
|
||||
if (state.error.isNotBlank()) Text(state.error, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
AnalysisCard("Analyse Woche ${state.week}", weekRecord)
|
||||
AnalysisCard("Gesamtanalyse", overall)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AnalysisCard(title: String, record: JsonObject?) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
if (record == null) {
|
||||
Text("Noch keine aktuelle Analyse vorhanden.")
|
||||
} else {
|
||||
val response = record.obj("response")
|
||||
Text(response.string("headline", "Analyse"), style = MaterialTheme.typography.titleLarge)
|
||||
Text(response.string("summary"))
|
||||
response.array("metrics").mapNotNull { it as? JsonObject }.forEach { metric ->
|
||||
AssistChip(onClick = {}, label = { Text("${metric.string("label")}: ${metric.string("value")}") })
|
||||
}
|
||||
response.array("exercise_updates").mapNotNull { it as? JsonObject }.take(6).forEach { update ->
|
||||
OutlinedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Text(update.string("name"), fontWeight = FontWeight.Bold)
|
||||
Text(update.string("next_action"))
|
||||
if (update.string("criterion").isNotBlank()) Text("Kriterium: ${update.string("criterion")}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlanPane(plan: PlanUi) {
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(plan.title, style = MaterialTheme.typography.headlineSmall)
|
||||
Text(plan.subtitle)
|
||||
Text("${plan.weeks} Wochen · veröffentlichte Revision ${plan.publishedRevision}")
|
||||
plan.days.forEach { day ->
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Tag ${day.num}: ${day.focus}", style = MaterialTheme.typography.titleMedium)
|
||||
day.rotations.forEach { rotation ->
|
||||
Text(rotation.label, fontWeight = FontWeight.Bold)
|
||||
rotation.exercises.forEach { exercise -> Text("• ${exercise.name}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FaqPane(state: TrackerUiState) {
|
||||
val plan = state.plan ?: return
|
||||
val guide = state.response.obj("equivalence_guide")
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("FAQ & Variantencluster", style = MaterialTheme.typography.headlineSmall)
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Trainingsformat", style = MaterialTheme.typography.titleMedium)
|
||||
Text("Modus: ${plan.trainingFormat.string("mode", "sets_reps")}")
|
||||
if (plan.trainingFormat.bool("fixed_interval")) {
|
||||
Text("Festes Intervall: ${plan.trainingFormat.int("work_seconds")} s Arbeit / ${plan.trainingFormat.int("rest_seconds")} s Pause · ${plan.trainingFormat.int("rounds")} Intervalle pro Block")
|
||||
Text("Die App implementiert bewusst keinen Timer und verändert dieses Format nicht.")
|
||||
}
|
||||
}
|
||||
}
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Planbezogene Übungsbibliothek", style = MaterialTheme.typography.titleMedium)
|
||||
plan.exerciseCatalog.values.mapNotNull { it as? JsonObject }.take(100).forEach { entry ->
|
||||
Text("${entry.string("movement_label", entry.string("name"))} · ${entry.array("variants").size} Variante(n)")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (guide.isNotEmpty()) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Fallbackregeln des Servers", style = MaterialTheme.typography.titleMedium)
|
||||
Text(guide.toString(), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+433
@@ -0,0 +1,433 @@
|
||||
package de.drazz.boehmitools.training.ui.tracker
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.drazz.boehmitools.training.data.local.PendingMutation
|
||||
import de.drazz.boehmitools.training.data.repository.ApiException
|
||||
import de.drazz.boehmitools.training.data.repository.RevisionConflictException
|
||||
import de.drazz.boehmitools.training.data.repository.SaveOutcome
|
||||
import de.drazz.boehmitools.training.data.repository.TrainingRepository
|
||||
import de.drazz.boehmitools.training.data.settings.SettingsRepository
|
||||
import de.drazz.boehmitools.training.domain.DayUi
|
||||
import de.drazz.boehmitools.training.domain.adaptResultDataToSchema
|
||||
import de.drazz.boehmitools.training.domain.PlanUi
|
||||
import de.drazz.boehmitools.training.domain.blankSession
|
||||
import de.drazz.boehmitools.training.domain.formatResultData
|
||||
import de.drazz.boehmitools.training.domain.int
|
||||
import de.drazz.boehmitools.training.domain.migrateLegacyItems
|
||||
import de.drazz.boehmitools.training.domain.mergeResultSchemaLayers
|
||||
import de.drazz.boehmitools.training.domain.obj
|
||||
import de.drazz.boehmitools.training.domain.overlaySessionPatches
|
||||
import de.drazz.boehmitools.training.domain.parsePlan
|
||||
import de.drazz.boehmitools.training.domain.resolveResultSchema
|
||||
import de.drazz.boehmitools.training.domain.sessionKey
|
||||
import de.drazz.boehmitools.training.domain.sessionPatch
|
||||
import de.drazz.boehmitools.training.domain.string
|
||||
import de.drazz.boehmitools.training.domain.trackerSession
|
||||
import de.drazz.boehmitools.training.domain.updateItem
|
||||
import de.drazz.boehmitools.training.domain.updateTrackerSession
|
||||
import de.drazz.boehmitools.training.domain.updated
|
||||
import de.drazz.boehmitools.training.domain.updatedBool
|
||||
import de.drazz.boehmitools.training.domain.updatedInt
|
||||
import de.drazz.boehmitools.training.domain.updatedString
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
data class TrackerPlanSummary(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val weeks: Int,
|
||||
val completedSessions: Int,
|
||||
val sourceChanged: Boolean,
|
||||
)
|
||||
|
||||
data class TrackerUiState(
|
||||
val loading: Boolean = true,
|
||||
val error: String = "",
|
||||
val plans: List<TrackerPlanSummary> = emptyList(),
|
||||
val selectedPlanId: String = "",
|
||||
val response: JsonObject = JsonObject(emptyMap()),
|
||||
val plan: PlanUi? = null,
|
||||
val tracker: JsonObject = JsonObject(emptyMap()),
|
||||
val tab: String = "session",
|
||||
val week: Int = 1,
|
||||
val day: Int = 1,
|
||||
val saveState: String = "",
|
||||
val conflict: RevisionConflictException? = null,
|
||||
val analysisMessage: String = "",
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class TrackerViewModel @Inject constructor(
|
||||
private val repository: TrainingRepository,
|
||||
private val settings: SettingsRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(TrackerUiState())
|
||||
val state: StateFlow<TrackerUiState> = _state.asStateFlow()
|
||||
val pending = repository.pendingMutations().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
private var saveJob: Job? = null
|
||||
private var pollJob: Job? = null
|
||||
private val saveMutex = Mutex()
|
||||
private val wireJson = Json { ignoreUnknownKeys = true; explicitNulls = false }
|
||||
|
||||
init { loadPlans() }
|
||||
|
||||
fun loadPlans(force: Boolean = false) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = "")
|
||||
runCatching { repository.trackerPlans(force) }
|
||||
.onSuccess { payload ->
|
||||
val plans = payload["plans"]?.jsonArray?.mapNotNull { it as? JsonObject }?.map { item ->
|
||||
TrackerPlanSummary(
|
||||
id = item.string("id"),
|
||||
title = item.string("title", item.string("name")),
|
||||
subtitle = item.string("subtitle"),
|
||||
weeks = item.int("weeks", 1),
|
||||
completedSessions = item.int("completed_sessions"),
|
||||
sourceChanged = item["source_changed"]?.jsonPrimitive?.content == "true",
|
||||
)
|
||||
}.orEmpty()
|
||||
val preferred = settings.selectedTrackerPlan()
|
||||
val selected = preferred?.takeIf { id -> plans.any { it.id == id } }
|
||||
?: payload.string("selected").takeIf { id -> plans.any { it.id == id } }
|
||||
?: plans.firstOrNull()?.id.orEmpty()
|
||||
_state.value = _state.value.copy(plans = plans, loading = false)
|
||||
if (selected.isNotBlank()) selectPlan(selected, persistServer = false)
|
||||
}
|
||||
.onFailure { error -> _state.value = _state.value.copy(loading = false, error = error.message ?: "Planliste konnte nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun selectPlan(planId: String, persistServer: Boolean = true) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = "", conflict = null)
|
||||
runCatching {
|
||||
if (persistServer) repository.selectTrackerPlan(planId)
|
||||
settings.setSelectedTrackerPlan(planId)
|
||||
val ui = settings.trackerUi(planId)
|
||||
Triple(repository.trackerPlan(planId, force = true), ui, repository.pendingForPlan(planId))
|
||||
}.onSuccess { (response, ui, queued) ->
|
||||
val plan = parsePlan(response.obj("plan"))
|
||||
var tracker = overlayPendingPatches(response.obj("tracker"), queued)
|
||||
val week = ui.week.coerceIn(1, plan.weeks)
|
||||
val day = ui.day.takeIf { candidate -> plan.days.any { it.num == candidate } } ?: plan.days.firstOrNull()?.num ?: 1
|
||||
val currentDay = plan.days.firstOrNull { it.num == day }
|
||||
if (currentDay != null) {
|
||||
val key = sessionKey(week, day)
|
||||
val (migrated, changed) = migrateLegacyItems(trackerSession(tracker, key, plan), currentDay)
|
||||
if (changed) tracker = updateTrackerSession(tracker, key, migrated)
|
||||
}
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
selectedPlanId = planId,
|
||||
response = response,
|
||||
plan = plan,
|
||||
tracker = tracker,
|
||||
tab = ui.tab,
|
||||
week = week,
|
||||
day = day,
|
||||
saveState = "",
|
||||
)
|
||||
if (response.obj("tracker").obj("analysis_state").string("status") == "running") startPolling()
|
||||
}.onFailure { error -> _state.value = _state.value.copy(loading = false, error = error.message ?: "Plan konnte nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun setTab(tab: String) {
|
||||
_state.value = _state.value.copy(tab = tab)
|
||||
persistUi()
|
||||
}
|
||||
|
||||
fun setWeek(week: Int) {
|
||||
val plan = _state.value.plan ?: return
|
||||
_state.value = _state.value.copy(week = week.coerceIn(1, plan.weeks))
|
||||
migrateCurrentLegacy()
|
||||
persistUi()
|
||||
}
|
||||
|
||||
fun setDay(day: Int) {
|
||||
val plan = _state.value.plan ?: return
|
||||
if (plan.days.none { it.num == day }) return
|
||||
_state.value = _state.value.copy(day = day)
|
||||
migrateCurrentLegacy()
|
||||
persistUi()
|
||||
}
|
||||
|
||||
private fun persistUi() {
|
||||
val s = _state.value
|
||||
if (s.selectedPlanId.isBlank()) return
|
||||
viewModelScope.launch { settings.saveTrackerUi(s.selectedPlanId, s.tab, s.week, s.day) }
|
||||
}
|
||||
|
||||
fun currentDay(): DayUi? = _state.value.plan?.days?.firstOrNull { it.num == _state.value.day }
|
||||
fun currentSession(): JsonObject? {
|
||||
val s = _state.value
|
||||
val plan = s.plan ?: return null
|
||||
return trackerSession(s.tracker, sessionKey(s.week, s.day), plan)
|
||||
}
|
||||
|
||||
fun setSessionStatus(status: String) {
|
||||
mutateSession { session ->
|
||||
var next = session.updatedString("status", status)
|
||||
val now = Instant.now().toString()
|
||||
when (status) {
|
||||
"in_progress" -> if (session.string("started_at").isBlank()) next = next.updatedString("started_at", now)
|
||||
"stopped" -> next = next.updatedString("stopped_at", now)
|
||||
"completed" -> next = next.updatedString("completed_at", now)
|
||||
}
|
||||
next
|
||||
}
|
||||
}
|
||||
|
||||
fun resetSession() {
|
||||
val plan = _state.value.plan ?: return
|
||||
val key = sessionKey(_state.value.week, _state.value.day)
|
||||
val tracker = updateTrackerSession(_state.value.tracker, key, blankSession(plan))
|
||||
_state.value = _state.value.copy(tracker = tracker)
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
fun setSequenceStatus(id: String, done: Boolean) {
|
||||
mutateSession { session ->
|
||||
val item = de.drazz.boehmitools.training.domain.itemFor(session, id)
|
||||
.updatedString("completion_status", if (done) "completed" else "planned")
|
||||
.updatedBool("done", done)
|
||||
updateItem(session, id, item)
|
||||
}
|
||||
}
|
||||
|
||||
fun setItemStatus(id: String, status: String) {
|
||||
mutateSession { session ->
|
||||
var item = de.drazz.boehmitools.training.domain.itemFor(session, id)
|
||||
.updatedString("completion_status", status)
|
||||
.updatedBool("done", status == "completed" || status == "partial")
|
||||
if (status != "skipped") item = item.updated("skip_reason", null)
|
||||
updateItem(session, id, item)
|
||||
}
|
||||
}
|
||||
|
||||
fun setItemProgression(id: String, value: String, stepId: String = "") {
|
||||
val plan = _state.value.plan ?: return
|
||||
val exercise = currentDay()?.rotations?.flatMap { it.exercises }?.firstOrNull { it.id == id }
|
||||
mutateSession { session ->
|
||||
var item = de.drazz.boehmitools.training.domain.itemFor(session, id).updatedString("progression", value)
|
||||
item = if (stepId.isBlank()) item.updated("progression_step_id", null) else item.updatedString("progression_step_id", stepId)
|
||||
val current = item["result_data"] as? JsonObject
|
||||
if (exercise != null && current != null) {
|
||||
val schema = resolveResultSchema(exercise, item, value, plan)
|
||||
val adapted = adaptResultDataToSchema(current, schema)
|
||||
item = if (adapted == null) {
|
||||
item.updated("result_data", null).updatedString("result", "")
|
||||
} else {
|
||||
item.updated("result_data", adapted).updatedString("result", formatResultData(adapted))
|
||||
}
|
||||
}
|
||||
updateItem(session, id, item)
|
||||
}
|
||||
}
|
||||
|
||||
fun setItemText(id: String, field: String, value: String) {
|
||||
mutateSession { session ->
|
||||
val item = de.drazz.boehmitools.training.domain.itemFor(session, id).updatedString(field, value)
|
||||
updateItem(session, id, item)
|
||||
}
|
||||
}
|
||||
|
||||
fun setItemResultData(id: String, data: JsonObject) {
|
||||
mutateSession { session ->
|
||||
val item = de.drazz.boehmitools.training.domain.itemFor(session, id)
|
||||
.updated("result_data", data)
|
||||
.updatedString("result", de.drazz.boehmitools.training.domain.formatResultData(data))
|
||||
updateItem(session, id, item)
|
||||
}
|
||||
}
|
||||
|
||||
fun setItemResultFormat(id: String, format: JsonObject?) {
|
||||
mutateSession { session ->
|
||||
var item = de.drazz.boehmitools.training.domain.itemFor(session, id)
|
||||
.updated("result_format", format)
|
||||
val current = item["result_data"] as? JsonObject
|
||||
if (format == null) {
|
||||
item = item.updated("result_data", null).updatedString("result", "")
|
||||
return@mutateSession updateItem(session, id, item)
|
||||
}
|
||||
|
||||
val nextMode = format.string("mode", "reps")
|
||||
if (nextMode == "none") {
|
||||
item = item.updated("result_data", null).updatedString("result", "")
|
||||
return@mutateSession updateItem(session, id, item)
|
||||
}
|
||||
|
||||
if (current != null) {
|
||||
val schema = mergeResultSchemaLayers(null, null, format)
|
||||
val adapted = adaptResultDataToSchema(current, schema)
|
||||
item = if (adapted == null) {
|
||||
item.updated("result_data", null).updatedString("result", "")
|
||||
} else {
|
||||
item.updated("result_data", adapted).updatedString("result", formatResultData(adapted))
|
||||
}
|
||||
}
|
||||
updateItem(session, id, item)
|
||||
}
|
||||
}
|
||||
|
||||
fun setWeekClosed(closed: Boolean) {
|
||||
val s = _state.value
|
||||
val statuses = s.tracker.obj("week_statuses").toMutableMap()
|
||||
statuses[s.week.toString()] = buildJsonObject {
|
||||
put("status", if (closed) "closed" else "open")
|
||||
put("updated_at", Instant.now().toString())
|
||||
}
|
||||
val tracker = s.tracker.updated("week_statuses", JsonObject(statuses))
|
||||
_state.value = s.copy(tracker = tracker)
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
private fun mutateSession(block: (JsonObject) -> JsonObject) {
|
||||
val s = _state.value
|
||||
val plan = s.plan ?: return
|
||||
val key = sessionKey(s.week, s.day)
|
||||
val nextSession = block(trackerSession(s.tracker, key, plan))
|
||||
_state.value = s.copy(tracker = updateTrackerSession(s.tracker, key, nextSession), saveState = "Ungespeichert")
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
private fun migrateCurrentLegacy() {
|
||||
val s = _state.value
|
||||
val plan = s.plan ?: return
|
||||
val day = plan.days.firstOrNull { it.num == s.day } ?: return
|
||||
val key = sessionKey(s.week, s.day)
|
||||
val (session, changed) = migrateLegacyItems(trackerSession(s.tracker, key, plan), day)
|
||||
if (changed) {
|
||||
_state.value = s.copy(tracker = updateTrackerSession(s.tracker, key, session))
|
||||
scheduleSave()
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleSave() {
|
||||
saveJob?.cancel()
|
||||
saveJob = viewModelScope.launch {
|
||||
delay(650)
|
||||
saveCurrent()
|
||||
}
|
||||
}
|
||||
|
||||
fun saveCurrent() {
|
||||
viewModelScope.launch {
|
||||
saveMutex.withLock {
|
||||
val snapshot = _state.value
|
||||
val plan = snapshot.plan ?: return@withLock
|
||||
val key = sessionKey(snapshot.week, snapshot.day)
|
||||
val session = trackerSession(snapshot.tracker, key, plan)
|
||||
val patch = sessionPatch(snapshot.tracker, key, session)
|
||||
_state.value = _state.value.copy(saveState = "Speichert …")
|
||||
runCatching { repository.patchSession(snapshot.selectedPlanId, key, patch) }
|
||||
.onSuccess { outcome ->
|
||||
when (outcome) {
|
||||
is SaveOutcome.Saved -> {
|
||||
val revision = outcome.body.int("revision", snapshot.tracker.int("revision", 1) + 1)
|
||||
_state.value = _state.value.copy(
|
||||
tracker = _state.value.tracker.updatedInt("revision", revision),
|
||||
saveState = "Gespeichert",
|
||||
response = _state.value.response
|
||||
.updated("analysis_catalog", outcome.body["analysis_catalog"])
|
||||
.updated("analysis_state", outcome.body["analysis_state"]),
|
||||
)
|
||||
}
|
||||
is SaveOutcome.Queued -> {
|
||||
_state.value = _state.value.copy(
|
||||
tracker = _state.value.tracker.updatedInt("revision", snapshot.tracker.int("revision", 1) + 1),
|
||||
saveState = "Offline vorgemerkt",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onFailure { error ->
|
||||
if (error is RevisionConflictException) {
|
||||
_state.value = _state.value.copy(conflict = error, saveState = "Konflikt")
|
||||
} else {
|
||||
_state.value = _state.value.copy(saveState = "Fehler", error = error.message ?: "Speichern fehlgeschlagen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun overlayPendingPatches(base: JsonObject, queued: List<PendingMutation>): JsonObject {
|
||||
val patches = queued.mapNotNull { mutation ->
|
||||
runCatching { wireJson.parseToJsonElement(mutation.payloadJson).jsonObject }.getOrNull()
|
||||
}
|
||||
return overlaySessionPatches(base, patches)
|
||||
}
|
||||
|
||||
fun dismissConflict() { _state.value = _state.value.copy(conflict = null) }
|
||||
fun reloadAfterConflict() { selectPlan(_state.value.selectedPlanId, persistServer = false) }
|
||||
fun discardQueuedConflict(id: Long) { viewModelScope.launch { repository.discardConflict(id) } }
|
||||
|
||||
fun startAnalysis(scope: String, week: Int? = null) {
|
||||
val planId = _state.value.selectedPlanId
|
||||
if (planId.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(analysisMessage = "Analyse wird gestartet …", error = "")
|
||||
runCatching { repository.analysisStart(planId, scope, week) }
|
||||
.onSuccess { outcome ->
|
||||
_state.value = _state.value.copy(
|
||||
response = _state.value.response
|
||||
.updated("analysis_state", outcome.body["analysis_state"])
|
||||
.updated("analysis_cache", outcome.body["analysis_cache"])
|
||||
.updated("analysis_catalog", outcome.body["analysis_catalog"]),
|
||||
analysisMessage = if (outcome.statusCode == 202) "Serverjob läuft …" else "Aktuelle Analyse aus Cache geladen.",
|
||||
)
|
||||
if (outcome.statusCode == 202) startPolling()
|
||||
}
|
||||
.onFailure { error -> _state.value = _state.value.copy(analysisMessage = "", error = error.message ?: "Analyse fehlgeschlagen") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPolling() {
|
||||
if (pollJob?.isActive == true) return
|
||||
val planId = _state.value.selectedPlanId
|
||||
pollJob = viewModelScope.launch {
|
||||
while (true) {
|
||||
delay(2_000)
|
||||
val body = runCatching { repository.analysisStatus(planId) }.getOrElse {
|
||||
_state.value = _state.value.copy(error = it.message ?: "Analysestatus konnte nicht gelesen werden")
|
||||
return@launch
|
||||
}
|
||||
_state.value = _state.value.copy(
|
||||
response = _state.value.response
|
||||
.updated("analysis_state", body["analysis_state"])
|
||||
.updated("analysis_cache", body["analysis_cache"])
|
||||
.updated("analysis_catalog", body["analysis_catalog"]),
|
||||
)
|
||||
val status = body.obj("analysis_state").string("status", "idle")
|
||||
_state.value = _state.value.copy(analysisMessage = body.obj("analysis_state").string("message"))
|
||||
if (status != "running") {
|
||||
selectPlan(planId, persistServer = false)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
|
||||
<path android:fillColor="#17212B" android:pathData="M0,0h108v108h-108z"/>
|
||||
<path android:fillColor="#D9F26A" android:pathData="M20,46h12v16h-12zM76,46h12v16h-12zM32,50h44v8h-44zM14,42h6v24h-6zM88,42h6v24h-6z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">boehmitools Training</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<resources>
|
||||
<style name="Theme.BoehmitoolsTraining" parent="android:style/Theme.Material.Light.NoActionBar">
|
||||
<item name="android:fontFamily">sans</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowActionModeOverlay">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
package de.drazz.boehmitools.training.domain
|
||||
|
||||
import java.io.InputStreamReader
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class FixtureContractTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
fun bothPlansRespectVersionAndStableIdContracts() {
|
||||
listOf("phase-1", "calihoss-newbie-8-wochen").forEach { name ->
|
||||
val wrapper = fixture("samples/plans/$name.json")
|
||||
val config = wrapper.obj("config")
|
||||
assertEquals(3, config.int("schema_version"))
|
||||
assertEquals(2, config.int("contract_version"))
|
||||
assertEquals(name, config.string("plan_id"))
|
||||
|
||||
val dayIds = mutableListOf<String>()
|
||||
val rotationIds = mutableListOf<String>()
|
||||
val placementIds = mutableListOf<String>()
|
||||
val stepIds = mutableListOf<String>()
|
||||
config.array("days").mapNotNull { it as? JsonObject }.forEach { day ->
|
||||
dayIds += day.string("id")
|
||||
day.array("rotations").mapNotNull { it as? JsonObject }.forEach { rotation ->
|
||||
rotationIds += rotation.string("id")
|
||||
rotation.array("exercises").mapNotNull { it as? JsonObject }.forEach { exercise ->
|
||||
placementIds += exercise.string("id")
|
||||
assertTrue(exercise.string("legacy_id").matches(Regex("d\\d+-r\\d+-e\\d+")))
|
||||
assertTrue(exercise.string("exercise_id").startsWith("movement-"))
|
||||
assertTrue(exercise.string("progression_id").isNotBlank())
|
||||
assertFalse(exercise.obj("result_schema").isEmpty())
|
||||
}
|
||||
}
|
||||
}
|
||||
config.obj("stages").values.mapNotNull { it as? JsonObject }.forEach { stage ->
|
||||
stage.array("steps").mapNotNull { it as? JsonObject }.forEach { step ->
|
||||
stepIds += step.string("id")
|
||||
assertFalse(step.obj("result_schema").isEmpty())
|
||||
}
|
||||
}
|
||||
listOf(dayIds, rotationIds, placementIds, stepIds).forEach { ids ->
|
||||
assertTrue(ids.all { it.isNotBlank() })
|
||||
assertEquals(ids.size, ids.toSet().size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsePlanSupportsBothProvidedEditorConfigs() {
|
||||
listOf("phase-1", "calihoss-newbie-8-wochen").forEach { name ->
|
||||
val config = fixture("samples/plans/$name.json").obj("config")
|
||||
val plan = parsePlan(config)
|
||||
assertEquals(8, plan.weeks)
|
||||
assertEquals(6, plan.days.size)
|
||||
assertEquals(31, plan.progressions.size)
|
||||
assertTrue(plan.days.flatMap { it.rotations }.flatMap { it.exercises }.isNotEmpty())
|
||||
assertTrue(plan.title.isNotBlank())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bothLegacySessionsLoadAndMigrateWithoutDroppingItems() {
|
||||
listOf("phase-1", "calihoss-newbie-8-wochen").forEach { name ->
|
||||
val plan = parsePlan(fixture("samples/plans/$name.json").obj("config"))
|
||||
val tracker = fixture("samples/sessions/$name.json")
|
||||
assertTrue(tracker.int("version") in 1..7)
|
||||
val firstSessionEntry = tracker.obj("sessions").entries.first()
|
||||
val day = plan.days.first { it.num == firstSessionEntry.key.substringAfter("-d").toInt() }
|
||||
val original = firstSessionEntry.value.jsonObject
|
||||
val originalCount = original.obj("items").size
|
||||
val (migrated, changed) = migrateLegacyItems(original, day)
|
||||
assertTrue(changed)
|
||||
assertEquals(originalCount, migrated.obj("items").size)
|
||||
day.rotations.flatMap { it.exercises }.forEach { exercise ->
|
||||
if (original.obj("items").containsKey(exercise.legacyId)) {
|
||||
assertTrue(migrated.obj("items").containsKey(exercise.id))
|
||||
assertFalse(migrated.obj("items").containsKey(exercise.legacyId))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resultSchemaPriorityIsManualThenStepThenExerciseThenFallback() {
|
||||
val config = fixture("samples/plans/phase-1.json").obj("config")
|
||||
val plan = parsePlan(config)
|
||||
val exercise = plan.days.flatMap { it.rotations }.flatMap { it.exercises }
|
||||
.first { it.progressionId == "pike" }
|
||||
val progression = plan.progressions.getValue("pike")
|
||||
val step = progression.steps.first { it.resultSchema != null }
|
||||
|
||||
val fromStep = resolveResultSchema(
|
||||
exercise = exercise,
|
||||
item = buildJsonObject {
|
||||
put("progression_step_id", step.id)
|
||||
},
|
||||
progressionText = step.name,
|
||||
plan = plan,
|
||||
)
|
||||
assertEquals("progression", fromStep.source)
|
||||
assertEquals(step.resultSchema?.mode, fromStep.mode)
|
||||
|
||||
val manual = resolveResultSchema(
|
||||
exercise = exercise,
|
||||
item = buildJsonObject {
|
||||
put("result_format", buildJsonObject {
|
||||
put("mode", "minutes")
|
||||
put("weight_mode", "none")
|
||||
put("laterality", "bilateral")
|
||||
put("sides_mode", "same")
|
||||
})
|
||||
},
|
||||
progressionText = step.name,
|
||||
plan = plan,
|
||||
)
|
||||
assertEquals("session", manual.source)
|
||||
assertEquals("minutes", manual.mode)
|
||||
assertNotEquals(fromStep.mode, manual.mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun partialProgressionSchemaInheritsUnspecifiedExerciseFields() {
|
||||
val exercise = buildJsonObject {
|
||||
put("mode", "reps")
|
||||
put("weight_mode", "required")
|
||||
put("laterality", "unilateral")
|
||||
put("sides_mode", "separate")
|
||||
put("sets", 4)
|
||||
put("locked_sets", true)
|
||||
}
|
||||
val progression = buildJsonObject { put("mode", "seconds") }
|
||||
val merged = mergeResultSchemaLayers(exercise, progression, null)
|
||||
assertEquals("seconds", merged.mode)
|
||||
assertEquals("required", merged.weightMode)
|
||||
assertEquals("unilateral", merged.laterality)
|
||||
assertEquals("separate", merged.sidesMode)
|
||||
assertEquals(4, merged.defaultSets)
|
||||
assertTrue(merged.lockedSets)
|
||||
assertEquals("progression", merged.source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun progressionUnitChangeNeverReinterpretsOldNumbers() {
|
||||
val current = buildJsonObject {
|
||||
put("version", 2)
|
||||
put("mode", "reps")
|
||||
put("laterality", "bilateral")
|
||||
put("sides_mode", "same")
|
||||
put("sets", 3)
|
||||
put("values", kotlinx.serialization.json.buildJsonArray { add(8); add(7); add(6) })
|
||||
put("left_values", kotlinx.serialization.json.JsonArray(emptyList()))
|
||||
put("right_values", kotlinx.serialization.json.JsonArray(emptyList()))
|
||||
put("weight_kg", 8.0)
|
||||
}
|
||||
val adapted = adaptResultDataToSchema(
|
||||
current,
|
||||
ResultSchemaUi(mode = "seconds", weightMode = "none", defaultSets = 2, lockedSets = true),
|
||||
)!!
|
||||
assertEquals("seconds", adapted.string("mode"))
|
||||
assertEquals(2, adapted.int("sets"))
|
||||
assertTrue(adapted.array("values").all { it == kotlinx.serialization.json.JsonNull })
|
||||
assertTrue(adapted["weight_kg"] == kotlinx.serialization.json.JsonNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resultDataV2NormalizesLegacyTextAndSetCount() {
|
||||
val schema = ResultSchemaUi(mode = "seconds", defaultSets = 3)
|
||||
val parsed = parseLegacyResult("3 x 20 s, 8 kg", schema)!!
|
||||
assertEquals(2, parsed.int("version"))
|
||||
assertEquals("seconds", parsed.string("mode"))
|
||||
assertEquals(3, parsed.int("sets"))
|
||||
assertEquals(3, parsed.array("values").size)
|
||||
assertTrue(formatResultData(parsed).contains("20"))
|
||||
assertTrue(formatResultData(parsed).contains("8 kg"))
|
||||
|
||||
val resized = resultDataWithSets(parsed, 20)
|
||||
assertEquals(20, resized.int("sets"))
|
||||
assertEquals(20, resized.array("values").size)
|
||||
assertEquals(20, resultDataWithSets(resized, 99).int("sets"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun queuedPatchesOverlayInFifoOrderAfterRestart() {
|
||||
val base = buildJsonObject {
|
||||
put("revision", 5)
|
||||
put("profile", buildJsonObject { put("display_name", "Server") })
|
||||
put("week_statuses", JsonObject(emptyMap()))
|
||||
put("sessions", JsonObject(emptyMap()))
|
||||
}
|
||||
val first = buildJsonObject {
|
||||
put("expected_revision", 5)
|
||||
put("profile", buildJsonObject { put("display_name", "Lokal") })
|
||||
put("session_key", "w01-d01")
|
||||
put("session", buildJsonObject { put("status", "in_progress") })
|
||||
}
|
||||
val second = buildJsonObject {
|
||||
put("expected_revision", 6)
|
||||
put("week_statuses", buildJsonObject { put("1", buildJsonObject { put("status", "closed") }) })
|
||||
put("session_key", "w01-d02")
|
||||
put("session", buildJsonObject { put("status", "completed") })
|
||||
}
|
||||
val overlaid = overlaySessionPatches(base, listOf(first, second))
|
||||
assertEquals(7, overlaid.int("revision"))
|
||||
assertEquals("Lokal", overlaid.obj("profile").string("display_name"))
|
||||
assertEquals("closed", overlaid.obj("week_statuses").obj("1").string("status"))
|
||||
assertEquals("in_progress", overlaid.obj("sessions").obj("w01-d01").string("status"))
|
||||
assertEquals("completed", overlaid.obj("sessions").obj("w01-d02").string("status"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun analysesAndProposalsMatchOverwriteOnlyModel() {
|
||||
val week = fixture("samples/analyses/phase-1/week-01.json")
|
||||
val overall = fixture("samples/analyses/phase-1/overall.json")
|
||||
val proposals = fixture("samples/proposals/phase-1.json")
|
||||
assertEquals("week", week.string("type"))
|
||||
assertEquals("overall", overall.string("type"))
|
||||
assertTrue(proposals.array("proposals").isNotEmpty())
|
||||
proposals.array("proposals").mapNotNull { it as? JsonObject }.forEach {
|
||||
assertTrue(it.string("id").isNotBlank())
|
||||
assertTrue(it.string("target_exercise_id").startsWith("movement-"))
|
||||
assertEquals("open", it.string("status"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun fixture(path: String): JsonObject {
|
||||
val stream = checkNotNull(javaClass.classLoader?.getResourceAsStream(path)) { "Fixture fehlt: $path" }
|
||||
return InputStreamReader(stream).use { json.parseToJsonElement(it.readText()).jsonObject }
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"version": 2,
|
||||
"source_file": "phase-1.json",
|
||||
"plan_id": "phase-1",
|
||||
"published_revision": 1,
|
||||
"updated_at": "2026-07-23T12:01:33+00:00",
|
||||
"proposals": [
|
||||
{
|
||||
"id": "overall:overall-1",
|
||||
"status": "open",
|
||||
"scope_key": "overall:overall",
|
||||
"analysis_type": "overall",
|
||||
"week": null,
|
||||
"target": "Woche 2 / Pike",
|
||||
"target_label": "Pike: nächste Stufe prüfen",
|
||||
"target_exercise_id": "movement-pike-17f7716e00",
|
||||
"target_progression_id": "pike",
|
||||
"target_step_id": "step-hold-kleine-senkung-4f3bfac4b2",
|
||||
"suggested_change": "Progressionsstufe von „Pike Hold“ auf „Hold + kleine Senkung“ setzen.",
|
||||
"reason": "Zwei Einheiten mit 6x20 s Pike Hold sprechen für einen vorsichtigen Variantenwechsel.",
|
||||
"condition": "Nur wenn beide Pike-Einheiten technisch sauber waren; sonst Pike Hold beibehalten.",
|
||||
"manual_step": "Im Trainingsplan-Modul bei Pike die nächste Progressionsstufe auswählen.",
|
||||
"analysis_created_at": "2026-07-23T12:01:33+00:00"
|
||||
},
|
||||
{
|
||||
"id": "overall:overall-2",
|
||||
"status": "open",
|
||||
"scope_key": "overall:overall",
|
||||
"analysis_type": "overall",
|
||||
"week": null,
|
||||
"target": "Woche 2 / Einarm-Rudern ENG",
|
||||
"target_label": "Einarm-Rudern ENG: Last oder Stufe prüfen",
|
||||
"target_exercise_id": "movement-einarm-rudern-eng-kb-4907fbb5c7",
|
||||
"target_progression_id": "rowe",
|
||||
"target_step_id": "step-etwas-mehr-4f2775725c",
|
||||
"suggested_change": "Von „leicht“ auf „etwas mehr“ wechseln.",
|
||||
"reason": "Die Wiederholungen lagen in beiden Einheiten stabil bei etwa 8–9 pro Intervall.",
|
||||
"condition": "Nur wenn Rückenposition und Zugweg sauber bleiben; sonst aktuelle Stufe beibehalten.",
|
||||
"manual_step": "Im Trainingsplan-Modul die nächste Rudern-ENG-Progression oder passende Last eintragen.",
|
||||
"analysis_created_at": "2026-07-23T12:01:33+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
{
|
||||
"created_at": "2026-07-23T09:14:02+00:00",
|
||||
"profile": {
|
||||
"display_name": "",
|
||||
"plan_notes": "",
|
||||
"start_date": ""
|
||||
},
|
||||
"progressions": {
|
||||
"ls": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:1-2-reps-reserve-pruefen": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:beine-protokollieren": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:brustwirbelsaeule-schultern": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:bulletproof-shoulders": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:core-protokollieren": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:finger-plank": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:full-body-mobility": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:hueft-mobilitaet": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:lockerer-spaziergang": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:negative-pruefen": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:prone-y-t-w-technik": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:pull-protokollieren": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:push-protokollieren": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:regeneration-pruefen": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:schlaf-priorisieren": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:schmerzcheck": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:schwierigkeit-erhoehen": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:sonst-vollstaendig-pausieren": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:spaziergang": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:sprunggelenk-mobilitaet": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:startposition-pruefen": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:volle-reps-pruefen": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:wrist-conditioning": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"plank": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"rowe": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"squat": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"w01-d01": {
|
||||
"completed_at": "",
|
||||
"items": {
|
||||
"cooldown-0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e1": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e2": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e3": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e1": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e2": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e3": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"stretch-0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-1": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-2": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-3": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-4": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
}
|
||||
},
|
||||
"note": "",
|
||||
"started_at": "",
|
||||
"status": "planned"
|
||||
},
|
||||
"w02-d01": {
|
||||
"completed_at": "",
|
||||
"items": {
|
||||
"cooldown-0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e1": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e2": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e3": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e1": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e2": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e3": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"stretch-0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-1": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-2": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-3": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-4": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
}
|
||||
},
|
||||
"note": "",
|
||||
"started_at": "",
|
||||
"status": "planned"
|
||||
},
|
||||
"w03-d01": {
|
||||
"completed_at": "",
|
||||
"items": {
|
||||
"cooldown-0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e1": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e2": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e3": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e1": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e2": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e3": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"stretch-0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-1": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-2": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-3": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-4": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
}
|
||||
},
|
||||
"note": "",
|
||||
"started_at": "",
|
||||
"status": "planned"
|
||||
},
|
||||
"w08-d01": {
|
||||
"completed_at": "",
|
||||
"items": {
|
||||
"cooldown-0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e1": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e2": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e3": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e1": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e2": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e3": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"stretch-0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-1": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-2": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-3": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-4": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
}
|
||||
},
|
||||
"note": "",
|
||||
"started_at": "",
|
||||
"status": "planned"
|
||||
},
|
||||
"w01-d02": {
|
||||
"status": "planned",
|
||||
"items": {
|
||||
"warmup-0": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"warmup-1": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d2-r0-e0": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d2-r0-e1": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d2-r0-e2": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d2-r0-e3": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d2-r1-e0": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d2-r1-e1": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d2-r1-e2": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d2-r1-e3": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"cooldown-0": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"cooldown-1": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"stretch-0": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
}
|
||||
},
|
||||
"note": "",
|
||||
"started_at": "",
|
||||
"completed_at": ""
|
||||
},
|
||||
"w01-d03": {
|
||||
"status": "planned",
|
||||
"items": {
|
||||
"warmup-0": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"warmup-1": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"warmup-2": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"warmup-3": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"warmup-4": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d3-r0-e0": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d3-r0-e1": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d3-r0-e2": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d3-r0-e3": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d3-r1-e0": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d3-r1-e1": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d3-r1-e2": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d3-r1-e3": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"cooldown-0": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"stretch-0": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
}
|
||||
},
|
||||
"note": "",
|
||||
"started_at": "",
|
||||
"completed_at": ""
|
||||
}
|
||||
},
|
||||
"source_file": "calihoss-newbie-8-wochen.json",
|
||||
"source_hash": "6d9ab7972384bb711693ced004c6a7053fbe858323e98dfea7e7a6b0c56c83b1",
|
||||
"updated_at": "2026-07-23T09:42:21+00:00",
|
||||
"version": 1
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
....ss.s.ssss.s.... [100%]
|
||||
11 passed, 8 skipped in 0.05s
|
||||
@@ -0,0 +1,16 @@
|
||||
Date: 2026-07-24T11:29:54+00:00
|
||||
OS: Linux 922d8f789c54 6.12.13 #1 SMP Wed Jul 22 00:33:13 UTC 2026 x86_64 GNU/Linux
|
||||
Java:
|
||||
openjdk version "21.0.10" 2026-01-20
|
||||
OpenJDK Runtime Environment (build 21.0.10+7-Debian-1deb13u1)
|
||||
OpenJDK 64-Bit Server VM (build 21.0.10+7-Debian-1deb13u1, mixed mode, sharing)
|
||||
Kotlinc:
|
||||
info: kotlinc-jvm 1.9.0 (JRE 21.0.10+7-Debian-1deb13u1)
|
||||
ANDROID_HOME=<unset>
|
||||
ANDROID_SDK_ROOT=<unset>
|
||||
gradle=<not installed>
|
||||
sdkmanager=<not installed>
|
||||
adb=<not installed>
|
||||
DNS services.gradle.org:
|
||||
<resolution failed>
|
||||
Gradle cache:
|
||||
@@ -0,0 +1,22 @@
|
||||
===== ./gradlew assembleDebug =====
|
||||
Exception in thread "main" java.net.UnknownHostException: services.gradle.org
|
||||
at java.base/sun.nio.ch.NioSocketImpl.connect(NioSocketImpl.java:567)
|
||||
at java.base/java.net.SocksSocketImpl.connect(SocksSocketImpl.java:327)
|
||||
at java.base/java.net.Socket.connect(Socket.java:751)
|
||||
at java.base/sun.security.ssl.SSLSocketImpl.connect(SSLSocketImpl.java:304)
|
||||
at java.base/sun.net.NetworkClient.doConnect(NetworkClient.java:178)
|
||||
at java.base/sun.net.www.http.HttpClient.openServer(HttpClient.java:531)
|
||||
at java.base/sun.net.www.http.HttpClient.openServer(HttpClient.java:636)
|
||||
at java.base/sun.net.www.protocol.https.HttpsClient.<init>(HttpsClient.java:264)
|
||||
at java.base/sun.net.www.protocol.https.HttpsClient.New(HttpsClient.java:377)
|
||||
at java.base/sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.getNewHttpClient(AbstractDelegateHttpsURLConnection.java:193)
|
||||
at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect0(HttpURLConnection.java:1257)
|
||||
at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect(HttpURLConnection.java:1143)
|
||||
at java.base/sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.connect(AbstractDelegateHttpsURLConnection.java:179)
|
||||
at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream0(HttpURLConnection.java:1705)
|
||||
at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream(HttpURLConnection.java:1629)
|
||||
at java.base/java.net.HttpURLConnection.getResponseCode(HttpURLConnection.java:531)
|
||||
at java.base/sun.net.www.protocol.https.HttpsURLConnectionImpl.getResponseCode(HttpsURLConnectionImpl.java:307)
|
||||
at org.gradle.wrapper.GradleWrapperMain.download(GradleWrapperMain.java:108)
|
||||
at org.gradle.wrapper.GradleWrapperMain.main(GradleWrapperMain.java:55)
|
||||
EXIT_CODE=1
|
||||
@@ -0,0 +1,22 @@
|
||||
===== ./gradlew lintDebug =====
|
||||
Exception in thread "main" java.net.UnknownHostException: services.gradle.org
|
||||
at java.base/sun.nio.ch.NioSocketImpl.connect(NioSocketImpl.java:567)
|
||||
at java.base/java.net.SocksSocketImpl.connect(SocksSocketImpl.java:327)
|
||||
at java.base/java.net.Socket.connect(Socket.java:751)
|
||||
at java.base/sun.security.ssl.SSLSocketImpl.connect(SSLSocketImpl.java:304)
|
||||
at java.base/sun.net.NetworkClient.doConnect(NetworkClient.java:178)
|
||||
at java.base/sun.net.www.http.HttpClient.openServer(HttpClient.java:531)
|
||||
at java.base/sun.net.www.http.HttpClient.openServer(HttpClient.java:636)
|
||||
at java.base/sun.net.www.protocol.https.HttpsClient.<init>(HttpsClient.java:264)
|
||||
at java.base/sun.net.www.protocol.https.HttpsClient.New(HttpsClient.java:377)
|
||||
at java.base/sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.getNewHttpClient(AbstractDelegateHttpsURLConnection.java:193)
|
||||
at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect0(HttpURLConnection.java:1257)
|
||||
at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect(HttpURLConnection.java:1143)
|
||||
at java.base/sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.connect(AbstractDelegateHttpsURLConnection.java:179)
|
||||
at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream0(HttpURLConnection.java:1705)
|
||||
at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream(HttpURLConnection.java:1629)
|
||||
at java.base/java.net.HttpURLConnection.getResponseCode(HttpURLConnection.java:531)
|
||||
at java.base/sun.net.www.protocol.https.HttpsURLConnectionImpl.getResponseCode(HttpsURLConnectionImpl.java:307)
|
||||
at org.gradle.wrapper.GradleWrapperMain.download(GradleWrapperMain.java:108)
|
||||
at org.gradle.wrapper.GradleWrapperMain.main(GradleWrapperMain.java:55)
|
||||
EXIT_CODE=1
|
||||
@@ -0,0 +1,22 @@
|
||||
===== ./gradlew test =====
|
||||
Exception in thread "main" java.net.UnknownHostException: services.gradle.org
|
||||
at java.base/sun.nio.ch.NioSocketImpl.connect(NioSocketImpl.java:567)
|
||||
at java.base/java.net.SocksSocketImpl.connect(SocksSocketImpl.java:327)
|
||||
at java.base/java.net.Socket.connect(Socket.java:751)
|
||||
at java.base/sun.security.ssl.SSLSocketImpl.connect(SSLSocketImpl.java:304)
|
||||
at java.base/sun.net.NetworkClient.doConnect(NetworkClient.java:178)
|
||||
at java.base/sun.net.www.http.HttpClient.openServer(HttpClient.java:531)
|
||||
at java.base/sun.net.www.http.HttpClient.openServer(HttpClient.java:636)
|
||||
at java.base/sun.net.www.protocol.https.HttpsClient.<init>(HttpsClient.java:264)
|
||||
at java.base/sun.net.www.protocol.https.HttpsClient.New(HttpsClient.java:377)
|
||||
at java.base/sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.getNewHttpClient(AbstractDelegateHttpsURLConnection.java:193)
|
||||
at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect0(HttpURLConnection.java:1257)
|
||||
at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect(HttpURLConnection.java:1143)
|
||||
at java.base/sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.connect(AbstractDelegateHttpsURLConnection.java:179)
|
||||
at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream0(HttpURLConnection.java:1705)
|
||||
at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream(HttpURLConnection.java:1629)
|
||||
at java.base/java.net.HttpURLConnection.getResponseCode(HttpURLConnection.java:531)
|
||||
at java.base/sun.net.www.protocol.https.HttpsURLConnectionImpl.getResponseCode(HttpsURLConnectionImpl.java:307)
|
||||
at org.gradle.wrapper.GradleWrapperMain.download(GradleWrapperMain.java:108)
|
||||
at org.gradle.wrapper.GradleWrapperMain.main(GradleWrapperMain.java:55)
|
||||
EXIT_CODE=1
|
||||
@@ -0,0 +1,20 @@
|
||||
Exception in thread "main" java.net.UnknownHostException: services.gradle.org
|
||||
at java.base/sun.nio.ch.NioSocketImpl.connect(NioSocketImpl.java:567)
|
||||
at java.base/java.net.SocksSocketImpl.connect(SocksSocketImpl.java:327)
|
||||
at java.base/java.net.Socket.connect(Socket.java:751)
|
||||
at java.base/sun.security.ssl.SSLSocketImpl.connect(SSLSocketImpl.java:304)
|
||||
at java.base/sun.net.NetworkClient.doConnect(NetworkClient.java:178)
|
||||
at java.base/sun.net.www.http.HttpClient.openServer(HttpClient.java:531)
|
||||
at java.base/sun.net.www.http.HttpClient.openServer(HttpClient.java:636)
|
||||
at java.base/sun.net.www.protocol.https.HttpsClient.<init>(HttpsClient.java:264)
|
||||
at java.base/sun.net.www.protocol.https.HttpsClient.New(HttpsClient.java:377)
|
||||
at java.base/sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.getNewHttpClient(AbstractDelegateHttpsURLConnection.java:193)
|
||||
at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect0(HttpURLConnection.java:1257)
|
||||
at java.base/sun.net.www.protocol.http.HttpURLConnection.plainConnect(HttpURLConnection.java:1143)
|
||||
at java.base/sun.net.www.protocol.https.AbstractDelegateHttpsURLConnection.connect(AbstractDelegateHttpsURLConnection.java:179)
|
||||
at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream0(HttpURLConnection.java:1705)
|
||||
at java.base/sun.net.www.protocol.http.HttpURLConnection.getInputStream(HttpURLConnection.java:1629)
|
||||
at java.base/java.net.HttpURLConnection.getResponseCode(HttpURLConnection.java:531)
|
||||
at java.base/sun.net.www.protocol.https.HttpsURLConnectionImpl.getResponseCode(HttpsURLConnectionImpl.java:307)
|
||||
at org.gradle.wrapper.GradleWrapperMain.download(GradleWrapperMain.java:77)
|
||||
at org.gradle.wrapper.GradleWrapperMain.main(GradleWrapperMain.java:32)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
Kotlinc was run without the Android/Gradle dependency classpath. Unresolved Android and library references are expected.
|
||||
Potential parser/syntax diagnostics:
|
||||
none
|
||||
|
||||
Total diagnostics:
|
||||
8017 build-logs/kotlinc-static.log
|
||||
@@ -0,0 +1 @@
|
||||
PASS: 8 JSON-Dateien und 7 XML-Dateien parsebar
|
||||
@@ -0,0 +1,11 @@
|
||||
Production credential/key scan:
|
||||
PASS: no production credential/key pattern found
|
||||
|
||||
Forbidden technology scan:
|
||||
PASS: no WebView or direct OpenAI client endpoint found
|
||||
|
||||
Release cleartext controls:
|
||||
app/src/main/res/xml/network_security_config.xml:3: <base-config cleartextTrafficPermitted="false">
|
||||
app/src/main/AndroidManifest.xml:15: android:usesCleartextTraffic="false">
|
||||
app/src/main/java/de/drazz/boehmitools/training/data/network/Api.kt:77: require(BuildConfig.DEBUG || scheme == "https") { "Release-Builds erlauben ausschließlich HTTPS." }
|
||||
app/src/main/java/de/drazz/boehmitools/training/data/network/Api.kt:77: require(BuildConfig.DEBUG || scheme == "https") { "Release-Builds erlauben ausschließlich HTTPS." }
|
||||
@@ -0,0 +1,148 @@
|
||||
{
|
||||
"current": {
|
||||
"files": 103,
|
||||
"extensions": {
|
||||
".py": 27,
|
||||
".json": 20,
|
||||
".example": 1,
|
||||
"<none>": 5,
|
||||
".md": 7,
|
||||
".txt": 1,
|
||||
".yml": 1,
|
||||
".css": 1,
|
||||
".js": 3,
|
||||
".tag": 1,
|
||||
".html": 6,
|
||||
".pyc": 29,
|
||||
".lock": 1
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"files": 29,
|
||||
"extensions": {
|
||||
".json": 9,
|
||||
".md": 4,
|
||||
".py": 14,
|
||||
".html": 2
|
||||
}
|
||||
},
|
||||
"samples": {
|
||||
"files": 8,
|
||||
"extensions": {
|
||||
".md": 1,
|
||||
".json": 7
|
||||
}
|
||||
},
|
||||
"plans": [
|
||||
{
|
||||
"file": "calihoss-newbie-8-wochen.json",
|
||||
"plan_id": "calihoss-newbie-8-wochen",
|
||||
"revision": 1,
|
||||
"published_revision": 1,
|
||||
"schema_version": 3,
|
||||
"contract_version": 2,
|
||||
"weeks": 8,
|
||||
"days": 6,
|
||||
"rotations": 12,
|
||||
"exercise_placements": 48,
|
||||
"stages": 31,
|
||||
"steps": 248,
|
||||
"exercise_catalog": 27,
|
||||
"exercise_result_schemas": 48,
|
||||
"step_result_schemas": 248,
|
||||
"stable_ids_nonblank": true,
|
||||
"stable_ids_unique": false,
|
||||
"legacy_ids": 48,
|
||||
"training_mode": "sets_reps"
|
||||
},
|
||||
{
|
||||
"file": "phase-1.json",
|
||||
"plan_id": "phase-1",
|
||||
"revision": 1,
|
||||
"published_revision": 1,
|
||||
"schema_version": 3,
|
||||
"contract_version": 2,
|
||||
"weeks": 8,
|
||||
"days": 6,
|
||||
"rotations": 12,
|
||||
"exercise_placements": 48,
|
||||
"stages": 31,
|
||||
"steps": 248,
|
||||
"exercise_catalog": 40,
|
||||
"exercise_result_schemas": 48,
|
||||
"step_result_schemas": 248,
|
||||
"stable_ids_nonblank": true,
|
||||
"stable_ids_unique": false,
|
||||
"legacy_ids": 48,
|
||||
"training_mode": "tabata"
|
||||
}
|
||||
],
|
||||
"sessions": [
|
||||
{
|
||||
"file": "calihoss-newbie-8-wochen.json",
|
||||
"version": 1,
|
||||
"sessions": 6,
|
||||
"progressions": 27,
|
||||
"items": 88,
|
||||
"legacy_position_items": 48,
|
||||
"sequence_items": 40,
|
||||
"structured_result_data": 0,
|
||||
"free_text_results": 0
|
||||
},
|
||||
{
|
||||
"file": "phase-1.json",
|
||||
"version": 5,
|
||||
"sessions": 10,
|
||||
"progressions": 36,
|
||||
"items": 197,
|
||||
"legacy_position_items": 80,
|
||||
"sequence_items": 117,
|
||||
"structured_result_data": 0,
|
||||
"free_text_results": 40
|
||||
}
|
||||
],
|
||||
"analyses": [
|
||||
{
|
||||
"file": "samples/analyses/phase-1/overall.json",
|
||||
"type": "overall",
|
||||
"week": null,
|
||||
"status": "success",
|
||||
"response_keys": [
|
||||
"headline",
|
||||
"summary",
|
||||
"data_quality",
|
||||
"metrics",
|
||||
"exercise_updates",
|
||||
"plan_adjustments",
|
||||
"warnings",
|
||||
"conclusion"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "samples/analyses/phase-1/week-01.json",
|
||||
"type": "week",
|
||||
"week": 1,
|
||||
"status": "success",
|
||||
"response_keys": [
|
||||
"headline",
|
||||
"summary",
|
||||
"data_quality",
|
||||
"metrics",
|
||||
"exercise_updates",
|
||||
"plan_adjustments",
|
||||
"warnings",
|
||||
"conclusion"
|
||||
]
|
||||
}
|
||||
],
|
||||
"proposals": {
|
||||
"version": 2,
|
||||
"plan_id": "phase-1",
|
||||
"published_revision": 1,
|
||||
"count": 2,
|
||||
"statuses": {
|
||||
"open": 2
|
||||
}
|
||||
},
|
||||
"screenshots": []
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
trainingsplan diff:
|
||||
Only in /mnt/data/boehmitools-training-work/boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan: __pycache__
|
||||
Only in /mnt/data/boehmitools-training-work/boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/tests: __pycache__
|
||||
trainingstracker diff:
|
||||
Only in /mnt/data/boehmitools-training-work/boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker: __pycache__
|
||||
Only in /mnt/data/boehmitools-training-work/boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/tests: __pycache__
|
||||
plans diff:
|
||||
Only in /mnt/data/boehmitools-training-work/boehmitools-current/01-boehmitools-current/boehmitools/data/trainingsplan/plans: couchsnacks.json
|
||||
Only in /mnt/data/boehmitools-training-work/boehmitools-current/01-boehmitools-current/boehmitools/data/trainingsplan/plans: fruehstueck.json
|
||||
Only in /mnt/data/boehmitools-training-work/boehmitools-current/01-boehmitools-current/boehmitools/data/trainingsplan/plans: granola.json
|
||||
Only in /mnt/data/boehmitools-training-work/boehmitools-current/01-boehmitools-current/boehmitools/data/trainingsplan/plans: nachmittags.json
|
||||
@@ -0,0 +1,130 @@
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/.dockerignore
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/.env.example
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/.gitignore
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/Dockerfile
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/MANIFEST.json
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/MIGRATION.md
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/README.md
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/RELEASE-NOTES.md
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/core/__init__.py
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/core/__pycache__/__init__.cpython-312.pyc
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/core/__pycache__/ai.cpython-312.pyc
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/core/__pycache__/app.cpython-312.pyc
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/core/__pycache__/backups.cpython-312.pyc
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/core/__pycache__/foodmatch.cpython-312.pyc
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/core/__pycache__/importer.cpython-312.pyc
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/core/__pycache__/jobs.cpython-312.pyc
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/core/__pycache__/loader.cpython-312.pyc
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/core/__pycache__/registry.cpython-312.pyc
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/core/__pycache__/security.cpython-312.pyc
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/core/__pycache__/settings.cpython-312.pyc
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/core/__pycache__/tandoor.cpython-312.pyc
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/core/ai.py
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/core/app.py
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/core/backups.py
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/core/foodmatch.py
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/core/importer.py
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/core/jobs.py
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/core/loader.py
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/core/registry.py
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/core/security.py
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/core/settings.py
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/core/static/404.html
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/core/static/dashboard.html
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/core/static/jobs.html
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/core/static/settings.html
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/core/tandoor.py
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/data/settings.json
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/data/trainingsplan/plans/calihoss-newbie-8-wochen.json
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/data/trainingsplan/plans/couchsnacks.json
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/data/trainingsplan/plans/fruehstueck.json
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/data/trainingsplan/plans/granola.json
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/data/trainingsplan/plans/nachmittags.json
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/data/trainingsplan/plans/phase-1.json
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/data/trainingstracker/analyses/phase-1.json/.analysis.lock
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/data/trainingstracker/analyses/phase-1.json/index.json
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/data/trainingstracker/analyses/phase-1.json/state.json
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/data/trainingstracker/analyses/phase-1.json/week-01.json
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/data/trainingstracker/sessions/calihoss-newbie-8-wochen.json
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/data/trainingstracker/sessions/phase-1.json
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/data/trainingstracker/settings.json
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/docker-compose.yml
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/README.md
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/TOOL-README.md
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/__pycache__/app.cpython-312.pyc
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/__pycache__/backend.cpython-312.pyc
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/__pycache__/plan_builder.cpython-312.pyc
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/__pycache__/recipe_builder.cpython-312.pyc
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/__pycache__/schema_contract.cpython-312.pyc
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/__pycache__/storage.cpython-312.pyc
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/app.py
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/backend.py
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/plan_builder.py
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/plugin.json
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/presets/couchsnacks.json
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/presets/fruehstueck.json
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/presets/granola.json
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/presets/nachmittags.json
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/recipe_builder.py
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/schema_contract.py
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/static/index.html
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/storage.py
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingsplan/tests/test_contract.py
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/README.md
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/__pycache__/analysis_store.cpython-312.pyc
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/__pycache__/app.cpython-312.pyc
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/__pycache__/backend.cpython-312.pyc
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/__pycache__/contract.cpython-312.pyc
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/__pycache__/session_store.cpython-312.pyc
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/analysis_store.py
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/app.py
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/backend.py
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/contract.py
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/plugin.json
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/session_store.py
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/static/index.html
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/tests/test_app.py
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/plugins/trainingstracker/tests/test_stability.py
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/requirements.txt
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/run.py
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/shared/boehmi-backups.js
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/shared/boehmi-runner.js
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/shared/boehmi.css
|
||||
boehmitools-current/01-boehmitools-current/boehmitools/shared/boehmi.js
|
||||
samples/training-app-samples/README.md
|
||||
samples/training-app-samples/samples/analyses/phase-1/overall.json
|
||||
samples/training-app-samples/samples/analyses/phase-1/week-01.json
|
||||
samples/training-app-samples/samples/plans/calihoss-newbie-8-wochen.json
|
||||
samples/training-app-samples/samples/plans/phase-1.json
|
||||
samples/training-app-samples/samples/proposals/phase-1.json
|
||||
samples/training-app-samples/samples/sessions/calihoss-newbie-8-wochen.json
|
||||
samples/training-app-samples/samples/sessions/phase-1.json
|
||||
training-tools-v2/MANIFEST.json
|
||||
training-tools-v2/MIGRATION.md
|
||||
training-tools-v2/RELEASE-NOTES.md
|
||||
training-tools-v2/data/trainingsplan/plans/calihoss-newbie-8-wochen.json
|
||||
training-tools-v2/data/trainingsplan/plans/phase-1.json
|
||||
training-tools-v2/plugins/trainingsplan/TOOL-README.md
|
||||
training-tools-v2/plugins/trainingsplan/app.py
|
||||
training-tools-v2/plugins/trainingsplan/backend.py
|
||||
training-tools-v2/plugins/trainingsplan/plan_builder.py
|
||||
training-tools-v2/plugins/trainingsplan/plugin.json
|
||||
training-tools-v2/plugins/trainingsplan/presets/couchsnacks.json
|
||||
training-tools-v2/plugins/trainingsplan/presets/fruehstueck.json
|
||||
training-tools-v2/plugins/trainingsplan/presets/granola.json
|
||||
training-tools-v2/plugins/trainingsplan/presets/nachmittags.json
|
||||
training-tools-v2/plugins/trainingsplan/recipe_builder.py
|
||||
training-tools-v2/plugins/trainingsplan/schema_contract.py
|
||||
training-tools-v2/plugins/trainingsplan/static/index.html
|
||||
training-tools-v2/plugins/trainingsplan/storage.py
|
||||
training-tools-v2/plugins/trainingsplan/tests/test_contract.py
|
||||
training-tools-v2/plugins/trainingstracker/README.md
|
||||
training-tools-v2/plugins/trainingstracker/analysis_store.py
|
||||
training-tools-v2/plugins/trainingstracker/app.py
|
||||
training-tools-v2/plugins/trainingstracker/backend.py
|
||||
training-tools-v2/plugins/trainingstracker/contract.py
|
||||
training-tools-v2/plugins/trainingstracker/plugin.json
|
||||
training-tools-v2/plugins/trainingstracker/session_store.py
|
||||
training-tools-v2/plugins/trainingstracker/static/index.html
|
||||
training-tools-v2/plugins/trainingstracker/tests/test_app.py
|
||||
training-tools-v2/plugins/trainingstracker/tests/test_stability.py
|
||||
@@ -0,0 +1,2 @@
|
||||
PASS: 728 Prüfungen
|
||||
EXIT_CODE=0
|
||||
@@ -0,0 +1,2 @@
|
||||
471264b9b8fdf46888558ce7f23bacf2e4c958edfac3106b80c065af6e727cf1 gradle/wrapper/gradle-wrapper.jar
|
||||
d16696db59d33ccbed443f46c95e0f44b4636605db3fe515163e99a6d8c006f1 gradle/wrapper/bootstrap-src/org/gradle/wrapper/GradleWrapperMain.java
|
||||
@@ -0,0 +1,8 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
alias(libs.plugins.kotlin.kapt) apply false
|
||||
alias(libs.plugins.hilt) apply false
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
# API-Vertrag boehmitools Training
|
||||
|
||||
Stand: aus dem vollständigen boehmitools-Quellstand und den Plugins `trainingsplan`/`trainingstracker` 2.0 abgeleitet. Die Plugins werden durch die Host-App unter `/plugins/<plugin-id>` gemountet. Alle Pfade unten sind relativ zur konfigurierbaren Serverbasis, standardmäßig `https://tools.d-razz.de/`.
|
||||
|
||||
## Authentifizierung
|
||||
|
||||
Die Host-App schützt alle Pfade außer exakt `/api/health` optional mit HTTP Basic Auth. Ist `APP_PASSWORD` gesetzt, muss der Client `Authorization: Basic <base64(username:password)>` senden. Der Standard-Benutzername des Servers ist `admin`, sofern `APP_USERNAME` nicht gesetzt wurde.
|
||||
|
||||
Fehlerantwort bei fehlender oder falscher Anmeldung:
|
||||
|
||||
```json
|
||||
{"detail":"Anmeldung erforderlich."}
|
||||
```
|
||||
|
||||
Status: `401 Unauthorized`, Header `WWW-Authenticate: Basic realm="boehmitools"`.
|
||||
|
||||
## Host-Verbindungstest
|
||||
|
||||
### `GET /api/health`
|
||||
|
||||
Ohne Authentifizierung erreichbar. Antwortfelder laut Backend:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.0.0",
|
||||
"plugins": {"trainingsplan":"ok","trainingstracker":"ok"},
|
||||
"tandoor_configured": false,
|
||||
"openai_configured": true,
|
||||
"authentication_enabled": true,
|
||||
"jobs_running": 0
|
||||
}
|
||||
```
|
||||
|
||||
Status: `200`. Die App prüft danach zusätzlich den authentifizierten Tracker-Health-Endpunkt.
|
||||
|
||||
## Trainingstracker
|
||||
|
||||
Basis: `/plugins/trainingstracker`
|
||||
|
||||
### `GET /api/health`
|
||||
|
||||
Liefert `ok`, Server-Datenpfade, Verfügbarkeit von OpenAI und die unterstützten Vertragsversionen:
|
||||
|
||||
- `plan_schema`
|
||||
- `training_contract`
|
||||
- `tracker_schema`
|
||||
- `result_data`
|
||||
- `prompt`
|
||||
- `analysis_schema`
|
||||
- `normalization`
|
||||
|
||||
Status: `200`, oder hostweit `401`.
|
||||
|
||||
### `GET /api/plans`
|
||||
|
||||
Antwort:
|
||||
|
||||
```json
|
||||
{
|
||||
"plans": [{
|
||||
"id": "phase-1.json",
|
||||
"filename": "phase-1.json",
|
||||
"name": "Phase 1",
|
||||
"title": "…",
|
||||
"subtitle": "…",
|
||||
"weeks": 8,
|
||||
"days": 6,
|
||||
"tracked": true,
|
||||
"start_date": "",
|
||||
"completed_sessions": 5,
|
||||
"source_changed": false,
|
||||
"plan_id": "phase-1",
|
||||
"published_revision": 1,
|
||||
"tracker_revision": 7
|
||||
}],
|
||||
"selected": "phase-1.json"
|
||||
}
|
||||
```
|
||||
|
||||
Status: `200`.
|
||||
|
||||
### `POST /api/plans/{plan_id}/select`
|
||||
|
||||
Kein Request-Body erforderlich. Antwort: `{"ok":true,"selected":"phase-1.json"}`.
|
||||
|
||||
Status: `200`; `404` mit `{"error":"Trainingsplan nicht gefunden"}`.
|
||||
|
||||
### `GET /api/plans/{plan_id}`
|
||||
|
||||
Antwort:
|
||||
|
||||
```json
|
||||
{
|
||||
"plan": { /* normalisierte veröffentlichte Planfassung */ },
|
||||
"tracker": { /* Tracker-Schema 7 */ },
|
||||
"analysis_catalog": { /* Wochen- und Gesamtstatus */ },
|
||||
"equivalence_guide": { /* Variantencluster und Fallback-FAQ */ },
|
||||
"capabilities": {"openai": true}
|
||||
}
|
||||
```
|
||||
|
||||
`plan` enthält ausschließlich vom Backend erzeugte Felder wie `source_file`, `source_hash`, `plan_id`, `schema_version`, `contract_version`, `compatibility`, `published_revision`, `published_at`, `name`, `title`, `subtitle`, `weeks`, `days`, `phases`, `phase_by_week`, `stages`, `exercise_catalog`, `front`, `training_format`.
|
||||
|
||||
`tracker` enthält mindestens `version`, `revision`, `source_file`, `source_hash`, `profile`, `progressions`, `sessions`, `week_statuses`, `created_at`, `updated_at`; für die GET-Antwort werden zusätzlich `source_changed`, `analysis_cache` und `analysis_state` zusammengesetzt.
|
||||
|
||||
Status: `200`; `404`; `400` bei nicht lesbarem Plan.
|
||||
|
||||
### `PUT /api/plans/{plan_id}/tracker`
|
||||
|
||||
Vollständiges Speichern, primär für Kompatibilität. Request entweder direkt Trackerobjekt oder:
|
||||
|
||||
```json
|
||||
{"expected_revision":7,"tracker":{}}
|
||||
```
|
||||
|
||||
Antwort:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"updated_at": "…",
|
||||
"revision": 8,
|
||||
"analysis_catalog": {},
|
||||
"analysis_state": {}
|
||||
}
|
||||
```
|
||||
|
||||
Status: `200`; `400` für ungültige Nutzdaten; `409` bei Revisionskonflikt; `413` über 5 MiB.
|
||||
|
||||
### `PATCH /api/plans/{plan_id}/tracker/session`
|
||||
|
||||
Der von der App verwendete Autosave-Vertrag:
|
||||
|
||||
```json
|
||||
{
|
||||
"expected_revision": 7,
|
||||
"profile": {"start_date":"","display_name":"","plan_notes":""},
|
||||
"week_statuses": {"1":{"status":"open"}},
|
||||
"session_key": "w01-d01",
|
||||
"session": {
|
||||
"status": "in_progress",
|
||||
"plan_id": "phase-1",
|
||||
"plan_revision": 1,
|
||||
"items": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`profile`, `week_statuses` und `session` sind jeweils optional. Ein Patch ersetzt die betreffende Session vollständig, nicht einzelne Felder innerhalb der Session.
|
||||
|
||||
Erlaubte Sessionstatus: `planned`, `in_progress`, `stopped`, `completed`.
|
||||
|
||||
Erlaubte Übungsstatus: `planned`, `completed`, `partial`, `skipped`. Bei `skipped` kann `skip_reason` gespeichert werden. Neue strukturierte Ergebnisse nutzen `result_data` Version 2:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 2,
|
||||
"mode": "reps|seconds|minutes",
|
||||
"laterality": "bilateral|unilateral",
|
||||
"sides_mode": "same|separate",
|
||||
"sets": 3,
|
||||
"weight_kg": 8.0,
|
||||
"values": [8,7,6],
|
||||
"left_values": [8,7,6],
|
||||
"right_values": [8,7,6]
|
||||
}
|
||||
```
|
||||
|
||||
Das Backend akzeptiert maximal 20 Sätze, Werte von 0 bis 100000 und genau ein gemeinsames Gewicht. Alte Freitextfelder `result`, `note`, `progression` bleiben erhalten.
|
||||
|
||||
Erfolg wie beim PUT. Konfliktantwort:
|
||||
|
||||
```json
|
||||
{
|
||||
"error":"Die Session wurde in einem anderen Tab oder Gerät geändert.",
|
||||
"code":"revision_conflict",
|
||||
"current_revision":8
|
||||
}
|
||||
```
|
||||
|
||||
Status: `409`. Die App darf dann nicht automatisch gegen Revision 8 überschreiben.
|
||||
|
||||
### `POST /api/plans/{plan_id}/analysis`
|
||||
|
||||
Request Woche:
|
||||
|
||||
```json
|
||||
{"scope":"week","week":1}
|
||||
```
|
||||
|
||||
Request Gesamtanalyse:
|
||||
|
||||
```json
|
||||
{"scope":"overall"}
|
||||
```
|
||||
|
||||
Unveränderte, bereits aktuelle Analyse: `200` mit `cached:true`, `selection`, `analysis_state`, `analysis_cache`, `analysis_catalog`.
|
||||
|
||||
Neuer Hintergrundjob: `202 Accepted` mit `cached:false`, `job`, `analysis_state`, `analysis_catalog`.
|
||||
|
||||
Fehler: `400` bei fehlender OpenAI-Konfiguration, ungültiger Woche oder fehlenden Daten. `409` wenn bereits eine Analyse läuft. `404` bei unbekanntem Plan.
|
||||
|
||||
### `GET /api/plans/{plan_id}/analysis/status`
|
||||
|
||||
Antwort enthält `analysis_state`, `analysis_cache`, `analysis_catalog`, `updated_at`. Die App pollt nur bei `analysis_state.status == "running"`.
|
||||
|
||||
Status: `200`; `400`; `404`.
|
||||
|
||||
### `GET /api/plans/{plan_id}/tracker/export`
|
||||
|
||||
Liefert die Sessiondatei als `application/json`; erzeugt bei Bedarf einen leeren Tracker. Status `200` oder `404`.
|
||||
|
||||
## Trainingsplan-Editor
|
||||
|
||||
Basis: `/plugins/trainingsplan`
|
||||
|
||||
### `GET /api/plans`
|
||||
|
||||
Antwort: `plans` mit `id`, `name`, `active`, `revision`, `published_revision`, `has_unpublished_changes`, plus `active`.
|
||||
|
||||
### `POST /api/plans`
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{"name":"Neue Phase","from":"optionale-plan-id","kind":"training"}
|
||||
```
|
||||
|
||||
`kind` kann technisch auch `recipe` sein; die Android-App erstellt ausschließlich Trainingspläne. Antwort ist der öffentliche Wrapper mit `id`, `plan_id`, `name`, `revision`, `published_revision`, `updated_at`, `published_at`, `has_unpublished_changes`, `tracked_sessions`, `config`.
|
||||
|
||||
### `GET /api/plans/{pid}`
|
||||
|
||||
Öffentlicher Wrapper des Entwurfs. Status `200` oder `404`.
|
||||
|
||||
### `POST /api/plans/{pid}/select`
|
||||
|
||||
Setzt den aktiven Plan. Status `200` oder `404`.
|
||||
|
||||
### `POST /api/plans/{pid}`
|
||||
|
||||
Speichert den Entwurf:
|
||||
|
||||
```json
|
||||
{"config":{},"expected_revision":4}
|
||||
```
|
||||
|
||||
Antwort: `ok`, öffentlicher Wrapper, `validation` mit `errors` und `warnings`.
|
||||
|
||||
Konflikt: `409` mit `code:"revision_conflict"` und `current_revision`.
|
||||
|
||||
### `POST /api/plans/{pid}/validate`
|
||||
|
||||
Request `{"config":{}}`. Antwort: `errors`, `warnings`, normalisierte `config`, `diff`. `diff` enthält Zähler und stabile-ID-basierte Änderungen für `days`, `rotations`, `exercises`, `progression_steps`, außerdem `changes`, `total_changes`, `training_format_changed`, `plan_meta_changed`, `tracked_sessions`.
|
||||
|
||||
### `POST /api/plans/{pid}/publish`
|
||||
|
||||
Request `{"expected_revision":4}`. Erfolgsantwort: `ok`, Wrapper, `diff`, `validation`.
|
||||
|
||||
Status `400` bei Validierungsfehlern; `409` bei Revisionskonflikt.
|
||||
|
||||
### `POST /api/plans/{pid}/rename`
|
||||
|
||||
Request `{"name":"…"}`. Antwort `id`, `name`, `revision`. Status `400` bei leerem Namen, `404` bei unbekanntem Plan.
|
||||
|
||||
### `DELETE /api/plans/{pid}`
|
||||
|
||||
Antwort `{"ok":true,"active":"…"}`. Status `400`, wenn der letzte Plan gelöscht werden soll; `404` bei unbekanntem Plan.
|
||||
|
||||
### `GET /api/plans/{pid}/proposals`
|
||||
|
||||
Liefert die Vorschlagsdatei unverändert oder `{}`. Vorschläge enthalten unter anderem `id`, `status`, `scope_key`, `analysis_type`, `week`, `target`, `target_label`, `target_exercise_id`, `target_progression_id`, `target_step_id`, `suggested_change`, `reason`, `condition`, `manual_step`, `analysis_created_at`.
|
||||
|
||||
### `POST /api/plans/{pid}/proposals/{proposal_id}`
|
||||
|
||||
Request `{"status":"open|accepted|rejected|reviewed"}`. Antwort `ok`, `proposals`. Ungültiger Status: `400`.
|
||||
|
||||
### Weitere vorhandene Endpunkte
|
||||
|
||||
`GET /api/config`, `POST /api/config`, `GET /api/default`, `POST /api/reset`, `GET /api/plans/{pid}/export`, `POST /api/plans/import`, `POST /api/pdf` existieren. Die native App verwendet für den Kernbetrieb die planbezogenen Endpunkte; sie bearbeitet niemals Serverdateien direkt.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Domänenmodell
|
||||
|
||||
## Vertragsversionen
|
||||
|
||||
- Plan: `schema_version = 3`
|
||||
- Trainingsvertrag: `contract_version = 2`
|
||||
- Ergebnisschema: Version 2
|
||||
- Tracker: `version = 7`
|
||||
- Ergebnisdaten: `version = 2`
|
||||
|
||||
## Identitäten
|
||||
|
||||
Dauerhaft stabil sind `plan_id`, Phasen-ID, Tages-ID, Rotations-ID, Übungsplatzierungs-ID, Bewegungs-`exercise_id`, `progression_id`, Progressions-ID und Stufen-ID. `legacy_id` wie `d1-r0-e0` dient ausschließlich der Migration alter Sessions. Neue Sessionitems werden mit der stabilen Übungsplatzierungs-ID gespeichert.
|
||||
|
||||
## Planlebenszyklus
|
||||
|
||||
Eine Plandatei besitzt `config` als veröffentlichte Fassung und `draft` als bearbeitbaren Entwurf. `revision` schützt Bearbeitungen, `published_revision` kennzeichnet die vom Tracker gelesene Fassung. Entwurf speichern und veröffentlichen sind getrennte Operationen.
|
||||
|
||||
## Planstruktur
|
||||
|
||||
Plan → Phasen, Tage, Progressionen, Übungsbibliothek und Trainingsformat.
|
||||
|
||||
Tag → Warm-up, Rotationen, Cooldown, Stretch.
|
||||
|
||||
Rotation → stabile ID, Bezeichnung, Übungen.
|
||||
|
||||
Übungsplatzierung → `id`, `legacy_id`, `exercise_id`, `progression_id`, Name, Hinweise, Standard-`result_schema`.
|
||||
|
||||
Progression → stabile ID/Key, Name, geordnete Stufen.
|
||||
|
||||
Progressionsstufe → ID, Name, optionale Phase, Bewegungscluster, Analysefaktor und optional überschreibendes `result_schema`.
|
||||
|
||||
## Ergebnisschema
|
||||
|
||||
Auflösungsreihenfolge:
|
||||
|
||||
1. manuelle Sessionanpassung,
|
||||
2. Schema der gewählten Progressionsstufe,
|
||||
3. Standardschema der Übung,
|
||||
4. automatische Fallback-Erkennung.
|
||||
|
||||
Felder: `mode` (`reps`, `seconds`, `minutes`, `none`, im Plan zusätzlich `auto`), `weight_mode` (`none`, `optional`, `required`), `laterality` (`bilateral`, `unilateral`), `sides_mode` (`same`, `separate`) sowie optional eine feste Satzanzahl.
|
||||
|
||||
## Tracker
|
||||
|
||||
Tracker → Profil, Sessions, Wochenstatus, Revision und Planbezug.
|
||||
|
||||
Session-Key: `wNN-dNN`. Sessionstatus: `planned`, `in_progress`, `stopped`, `completed`.
|
||||
|
||||
Sessionitemstatus: `planned`, `completed`, `partial`, `skipped`. `done` bleibt als Kompatibilitätsfeld erhalten und ist bei `completed` oder `partial` wahr.
|
||||
|
||||
`result_data` speichert eine Messart, gemeinsame oder getrennte Satzwerte und höchstens ein gemeinsames Gewicht. Unterschiedliche Gewichte pro Satz, RIR, RPE und Technikrating gehören bewusst nicht zum Vertrag.
|
||||
|
||||
## Analyse
|
||||
|
||||
`analysis_state` ist persistent und kann `idle`, `running`, `done` oder `error` sein. Der Cache enthält höchstens eine Analyse je Woche und genau eine Gesamtanalyse. Neuberechnung überschreibt dieselbe Datei. Es gibt keine Historie.
|
||||
|
||||
`analysis_catalog` kennzeichnet Wochen als `missing`, `stale` oder `current`; die Gesamtanalyse zusätzlich als `needs_weeks`.
|
||||
|
||||
## Vorschläge
|
||||
|
||||
Analysen erzeugen höchstens drei strukturierte Vorschläge. Ziel-IDs verbinden Vorschläge mit Übungen, Progressionen und Stufen. Die App darf Status ändern und zum Ziel navigieren, aber niemals automatisch den Plan mutieren.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Implementierungsplan
|
||||
|
||||
## Meilenstein 0
|
||||
|
||||
Backendcode, Mounts, Authentifizierung, Datenmodelle, Migrationen, Beispielpläne, alte Sessions, Analysen, Vorschläge und Web-UIs analysieren. Vertrag und Konfliktmodell dokumentieren.
|
||||
|
||||
## Meilenstein 1
|
||||
|
||||
Single-Activity-App, Compose/Material 3, Navigation, Hilt, Retrofit/OkHttp, Kotlin Serialization, Room, DataStore, Android-Keystore-Verschlüsselung, WorkManager und Verbindungstest.
|
||||
|
||||
## Meilenstein 2
|
||||
|
||||
Trackerplan laden, UI-Zustand pro Plan persistieren, Legacy-IDs migrieren, Sessionstatus und Übungsstatus abbilden, Ergebnisfelder aus Stufe/Übung/Fallback bestimmen, debounced Autosave und Offline-Queue implementieren.
|
||||
|
||||
## Meilenstein 3
|
||||
|
||||
Lokale Fortschrittskarten, Variantencluster/FAQ, Wochenabschluss, Analyseauswahl, `200`/`202`, persistente Jobanzeige und Polling.
|
||||
|
||||
## Meilenstein 4
|
||||
|
||||
Entwurfs-CRUD, strukturierte Planansicht, Result-Schema-Editor, Bibliothek, Validierung, Diff, Publish und manuell bestätigte KI-Vorschläge.
|
||||
|
||||
## Meilenstein 5
|
||||
|
||||
Unit-, Repository-, MockWebServer- und Compose-Tests mit allen gelieferten Fixtures; 360-dp-Prüfung; Light/Dark; Gradle-Test-, Lint- und Assemble-Läufe; ZIP/APK/Build- und Testbericht.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Offline- und Konfliktkonzept
|
||||
|
||||
## Cache
|
||||
|
||||
Room speichert die zuletzt erfolgreichen JSON-Antworten für Planlisten, normalisierte Trackerpläne, Editorentwürfe und Analysen. Der Cache ist lesbar, wenn das Netzwerk fehlt; er ist kein eigenständiger Serverdatenbestand.
|
||||
|
||||
## Ausstehende Mutationen
|
||||
|
||||
Sessionänderungen werden als vollständiger Session-Patch mit `expected_revision` gespeichert. Bei Transportfehlern wird exakt der gesendete JSON-Patch in einer FIFO-Queue abgelegt. WorkManager synchronisiert nur bei Netzwerkverbindung und in Erstellungsreihenfolge.
|
||||
|
||||
## Revisionskonflikt
|
||||
|
||||
HTTP `409` mit `code = revision_conflict` stoppt die Queue am konfliktbehafteten Eintrag. Der Eintrag bleibt erhalten und wird als Konflikt markiert. Die UI zeigt lokalen Patch und aktuelle Serverrevision. Es gibt kein automatisches Rebase und kein blindes Überschreiben.
|
||||
|
||||
Sichere Aktionen:
|
||||
|
||||
- Serverstand neu laden und lokale Änderung manuell erneut anwenden.
|
||||
- Konflikt-Patch nach bewusster Benutzerentscheidung verwerfen.
|
||||
- Keine automatische Änderung der `expected_revision`.
|
||||
|
||||
## Analysejobs
|
||||
|
||||
Ein laufender Job wird vom Server in `state.json` gespeichert. Die App verlässt sich nicht auf flüchtigen UI-Zustand, sondern liest nach Neustart `analysis_state` erneut. Solange `status == running`, bleibt der Startbutton deaktiviert und der Statusendpunkt wird gepollt.
|
||||
|
||||
## Serveränderungen
|
||||
|
||||
Die App schreibt nie direkt in Plan-, Session-, Analyse- oder Vorschlagsdateien. Sämtliche Mutationen laufen über die vorhandenen HTTP-Endpunkte.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Screen Map
|
||||
|
||||
## App-Shell
|
||||
|
||||
- Start/Verbindung: Server-URL, Benutzername, Passwort, Verbindungstest.
|
||||
- Hauptnavigation: Tracker, Planeditor, Einstellungen.
|
||||
- Material-3-Theme mit Light/Dark/System.
|
||||
|
||||
## Tracker
|
||||
|
||||
- Planauswahl.
|
||||
- Session-Ansicht mit Wochen- und Tageschips.
|
||||
- Sessionstatus starten, stoppen, fortsetzen, wieder öffnen, abschließen, zurücksetzen.
|
||||
- Warm-up, Rotationen, Übungen, Cooldown und Stretch.
|
||||
- Übungsstatus offen, erledigt, teilweise, übersprungen.
|
||||
- Progressionsauswahl und progressionsabhängiger Ergebnis-Editor.
|
||||
- Fortschritt: lokale Sessionkennzahlen, Wochenstatus, Analyseauswahl, Cache/Jobstatus.
|
||||
- Plan: veröffentlichte Struktur und Planrevision.
|
||||
- FAQ: Trainingsformat, Variantencluster und Backend-Fallbackregeln.
|
||||
|
||||
## Planeditor
|
||||
|
||||
- Planliste, Auswahl, neuer Plan, Umbenennen, Löschen.
|
||||
- Entwurfsübersicht und Metadaten.
|
||||
- Tage, Rotationen und Übungen mit stabilen IDs.
|
||||
- Ergebnisschema pro Übung.
|
||||
- Progressionsstufen und überschreibende Ergebnisschemata.
|
||||
- Übungsbibliothek.
|
||||
- Prüfung mit Fehlern/Warnungen.
|
||||
- Tracker-Vorschau und Änderungsübersicht.
|
||||
- Veröffentlichen.
|
||||
- Vorschlags-Postfach: prüfen, Ziel öffnen, angenommen/abgelehnt/geprüft markieren.
|
||||
|
||||
## Visuelle Referenz
|
||||
|
||||
In den gelieferten Archiven sind keine Bilddateien oder Screenshots enthalten. Als vorhandene visuelle Referenz wurden daher die mobile-first HTML/CSS-Oberflächen der beiden Plugins ausgewertet. Die native Umsetzung übernimmt Informationshierarchie und Funktionsgruppen, nicht DOM-Struktur oder Web-Komponenten.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Vollständige Quellenanalyse
|
||||
|
||||
Stand: 24. Juli 2026
|
||||
|
||||
## Untersuchte Lieferobjekte
|
||||
|
||||
| Archiv | Originaldateien | SHA-256 |
|
||||
|---|---:|---|
|
||||
| `01-boehmitools-current(1).zip` | 93 | `167d0d1b4670cbe6bb7cab10d2b84c315fde6dadc824cf3f7b9d6ccce3d61d1e` |
|
||||
| `02-training-tools-v2.0-bundle.zip` | 29 | `b2859ccb86a3915a86e3c64e157c9021f3b713b63b600360419e686f41c8dbee` |
|
||||
| `03-training-app-samples.zip` | 8 | `1634f3b83e98d5e5df7d1c56719dede13ed4cf1ce1517a3745cfee0384f93991` |
|
||||
|
||||
Alle enthaltenen Dateien wurden inventarisiert. Die technische Analyse umfasste insbesondere Host-Mounts und Basic Auth, beide Plugin-Backends, Storage- und Vertragsmodule, Plugin-Metadaten, Tests, HTML-Oberflächen, Migrationsdokumentation, beide Planwrapper, beide Legacy-Tracker, Wochen- und Gesamtanalyse sowie die Vorschlagsdatei. Die vollständige Dateiliste liegt unter `build-logs/source-files.txt`; die maschinenlesbare Datenauswertung unter `build-logs/source-audit.json`.
|
||||
|
||||
## Abgleich aktueller Serverstand gegen v2.0-Bundle
|
||||
|
||||
Die Quelltexte von `plugins/trainingsplan` und `plugins/trainingstracker` sind in beiden Lieferungen inhaltlich identisch. Im entpackten aktuellen Stand kamen durch lokale Python-Testläufe lediglich `__pycache__`-Dateien hinzu. Die beiden Trainingsplan-Dateien `phase-1.json` und `calihoss-newbie-8-wochen.json` sind ebenfalls identisch. Der vollständige Serverstand enthält zusätzlich vier Rezeptplan-Dateien, die nicht zum Umfang dieser Android-App gehören.
|
||||
|
||||
Die Host-App mountet die Plugins unter:
|
||||
|
||||
- `/plugins/trainingsplan`
|
||||
- `/plugins/trainingstracker`
|
||||
|
||||
Die App nutzt ausschließlich diese HTTP-Mounts. Es gibt keinen direkten Dateizugriff.
|
||||
|
||||
## Pläne und Vertragsversionen
|
||||
|
||||
Beide Planwrapper haben `revision = 1` und `published_revision = 1`. Die veröffentlichte Fassung liegt in `config`, der Entwurf in `draft`.
|
||||
|
||||
| Plan | Modus | Wochen | Tage | Blöcke | Platzierungen | Progressionen | Stufen | Katalog |
|
||||
|---|---|---:|---:|---:|---:|---:|---:|---:|
|
||||
| `phase-1` | `tabata` | 8 | 6 | 12 | 48 | 31 | 248 | 40 |
|
||||
| `calihoss-newbie-8-wochen` | `sets_reps` | 8 | 6 | 12 | 48 | 31 | 248 | 27 |
|
||||
|
||||
Jede der 96 Übungsplatzierungen besitzt ein explizites `result_schema`; alle 496 Progressionsstufen ebenfalls. Tages-, Rotations-, Platzierungs-, Progressions- und Stufen-IDs sind innerhalb ihres jeweiligen Entitätstyps vollständig und eindeutig. Bewegungs-IDs werden absichtlich über mehrere Platzierungen hinweg wiederverwendet und dürfen daher nicht global eindeutig behandelt werden.
|
||||
|
||||
Die Beschreibung verwendet die Begriffe `plan_schema` und `training_contract`. In den Plan-JSONs heißen die tatsächlichen Felder `schema_version = 3` und `contract_version = 2`. Der Tracker-Health-Endpunkt meldet dieselben Verträge als `versions.plan_schema = 3` und `versions.training_contract = 2`. Die Android-Domäne bildet diese Namensdifferenz explizit ab, statt ein zusätzliches JSON-Feld zu erfinden.
|
||||
|
||||
## Legacy-Sessions und Migration
|
||||
|
||||
| Sessiondatei | gelieferte Version | Sessions | Items | positionsbasierte Übungskeys | Freitextergebnisse | `result_data` v2 |
|
||||
|---|---:|---:|---:|---:|---:|---:|
|
||||
| `phase-1.json` | 5 | 10 | 197 | 80 | 40 | 0 |
|
||||
| `calihoss-newbie-8-wochen.json` | 1 | 6 | 88 | 48 | 0 | 0 |
|
||||
|
||||
Die Testdaten erzwingen damit beide Migrationspfade:
|
||||
|
||||
1. `dN-rN-eN` wird ausschließlich anhand des im Plan mitgelieferten `legacy_id` in die stabile Platzierungs-ID überführt.
|
||||
2. Vorhandene Freitextergebnisse werden lokal in `result_data` Version 2 geparst, ohne den Originaltext vor einer Benutzeränderung zu verwerfen.
|
||||
|
||||
Warm-up-, Cooldown- und Stretch-Items besitzen eigene Sequenzschlüssel und werden nicht als Übungsplatzierungen migriert.
|
||||
|
||||
## Analyse und Vorschläge
|
||||
|
||||
Geliefert sind genau eine Wochenanalyse für Woche 1 und genau eine Gesamtanalyse. Beide besitzen `status = success` und die Antwortbereiche `headline`, `summary`, `data_quality`, `metrics`, `exercise_updates`, `plan_adjustments`, `warnings` und `conclusion`.
|
||||
|
||||
Die Vorschlagsdatei hat Version 2, gehört zu `phase-1`, basiert auf `published_revision = 1` und enthält zwei offene Vorschläge. Zielreferenzen verwenden stabile Bewegungs-, Progressions- und Stufen-IDs. Der Editor aktualisiert nur den Vorschlagsstatus und navigiert zum Ziel. Er überträgt keinen Vorschlag automatisch in den Entwurf.
|
||||
|
||||
## API- und Konfliktbefunde
|
||||
|
||||
- Hostweite Basic Auth wird vor den Plugin-Routen ausgewertet; nur der exakte Hostpfad `/api/health` ist ausgenommen.
|
||||
- Der Session-Patch ersetzt eine komplette Session und prüft die globale Tracker-`revision` über `expected_revision`.
|
||||
- Ein `409 revision_conflict` liefert `current_revision`; es existiert kein serverseitiger Merge-Endpunkt.
|
||||
- Analyse-POST liefert `200` für einen aktuellen Cachetreffer und `202` für einen neu gestarteten Hintergrundjob.
|
||||
- Der laufende Analysezustand ist serverseitig persistent und wird über den Statusendpunkt gelesen.
|
||||
- Planentwurf und Veröffentlichung haben getrennte Revisionen und getrennte Endpunkte.
|
||||
- Der Server erstellt beim Planlöschen ein Backup. Die Android-App verlässt sich dennoch ausschließlich auf die API und kennt keinen Backup-Dateipfad.
|
||||
|
||||
## Visuelle Referenz
|
||||
|
||||
Keines der drei Archive enthält PNG-, JPEG-, WebP- oder andere Screenshotdateien. Das ist ein Widerspruch zur Aufgabenbeschreibung, die UI-Screenshots als mitgelieferte Referenz nennt. Für die Umsetzung wurden deshalb die beiden mobilen HTML/CSS-Oberflächen vollständig als visuelle und funktionale Referenz ausgewertet. Die native App übernimmt Informationshierarchie, Begriffe und Arbeitsabläufe, nicht DOM-Struktur oder Web-Komponenten.
|
||||
|
||||
## Bewusst nicht übernommene Web-Funktionen
|
||||
|
||||
Der `phase-1`-Plan deklariert ein Tabata-Format. Entsprechend der App-Vorgabe wird dieser Vertrag angezeigt und für Ergebnisschemata berücksichtigt, aber es wird kein Tabata- oder Trainingstimer implementiert. Ebenso fehlen bewusst RIR, RPE, Technikrating, unterschiedliche Gewichte pro Satz, WebView und direkte OpenAI-Kommunikation.
|
||||
|
||||
## Verifikation des gelieferten Backends
|
||||
|
||||
Die vorhandenen Plugin-Testsets wurden lokal ausgeführt:
|
||||
|
||||
```text
|
||||
11 passed, 8 skipped
|
||||
```
|
||||
|
||||
Das vollständige Protokoll liegt unter `build-logs/backend-plugin-tests.log`.
|
||||
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
@@ -0,0 +1,74 @@
|
||||
[versions]
|
||||
agp = "8.9.1"
|
||||
kotlin = "2.1.20"
|
||||
composeBom = "2025.05.01"
|
||||
activityCompose = "1.10.1"
|
||||
lifecycle = "2.9.0"
|
||||
navigation = "2.9.0"
|
||||
hilt = "2.56.2"
|
||||
hiltNavigation = "1.2.0"
|
||||
room = "2.7.1"
|
||||
datastore = "1.1.7"
|
||||
work = "2.10.1"
|
||||
retrofit = "2.11.0"
|
||||
okhttp = "4.12.0"
|
||||
serialization = "1.8.1"
|
||||
coreKtx = "1.16.0"
|
||||
securityCrypto = "1.1.0-alpha06"
|
||||
junit = "4.13.2"
|
||||
androidxJunit = "1.2.1"
|
||||
espresso = "3.6.1"
|
||||
testRunner = "1.6.2"
|
||||
testCore = "1.6.1"
|
||||
mockwebserver = "4.12.0"
|
||||
coroutines = "1.10.2"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
|
||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
|
||||
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" }
|
||||
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
|
||||
compose-ui = { module = "androidx.compose.ui:ui" }
|
||||
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||
compose-material3 = { module = "androidx.compose.material3:material3" }
|
||||
compose-icons = { module = "androidx.compose.material:material-icons-extended" }
|
||||
compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
|
||||
compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
|
||||
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
||||
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
|
||||
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigation" }
|
||||
hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltNavigation" }
|
||||
hilt-work-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltNavigation" }
|
||||
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
|
||||
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
|
||||
room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
|
||||
room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
|
||||
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
||||
work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
|
||||
work-testing = { module = "androidx.work:work-testing", version.ref = "work" }
|
||||
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
|
||||
retrofit-kotlinx = { module = "com.squareup.retrofit2:converter-kotlinx-serialization", version.ref = "retrofit" }
|
||||
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
||||
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
|
||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }
|
||||
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||
junit = { module = "junit:junit", version.ref = "junit" }
|
||||
androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" }
|
||||
espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" }
|
||||
androidx-test-runner = { module = "androidx.test:runner", version.ref = "testRunner" }
|
||||
androidx-test-core-ktx = { module = "androidx.test:core-ktx", version.ref = "testCore" }
|
||||
hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" }
|
||||
mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "mockwebserver" }
|
||||
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
|
||||
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
package org.gradle.wrapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.net.URLConnection;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
/**
|
||||
* Small auditable Gradle wrapper bootstrap used because the official wrapper JAR
|
||||
* could not be generated in the build container. It downloads the configured
|
||||
* distribution, validates SHA-256, extracts it with zip-slip protection and
|
||||
* starts Gradle in the project directory.
|
||||
*/
|
||||
public final class GradleWrapperMain {
|
||||
private GradleWrapperMain() {}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
Path jar = Paths.get(GradleWrapperMain.class.getProtectionDomain().getCodeSource().getLocation().toURI());
|
||||
Path projectRoot = jar.getParent().getParent().getParent();
|
||||
Path propertiesPath = jar.resolveSibling("gradle-wrapper.properties");
|
||||
Properties properties = new Properties();
|
||||
try (InputStream input = Files.newInputStream(propertiesPath)) {
|
||||
properties.load(input);
|
||||
}
|
||||
|
||||
URI distributionUri = URI.create(required(properties, "distributionUrl"));
|
||||
String archiveName = Paths.get(distributionUri.getPath()).getFileName().toString();
|
||||
String distributionName = archiveName.replaceFirst("-(bin|all)\\.zip$", "");
|
||||
Path distributionDirectory = gradleUserHome(args)
|
||||
.resolve(properties.getProperty("distributionPath", "wrapper/dists"))
|
||||
.resolve(distributionName)
|
||||
.resolve(archiveName);
|
||||
Path archive = distributionDirectory.resolve(archiveName);
|
||||
Path marker = distributionDirectory.resolve(".installed");
|
||||
Files.createDirectories(distributionDirectory);
|
||||
|
||||
if (!Files.exists(marker)) {
|
||||
if (!Files.exists(archive)) {
|
||||
download(distributionUri.toURL(), archive, intProperty(properties, "networkTimeout", 10_000));
|
||||
}
|
||||
String expectedSha = properties.getProperty("distributionSha256Sum", "").trim();
|
||||
if (!expectedSha.isEmpty()) {
|
||||
verifySha256(archive, expectedSha);
|
||||
}
|
||||
unzip(archive, distributionDirectory);
|
||||
Files.writeString(marker, "ok\n", StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
|
||||
}
|
||||
|
||||
Path gradleHome;
|
||||
try (Stream<Path> children = Files.list(distributionDirectory)) {
|
||||
gradleHome = children
|
||||
.filter(Files::isDirectory)
|
||||
.filter(path -> path.getFileName().toString().startsWith("gradle-"))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IOException("Extracted Gradle directory not found in " + distributionDirectory));
|
||||
}
|
||||
|
||||
boolean windows = System.getProperty("os.name", "").toLowerCase().contains("win");
|
||||
Path executable = gradleHome.resolve("bin").resolve(windows ? "gradle.bat" : "gradle");
|
||||
if (!windows) executable.toFile().setExecutable(true);
|
||||
List<String> command = new ArrayList<>();
|
||||
command.add(executable.toString());
|
||||
for (String argument : args) command.add(argument);
|
||||
Process process = new ProcessBuilder(command)
|
||||
.directory(projectRoot.toFile())
|
||||
.inheritIO()
|
||||
.start();
|
||||
System.exit(process.waitFor());
|
||||
}
|
||||
|
||||
private static Path gradleUserHome(String[] args) {
|
||||
for (int index = 0; index < args.length - 1; index++) {
|
||||
if ("-g".equals(args[index]) || "--gradle-user-home".equals(args[index])) {
|
||||
return Paths.get(args[index + 1]).toAbsolutePath();
|
||||
}
|
||||
}
|
||||
String configured = System.getenv("GRADLE_USER_HOME");
|
||||
return configured == null || configured.isBlank()
|
||||
? Paths.get(System.getProperty("user.home"), ".gradle")
|
||||
: Paths.get(configured).toAbsolutePath();
|
||||
}
|
||||
|
||||
private static void download(URL initialUrl, Path destination, int timeoutMillis) throws Exception {
|
||||
URL url = initialUrl;
|
||||
for (int redirect = 0; redirect < 8; redirect++) {
|
||||
URLConnection connection = url.openConnection();
|
||||
connection.setConnectTimeout(timeoutMillis);
|
||||
connection.setReadTimeout(Math.max(timeoutMillis, 30_000));
|
||||
connection.setRequestProperty("User-Agent", "boehmitools-training-gradle-wrapper");
|
||||
if (connection instanceof HttpURLConnection http) {
|
||||
http.setInstanceFollowRedirects(false);
|
||||
int status = http.getResponseCode();
|
||||
if (status >= 300 && status < 400) {
|
||||
String location = http.getHeaderField("Location");
|
||||
if (location == null) throw new IOException("Redirect without Location from " + url);
|
||||
url = url.toURI().resolve(location).toURL();
|
||||
http.disconnect();
|
||||
continue;
|
||||
}
|
||||
if (status >= 400) throw new IOException("HTTP " + status + " while downloading " + url);
|
||||
}
|
||||
Path partial = destination.resolveSibling(destination.getFileName() + ".part");
|
||||
try (InputStream input = connection.getInputStream(); OutputStream output = Files.newOutputStream(partial)) {
|
||||
input.transferTo(output);
|
||||
}
|
||||
Files.move(partial, destination, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
return;
|
||||
}
|
||||
throw new IOException("Too many redirects while downloading " + initialUrl);
|
||||
}
|
||||
|
||||
private static void verifySha256(Path file, String expected) throws Exception {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
try (InputStream input = Files.newInputStream(file)) {
|
||||
byte[] buffer = new byte[64 * 1024];
|
||||
for (int read; (read = input.read(buffer)) >= 0;) {
|
||||
if (read > 0) digest.update(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
String actual = HexFormat.of().formatHex(digest.digest());
|
||||
if (!actual.equalsIgnoreCase(expected)) {
|
||||
Files.deleteIfExists(file);
|
||||
throw new IOException("Gradle distribution SHA-256 mismatch: expected " + expected + ", got " + actual);
|
||||
}
|
||||
}
|
||||
|
||||
private static void unzip(Path archive, Path destination) throws IOException {
|
||||
try (ZipInputStream zip = new ZipInputStream(Files.newInputStream(archive))) {
|
||||
for (ZipEntry entry; (entry = zip.getNextEntry()) != null;) {
|
||||
Path target = destination.resolve(entry.getName()).normalize();
|
||||
if (!target.startsWith(destination.normalize())) {
|
||||
throw new IOException("Unsafe ZIP entry: " + entry.getName());
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
Files.createDirectories(target);
|
||||
} else {
|
||||
Files.createDirectories(target.getParent());
|
||||
Files.copy(zip, target, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
zip.closeEntry();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String required(Properties properties, String key) {
|
||||
String value = properties.getProperty(key);
|
||||
if (value == null || value.isBlank()) throw new IllegalArgumentException("Missing " + key);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static int intProperty(Properties properties, String key, int fallback) {
|
||||
try {
|
||||
return Integer.parseInt(properties.getProperty(key, Integer.toString(fallback)));
|
||||
} catch (NumberFormatException ignored) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
f397b287023acdba1e9f6fc5ea72d22dd63669d59ed4a289a29b1a76eee151c6
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,9 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
distributionSha256Sum=f397b287023acdba1e9f6fc5ea72d22dd63669d59ed4a289a29b1a76eee151c6
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
APP_HOME=$(cd "${0%/*}" && pwd -P)
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
exec java -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
@rem Gradle wrapper
|
||||
@java -classpath "%~dp0\gradle\wrapper\gradle-wrapper.jar" org.gradle.wrapper.GradleWrapperMain %*
|
||||
@@ -0,0 +1,16 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
rootProject.name = "boehmitools Training"
|
||||
include(":app")
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
errors: list[str] = []
|
||||
checks: list[str] = []
|
||||
|
||||
def require(condition: bool, message: str) -> None:
|
||||
(checks if condition else errors).append(message)
|
||||
|
||||
app_gradle = (ROOT / "app/build.gradle.kts").read_text()
|
||||
manifest = (ROOT / "app/src/main/AndroidManifest.xml").read_text()
|
||||
network = (ROOT / "app/src/main/res/xml/network_security_config.xml").read_text()
|
||||
source = "\n".join(p.read_text(errors="replace") for p in (ROOT / "app/src/main").rglob("*.*") if p.is_file())
|
||||
api = (ROOT / "app/src/main/java/de/drazz/boehmitools/training/data/network/Api.kt").read_text()
|
||||
|
||||
require('applicationId = "de.drazz.boehmitools.training"' in app_gradle, "Application ID stimmt")
|
||||
require('minSdk = 26' in app_gradle, "Minimum SDK 26")
|
||||
require('compileSdk = 35' in app_gradle, "Compile SDK 35")
|
||||
require('android:usesCleartextTraffic="false"' in manifest, "Release-Manifest verbietet Klartext")
|
||||
require('cleartextTrafficPermitted="false"' in network, "Release Network Security verbietet Klartext")
|
||||
require('https://tools.d-razz.de/' in source, "Standardserver ist gesetzt")
|
||||
require('WebView' not in source, "Keine WebView")
|
||||
require('api.openai.com' not in source.lower(), "Keine direkte OpenAI-URL")
|
||||
require('sk-' not in source, "Kein erkennbarer OpenAI-Key")
|
||||
require('Credentials.basic' in api, "Basic Auth über OkHttp")
|
||||
for route in [
|
||||
'tracker/session', '/analysis', '/analysis/status',
|
||||
'trainingsplan/api/plans/{planId}/validate',
|
||||
'trainingsplan/api/plans/{planId}/publish',
|
||||
'proposals/{proposalId}',
|
||||
]:
|
||||
require(route in api, f"API-Route vorhanden: {route}")
|
||||
|
||||
fixtures = ROOT / "app/src/test/resources/samples"
|
||||
for name in ["phase-1", "calihoss-newbie-8-wochen"]:
|
||||
wrapper = json.loads((fixtures / f"plans/{name}.json").read_text())
|
||||
config = wrapper["config"]
|
||||
require(config.get("schema_version") == 3, f"{name}: schema_version 3")
|
||||
require(config.get("contract_version") == 2, f"{name}: contract_version 2")
|
||||
groups = {"days": [], "rotations": [], "placements": [], "steps": []}
|
||||
for day in config.get("days", []):
|
||||
groups["days"].append(day.get("id"))
|
||||
for rotation in day.get("rotations", []):
|
||||
groups["rotations"].append(rotation.get("id"))
|
||||
for exercise in rotation.get("exercises", []):
|
||||
groups["placements"].append(exercise.get("id"))
|
||||
require(bool(re.fullmatch(r"d\d+-r\d+-e\d+", exercise.get("legacy_id", ""))), f"{name}: Legacy-ID gültig")
|
||||
require(bool(exercise.get("result_schema")), f"{name}: Übungsschema vorhanden")
|
||||
for stage in config.get("stages", {}).values():
|
||||
for step in stage.get("steps", []):
|
||||
groups["steps"].append(step.get("id"))
|
||||
require(bool(step.get("result_schema")), f"{name}: Stufenschema vorhanden")
|
||||
for label, ids in groups.items():
|
||||
require(all(ids), f"{name}: {label} vollständig")
|
||||
require(len(ids) == len(set(ids)), f"{name}: {label} eindeutig")
|
||||
|
||||
for name in ["phase-1", "calihoss-newbie-8-wochen"]:
|
||||
tracker = json.loads((fixtures / f"sessions/{name}.json").read_text())
|
||||
require(1 <= int(tracker.get("version", 0)) <= 7, f"{name}: Legacy-Tracker unterstützt")
|
||||
require(bool(tracker.get("sessions")), f"{name}: Sessions vorhanden")
|
||||
|
||||
print(f"PASS: {len(checks)} Prüfungen")
|
||||
if errors:
|
||||
print(f"FAIL: {len(errors)} Prüfungen")
|
||||
for error in errors:
|
||||
print(f"- {error}")
|
||||
raise SystemExit(1)
|
||||
Reference in New Issue
Block a user