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
+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