chore: initial import

This commit is contained in:
2026-07-24 21:37:03 +02:00
commit 6b4ed9bc36
111 changed files with 95131 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
.gradle/
.idea/
local.properties
**/build/
*.iml
*.jks
*.keystore
*.properties.local
captures/
.externalNativeBuild/
.cxx/
+9
View File
@@ -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.
+67
View File
@@ -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.
+26
View File
@@ -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
```
+9
View File
@@ -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.
+14
View File
@@ -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`.
+125
View File
@@ -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.
+101
View File
@@ -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)
}
+3
View File
@@ -0,0 +1,3 @@
-keepattributes Signature,*Annotation*
-keep class kotlinx.serialization.** { *; }
-dontwarn javax.annotation.**
View File
@@ -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)
}
}
@@ -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>
+35
View File
@@ -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)
}
}
}
}
}
@@ -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>
@@ -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 89 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
+16
View File
@@ -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
+22
View File
@@ -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
+22
View File
@@ -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
+20
View File
@@ -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
+11
View File
@@ -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." }
+148
View File
@@ -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": []
}
+11
View File
@@ -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
+130
View File
@@ -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
+2
View File
@@ -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
+8
View File
@@ -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
}
+280
View File
@@ -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.
+62
View File
@@ -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.
+25
View File
@@ -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.
+27
View File
@@ -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.
+36
View File
@@ -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.
+85
View File
@@ -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`.
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
+74
View File
@@ -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" }
@@ -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;
}
}
}
+1
View File
@@ -0,0 +1 @@
f397b287023acdba1e9f6fc5ea72d22dd63669d59ed4a289a29b1a76eee151c6
Binary file not shown.
+9
View File
@@ -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
Vendored Executable
+4
View File
@@ -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 "$@"
+2
View File
@@ -0,0 +1,2 @@
@rem Gradle wrapper
@java -classpath "%~dp0\gradle\wrapper\gradle-wrapper.jar" org.gradle.wrapper.GradleWrapperMain %*
+16
View File
@@ -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")
+72
View File
@@ -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)