chore: initial import
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
package de.drazz.boehmitools.training
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.test.runner.AndroidJUnitRunner
|
||||
import dagger.hilt.android.testing.HiltTestApplication
|
||||
|
||||
class HiltTestRunner : AndroidJUnitRunner() {
|
||||
override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application {
|
||||
return super.newApplication(cl, HiltTestApplication::class.java.name, context)
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package de.drazz.boehmitools.training.ui
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertWidthIsAtMost
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class CompactEditorUiTest {
|
||||
@get:Rule val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun longExerciseNamesWrapWithin360Dp() {
|
||||
compose.setContent {
|
||||
MaterialTheme {
|
||||
Column(Modifier.width(360.dp).testTag("root")) {
|
||||
Text(
|
||||
"Sehr langer Übungsname mit Progressionshinweis und mehreren Ergebniswerten, der sicher umbrechen muss",
|
||||
modifier = Modifier.fillMaxWidth().testTag("long-name"),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = "8/8/7 Wiederholungen je Seite bei optionalem Gewicht",
|
||||
onValueChange = {},
|
||||
modifier = Modifier.fillMaxWidth().testTag("field"),
|
||||
label = { Text("Strukturiertes Ergebnis") },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
compose.onNodeWithTag("long-name").assertIsDisplayed().assertWidthIsAtMost(360.dp)
|
||||
compose.onNodeWithTag("field").assertIsDisplayed().assertWidthIsAtMost(360.dp)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:networkSecurityConfig="@xml/network_security_config_debug"
|
||||
android:usesCleartextTraffic="true" />
|
||||
</manifest>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true">
|
||||
<trust-anchors><certificates src="system" /></trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:name=".TrainingApplication"
|
||||
android:allowBackup="false"
|
||||
android:icon="@drawable/ic_launcher_foreground"
|
||||
android:label="boehmitools Training"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.BoehmitoolsTraining"
|
||||
android:usesCleartextTraffic="false">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.startup.InitializationProvider"
|
||||
android:authorities="${applicationId}.androidx-startup"
|
||||
android:exported="false"
|
||||
tools:node="merge">
|
||||
<meta-data
|
||||
android:name="androidx.work.WorkManagerInitializer"
|
||||
tools:node="remove" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.drazz.boehmitools.training
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import de.drazz.boehmitools.training.ui.AppRoot
|
||||
import de.drazz.boehmitools.training.ui.AppViewModel
|
||||
import de.drazz.boehmitools.training.ui.theme.TrainingTheme
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
val vm: AppViewModel = hiltViewModel()
|
||||
val settings by vm.settings.collectAsStateWithLifecycle()
|
||||
TrainingTheme(settings.theme) {
|
||||
AppRoot(settings = settings, onTheme = vm::setTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.drazz.boehmitools.training
|
||||
|
||||
import android.app.Application
|
||||
import androidx.hilt.work.HiltWorkerFactory
|
||||
import androidx.work.Configuration
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp
|
||||
class TrainingApplication : Application(), Configuration.Provider {
|
||||
@Inject lateinit var workerFactory: HiltWorkerFactory
|
||||
|
||||
override val workManagerConfiguration: Configuration
|
||||
get() = Configuration.Builder()
|
||||
.setWorkerFactory(workerFactory)
|
||||
.build()
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.drazz.boehmitools.training.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.work.WorkManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import de.drazz.boehmitools.training.data.local.TrainingDatabase
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun json(): Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
encodeDefaults = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun database(@ApplicationContext context: Context): TrainingDatabase =
|
||||
Room.databaseBuilder(context, TrainingDatabase::class.java, "training.db")
|
||||
.build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun workManager(@ApplicationContext context: Context): WorkManager = WorkManager.getInstance(context)
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
package de.drazz.boehmitools.training.domain
|
||||
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
fun JsonObject.string(key: String, fallback: String = ""): String = this[key]?.jsonPrimitive?.contentOrNull ?: fallback
|
||||
fun JsonObject.int(key: String, fallback: Int = 0): Int = this[key]?.jsonPrimitive?.intOrNull ?: fallback
|
||||
fun JsonObject.bool(key: String, fallback: Boolean = false): Boolean = this[key]?.jsonPrimitive?.booleanOrNull ?: fallback
|
||||
fun JsonObject.obj(key: String): JsonObject = this[key] as? JsonObject ?: JsonObject(emptyMap())
|
||||
fun JsonObject.array(key: String): JsonArray = this[key] as? JsonArray ?: JsonArray(emptyList())
|
||||
|
||||
fun JsonObject.updated(key: String, value: JsonElement?): JsonObject = JsonObject(toMutableMap().apply {
|
||||
if (value == null) remove(key) else put(key, value)
|
||||
})
|
||||
|
||||
fun JsonObject.updatedString(key: String, value: String): JsonObject = updated(key, JsonPrimitive(value))
|
||||
fun JsonObject.updatedInt(key: String, value: Int): JsonObject = updated(key, JsonPrimitive(value))
|
||||
fun JsonObject.updatedBool(key: String, value: Boolean): JsonObject = updated(key, JsonPrimitive(value))
|
||||
|
||||
data class ResultSchemaUi(
|
||||
val mode: String = "reps",
|
||||
val weightMode: String = "none",
|
||||
val laterality: String = "bilateral",
|
||||
val sidesMode: String = "same",
|
||||
val defaultSets: Int = 3,
|
||||
val lockedSets: Boolean = false,
|
||||
val source: String = "fallback",
|
||||
)
|
||||
|
||||
data class ProgressionStepUi(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val resultSchema: ResultSchemaUi?,
|
||||
val raw: JsonObject,
|
||||
)
|
||||
|
||||
data class ProgressionUi(
|
||||
val id: String,
|
||||
val key: String,
|
||||
val name: String,
|
||||
val steps: List<ProgressionStepUi>,
|
||||
val raw: JsonObject,
|
||||
)
|
||||
|
||||
data class ExerciseUi(
|
||||
val id: String,
|
||||
val legacyId: String,
|
||||
val exerciseId: String,
|
||||
val progressionId: String,
|
||||
val name: String,
|
||||
val cue: String,
|
||||
val resultSchema: ResultSchemaUi?,
|
||||
val raw: JsonObject,
|
||||
)
|
||||
|
||||
data class RotationUi(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val exercises: List<ExerciseUi>,
|
||||
val raw: JsonObject,
|
||||
)
|
||||
|
||||
data class DayUi(
|
||||
val id: String,
|
||||
val num: Int,
|
||||
val focus: String,
|
||||
val light: Boolean,
|
||||
val warmup: List<String>,
|
||||
val cooldown: List<String>,
|
||||
val stretch: List<String>,
|
||||
val rotations: List<RotationUi>,
|
||||
val raw: JsonObject,
|
||||
)
|
||||
|
||||
data class PlanUi(
|
||||
val sourceFile: String,
|
||||
val planId: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val weeks: Int,
|
||||
val publishedRevision: Int,
|
||||
val days: List<DayUi>,
|
||||
val progressions: Map<String, ProgressionUi>,
|
||||
val trainingFormat: JsonObject,
|
||||
val exerciseCatalog: JsonObject,
|
||||
val front: JsonObject,
|
||||
val raw: JsonObject,
|
||||
)
|
||||
|
||||
fun parsePlan(plan: JsonObject): PlanUi {
|
||||
val progressions = parseProgressions(plan["stages"])
|
||||
val prepost = plan.obj("prepost")
|
||||
val meta = plan.obj("meta")
|
||||
val days = plan.array("days").mapNotNull { it as? JsonObject }.map { day ->
|
||||
val dayNum = day.int("num", 1)
|
||||
val rotations = day.array("rotations").mapNotNull { it as? JsonObject }.mapIndexed { ri, rotation ->
|
||||
val exercises = rotation.array("exercises").mapNotNull { it as? JsonObject }.mapIndexed { ei, exercise ->
|
||||
ExerciseUi(
|
||||
id = exercise.string("id", "exercise-$dayNum-$ri-$ei"),
|
||||
legacyId = exercise.string("legacy_id", "d$dayNum-r$ri-e$ei"),
|
||||
exerciseId = exercise.string("exercise_id"),
|
||||
progressionId = exercise.string("progression_id", exercise.string("key")),
|
||||
name = exercise.string("name", "Übung ${ei + 1}"),
|
||||
cue = exercise.string("cue"),
|
||||
resultSchema = parseResultSchema(exercise["result_schema"] as? JsonObject, "exercise"),
|
||||
raw = exercise,
|
||||
)
|
||||
}
|
||||
RotationUi(
|
||||
id = rotation.string("id", "rotation-$dayNum-$ri"),
|
||||
label = rotation.string("label", "Block ${ri + 1}"),
|
||||
exercises = exercises,
|
||||
raw = rotation,
|
||||
)
|
||||
}
|
||||
val legacyPrePost = prepost.obj(dayNum.toString())
|
||||
fun sequence(name: String): List<String> {
|
||||
val normalized = day.obj(name).array("items").mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||
if (normalized.isNotEmpty()) return normalized
|
||||
return legacyPrePost.string(name)
|
||||
.split(Regex("""<br\s*/?>|\n""", RegexOption.IGNORE_CASE))
|
||||
.map { it.replace(Regex("<[^>]+>"), "").trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
}
|
||||
DayUi(
|
||||
id = day.string("id", "day-$dayNum"),
|
||||
num = dayNum,
|
||||
focus = day.string("focus", "Tag $dayNum"),
|
||||
light = day.bool("light"),
|
||||
warmup = sequence("warmup"),
|
||||
cooldown = sequence("cooldown"),
|
||||
stretch = sequence("stretch"),
|
||||
rotations = rotations,
|
||||
raw = day,
|
||||
)
|
||||
}.sortedBy { it.num }
|
||||
return PlanUi(
|
||||
sourceFile = plan.string("source_file"),
|
||||
planId = plan.string("plan_id"),
|
||||
title = plan.string("title", meta.string("title", plan.string("name"))),
|
||||
subtitle = plan.string("subtitle", meta.string("subtitle")),
|
||||
weeks = plan.int("weeks", meta.int("weeks", 1)).coerceAtLeast(1),
|
||||
publishedRevision = plan.int("published_revision", 1),
|
||||
days = days,
|
||||
progressions = progressions,
|
||||
trainingFormat = plan.obj("training_format"),
|
||||
exerciseCatalog = plan.obj("exercise_catalog"),
|
||||
front = plan.obj("front"),
|
||||
raw = plan,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseProgressions(element: JsonElement?): Map<String, ProgressionUi> {
|
||||
val entries: List<Pair<String, JsonObject>> = when (element) {
|
||||
is JsonArray -> element.mapNotNull { value ->
|
||||
val stage = value as? JsonObject ?: return@mapNotNull null
|
||||
val key = stage.string("key", stage.string("id"))
|
||||
key to stage
|
||||
}
|
||||
is JsonObject -> element.mapNotNull { (mapKey, value) ->
|
||||
val stage = value as? JsonObject ?: return@mapNotNull null
|
||||
mapKey to stage
|
||||
}
|
||||
else -> emptyList()
|
||||
}
|
||||
return entries.associate { (mapKey, stage) ->
|
||||
val key = stage.string("key", mapKey.ifBlank { stage.string("id") })
|
||||
val steps = stage.array("steps").mapNotNull { it as? JsonObject }.map { step ->
|
||||
ProgressionStepUi(
|
||||
id = step.string("id"),
|
||||
name = step.string("name"),
|
||||
resultSchema = parseResultSchema(step["result_schema"] as? JsonObject, "progression"),
|
||||
raw = step,
|
||||
)
|
||||
}
|
||||
key to ProgressionUi(
|
||||
id = stage.string("id", key),
|
||||
key = key,
|
||||
name = stage.string("name", key),
|
||||
steps = steps,
|
||||
raw = stage,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun parseResultSchema(raw: JsonObject?, source: String): ResultSchemaUi? {
|
||||
if (raw == null || raw.isEmpty()) return null
|
||||
val mode = raw.string("mode", "auto")
|
||||
val normalizedMode = if (mode in setOf("reps", "seconds", "minutes", "none", "auto")) mode else "auto"
|
||||
return ResultSchemaUi(
|
||||
mode = normalizedMode,
|
||||
weightMode = raw.string("weight_mode", "none").takeIf { it in setOf("none", "optional", "required") } ?: "none",
|
||||
laterality = raw.string("laterality", "bilateral").takeIf { it in setOf("bilateral", "unilateral") } ?: "bilateral",
|
||||
sidesMode = raw.string("sides_mode", "same").takeIf { it in setOf("same", "separate") } ?: "same",
|
||||
defaultSets = (raw.int("default_sets", raw.int("sets", 3))).coerceIn(1, 20),
|
||||
lockedSets = raw.bool("locked_sets"),
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
|
||||
fun mergeResultSchemaLayers(
|
||||
exerciseSchema: JsonObject?,
|
||||
progressionSchema: JsonObject?,
|
||||
manualSchema: JsonObject?,
|
||||
): ResultSchemaUi {
|
||||
fun apply(base: ResultSchemaUi, raw: JsonObject?, source: String): ResultSchemaUi {
|
||||
if (raw == null || raw.isEmpty()) return base
|
||||
val mode = raw["mode"]?.jsonPrimitive?.contentOrNull
|
||||
?.takeIf { it in setOf("reps", "seconds", "minutes", "none", "auto") }
|
||||
?: base.mode
|
||||
val weightMode = raw["weight_mode"]?.jsonPrimitive?.contentOrNull
|
||||
?.takeIf { it in setOf("none", "optional", "required") }
|
||||
?: base.weightMode
|
||||
val laterality = raw["laterality"]?.jsonPrimitive?.contentOrNull
|
||||
?.takeIf { it in setOf("bilateral", "unilateral") }
|
||||
?: base.laterality
|
||||
val sidesMode = raw["sides_mode"]?.jsonPrimitive?.contentOrNull
|
||||
?.takeIf { it in setOf("same", "separate") }
|
||||
?: base.sidesMode
|
||||
val sets = when {
|
||||
raw["sets"]?.jsonPrimitive?.intOrNull != null -> raw["sets"]!!.jsonPrimitive.intOrNull
|
||||
raw["default_sets"]?.jsonPrimitive?.intOrNull != null -> raw["default_sets"]!!.jsonPrimitive.intOrNull
|
||||
else -> null
|
||||
}?.coerceIn(1, 20) ?: base.defaultSets
|
||||
val locked = raw["locked_sets"]?.jsonPrimitive?.booleanOrNull ?: base.lockedSets
|
||||
return base.copy(
|
||||
mode = mode,
|
||||
weightMode = weightMode,
|
||||
laterality = laterality,
|
||||
sidesMode = if (laterality == "bilateral") "same" else sidesMode,
|
||||
defaultSets = sets,
|
||||
lockedSets = locked,
|
||||
source = source,
|
||||
)
|
||||
}
|
||||
|
||||
var merged = ResultSchemaUi()
|
||||
merged = apply(merged, exerciseSchema, "exercise")
|
||||
merged = apply(merged, progressionSchema, "progression")
|
||||
merged = apply(merged, manualSchema, "session")
|
||||
if ((exerciseSchema == null || exerciseSchema.isEmpty()) &&
|
||||
(progressionSchema == null || progressionSchema.isEmpty()) &&
|
||||
(manualSchema == null || manualSchema.isEmpty())
|
||||
) {
|
||||
merged = merged.copy(source = "fallback")
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
fun resolveResultSchema(
|
||||
exercise: ExerciseUi,
|
||||
item: JsonObject,
|
||||
progressionText: String,
|
||||
plan: PlanUi,
|
||||
): ResultSchemaUi {
|
||||
val manualRaw = item["result_format"] as? JsonObject
|
||||
val progression = plan.progressions[exercise.progressionId]
|
||||
val stepId = item.string("progression_step_id")
|
||||
val step = progression?.steps?.firstOrNull { it.id == stepId }
|
||||
?: progression?.steps?.firstOrNull { it.name.equals(progressionText, ignoreCase = true) }
|
||||
val stepRaw = step?.raw?.get("result_schema") as? JsonObject
|
||||
val exerciseRaw = exercise.raw["result_schema"] as? JsonObject
|
||||
val base = mergeResultSchemaLayers(exerciseRaw, stepRaw, manualRaw)
|
||||
|
||||
val text = "${exercise.name} $progressionText ${exercise.cue}".lowercase()
|
||||
val static = Regex("hold|plank|hang|wall sit|wandsitz|isometr|stützposition").containsMatchIn(text)
|
||||
val dynamic = Regex("push.?up|pull.?up|klimmzug|liegestütz|senkung|negativ|reps|rudern|row|squat|kniebeuge|dip|curl|deadlift|rdl|hinge|swing|raise|heben|climber|crunch|twist|lunge|ausfallschritt").containsMatchIn(progressionText.lowercase())
|
||||
val mode = when {
|
||||
base.mode == "auto" -> if (static && !dynamic) "seconds" else "reps"
|
||||
base.source == "exercise" && base.mode !in setOf("none", "minutes") && static && !dynamic -> "seconds"
|
||||
else -> base.mode
|
||||
}
|
||||
val inferredLaterality = when {
|
||||
base.source !in setOf("progression", "session") && Regex("einarm|einbein|je seite|pro seite").containsMatchIn(progressionText.lowercase()) -> "unilateral"
|
||||
base.source !in setOf("progression", "session") && Regex("beidbeinig|zweiarmig|beide seiten gemeinsam").containsMatchIn(progressionText.lowercase()) -> "bilateral"
|
||||
else -> base.laterality
|
||||
}
|
||||
val inferredWeight = if (base.source in setOf("progression", "session")) base.weightMode else {
|
||||
val low = progressionText.lowercase()
|
||||
val unweighted = Regex("ohne gewicht|ungewichtet|körpergewicht|koerpergewicht|\\bbw\\b").containsMatchIn(low)
|
||||
val loaded = Regex("gewicht|gewichtet|zusatzlast|\\blast\\b|\\bkg\\b|kettlebell|\\bkb\\b|rucksack").containsMatchIn(low)
|
||||
when {
|
||||
unweighted && loaded -> "optional"
|
||||
unweighted -> "none"
|
||||
loaded -> if (Regex("möglich|optional|bei 8").containsMatchIn(low)) "optional" else "required"
|
||||
else -> base.weightMode
|
||||
}
|
||||
}
|
||||
return base.copy(
|
||||
mode = mode,
|
||||
laterality = inferredLaterality,
|
||||
sidesMode = if (inferredLaterality == "unilateral") base.sidesMode else "same",
|
||||
weightMode = inferredWeight,
|
||||
)
|
||||
}
|
||||
|
||||
fun sessionKey(week: Int, day: Int): String = "w%02d-d%02d".format(week, day)
|
||||
|
||||
fun blankSession(plan: PlanUi): JsonObject = buildJsonObject {
|
||||
put("status", "planned")
|
||||
put("plan_id", plan.planId)
|
||||
put("plan_revision", plan.publishedRevision)
|
||||
put("items", JsonObject(emptyMap()))
|
||||
put("note", "")
|
||||
}
|
||||
|
||||
fun trackerSession(tracker: JsonObject, key: String, plan: PlanUi): JsonObject {
|
||||
return (tracker.obj("sessions")[key] as? JsonObject) ?: blankSession(plan)
|
||||
}
|
||||
|
||||
fun migrateLegacyItems(session: JsonObject, day: DayUi): Pair<JsonObject, Boolean> {
|
||||
val items = session.obj("items").toMutableMap()
|
||||
var changed = false
|
||||
day.rotations.flatMap { it.exercises }.forEach { exercise ->
|
||||
if (items[exercise.id] == null && exercise.legacyId.isNotBlank() && items[exercise.legacyId] != null) {
|
||||
items[exercise.id] = items.getValue(exercise.legacyId)
|
||||
items.remove(exercise.legacyId)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return session.updated("items", JsonObject(items)) to changed
|
||||
}
|
||||
|
||||
fun itemFor(session: JsonObject, exerciseId: String): JsonObject = session.obj("items")[exerciseId] as? JsonObject
|
||||
?: buildJsonObject {
|
||||
put("completion_status", "planned")
|
||||
put("done", false)
|
||||
put("progression", "")
|
||||
put("result", "")
|
||||
put("note", "")
|
||||
}
|
||||
|
||||
fun updateItem(session: JsonObject, exerciseId: String, item: JsonObject): JsonObject {
|
||||
val items = session.obj("items").toMutableMap().apply { put(exerciseId, item) }
|
||||
return session.updated("items", JsonObject(items))
|
||||
}
|
||||
|
||||
fun updateTrackerSession(tracker: JsonObject, key: String, session: JsonObject): JsonObject {
|
||||
val sessions = tracker.obj("sessions").toMutableMap().apply { put(key, session) }
|
||||
return tracker.updated("sessions", JsonObject(sessions))
|
||||
}
|
||||
|
||||
fun sessionPatch(tracker: JsonObject, key: String, session: JsonObject): JsonObject = buildJsonObject {
|
||||
put("expected_revision", tracker.int("revision", 1))
|
||||
put("profile", tracker.obj("profile"))
|
||||
put("week_statuses", tracker.obj("week_statuses"))
|
||||
put("session_key", key)
|
||||
put("session", session)
|
||||
}
|
||||
|
||||
fun overlaySessionPatches(base: JsonObject, patches: List<JsonObject>): JsonObject {
|
||||
var tracker = base
|
||||
patches.forEach { patch ->
|
||||
patch["profile"]?.let { tracker = tracker.updated("profile", it) }
|
||||
patch["week_statuses"]?.let { tracker = tracker.updated("week_statuses", it) }
|
||||
val key = patch.string("session_key")
|
||||
val session = patch["session"] as? JsonObject
|
||||
if (key.isNotBlank() && session != null) tracker = updateTrackerSession(tracker, key, session)
|
||||
val localRevision = patch.int("expected_revision", tracker.int("revision", 1)) + 1
|
||||
if (localRevision > tracker.int("revision", 1)) tracker = tracker.updatedInt("revision", localRevision)
|
||||
}
|
||||
return tracker
|
||||
}
|
||||
|
||||
fun adaptResultDataToSchema(current: JsonObject, schema: ResultSchemaUi): JsonObject? {
|
||||
if (schema.mode == "none") return null
|
||||
val oldMode = current.string("mode", schema.mode)
|
||||
val oldLaterality = current.string("laterality", schema.laterality)
|
||||
val oldSides = current.string("sides_mode", schema.sidesMode)
|
||||
val incompatible = oldMode != schema.mode || oldLaterality != schema.laterality ||
|
||||
(schema.laterality == "unilateral" && oldSides != schema.sidesMode)
|
||||
var data = current
|
||||
.updatedString("mode", schema.mode)
|
||||
.updatedString("laterality", schema.laterality)
|
||||
.updatedString("sides_mode", if (schema.laterality == "unilateral") schema.sidesMode else "same")
|
||||
if (incompatible) {
|
||||
data = data
|
||||
.updated("values", JsonArray(emptyList()))
|
||||
.updated("left_values", JsonArray(emptyList()))
|
||||
.updated("right_values", JsonArray(emptyList()))
|
||||
}
|
||||
if (schema.lockedSets) data = resultDataWithSets(data, schema.defaultSets)
|
||||
if (schema.weightMode == "none") data = data.updated("weight_kg", null)
|
||||
return normalizeResultData(data, schema)
|
||||
}
|
||||
|
||||
fun emptyResultData(schema: ResultSchemaUi): JsonObject {
|
||||
val sets = schema.defaultSets.coerceIn(1, 20)
|
||||
return buildJsonObject {
|
||||
put("version", 2)
|
||||
put("mode", schema.mode)
|
||||
put("laterality", schema.laterality)
|
||||
put("sides_mode", if (schema.laterality == "unilateral") schema.sidesMode else "same")
|
||||
put("sets", sets)
|
||||
put("weight_kg", JsonNull)
|
||||
put("values", buildJsonArray { repeat(sets) { add(JsonNull) } })
|
||||
put("left_values", buildJsonArray { repeat(sets) { add(JsonNull) } })
|
||||
put("right_values", buildJsonArray { repeat(sets) { add(JsonNull) } })
|
||||
}
|
||||
}
|
||||
|
||||
fun normalizedResultData(item: JsonObject, schema: ResultSchemaUi): JsonObject {
|
||||
val structured = item["result_data"] as? JsonObject
|
||||
if (structured != null) return normalizeResultData(structured, schema)
|
||||
return parseLegacyResult(item.string("result"), schema) ?: emptyResultData(schema)
|
||||
}
|
||||
|
||||
private fun normalizeResultData(raw: JsonObject, schema: ResultSchemaUi): JsonObject {
|
||||
val sets = raw.int("sets", schema.defaultSets).coerceIn(1, 20)
|
||||
fun normalizedArray(key: String): JsonArray {
|
||||
val values = raw.array(key).take(sets).toMutableList()
|
||||
while (values.size < sets) values.add(JsonNull)
|
||||
return JsonArray(values)
|
||||
}
|
||||
return buildJsonObject {
|
||||
put("version", 2)
|
||||
put("mode", raw.string("mode", schema.mode))
|
||||
put("laterality", raw.string("laterality", schema.laterality))
|
||||
put("sides_mode", raw.string("sides_mode", schema.sidesMode))
|
||||
put("sets", sets)
|
||||
put("weight_kg", raw["weight_kg"] ?: JsonNull)
|
||||
put("values", normalizedArray("values"))
|
||||
put("left_values", normalizedArray("left_values"))
|
||||
put("right_values", normalizedArray("right_values"))
|
||||
}
|
||||
}
|
||||
|
||||
fun parseLegacyResult(text: String, schema: ResultSchemaUi): JsonObject? {
|
||||
val raw = text.trim()
|
||||
if (raw.isBlank()) return null
|
||||
val weight = Regex("(\\d+(?:[.,]\\d+)?)\\s*kg", RegexOption.IGNORE_CASE)
|
||||
.find(raw)?.groupValues?.getOrNull(1)?.replace(',', '.')?.toDoubleOrNull()
|
||||
val repeatedSeconds = Regex("(\\d+)\\s*[x×]\\s*(\\d+(?:[.,]\\d+)?)\\s*s", RegexOption.IGNORE_CASE).find(raw)
|
||||
val values: List<Double> = when {
|
||||
repeatedSeconds != null -> {
|
||||
val count = repeatedSeconds.groupValues[1].toIntOrNull()?.coerceIn(1, 20) ?: 1
|
||||
val value = repeatedSeconds.groupValues[2].replace(',', '.').toDoubleOrNull() ?: return null
|
||||
List(count) { value }
|
||||
}
|
||||
else -> raw.replace(Regex("\\d+(?:[.,]\\d+)?\\s*kg", RegexOption.IGNORE_CASE), "")
|
||||
.split('/', ';')
|
||||
.mapNotNull { Regex("-?\\d+(?:[.,]\\d+)?").find(it)?.value?.replace(',', '.')?.toDoubleOrNull() }
|
||||
.take(20)
|
||||
}
|
||||
if (values.isEmpty() && weight == null) return null
|
||||
val mode = when {
|
||||
repeatedSeconds != null || Regex("\\bsek|\\bs\\b|second", RegexOption.IGNORE_CASE).containsMatchIn(raw) -> "seconds"
|
||||
Regex("min", RegexOption.IGNORE_CASE).containsMatchIn(raw) -> "minutes"
|
||||
else -> schema.mode.takeIf { it in setOf("reps", "seconds", "minutes") } ?: "reps"
|
||||
}
|
||||
val sets = values.size.coerceAtLeast(1)
|
||||
return buildJsonObject {
|
||||
put("version", 2)
|
||||
put("mode", mode)
|
||||
put("laterality", schema.laterality)
|
||||
put("sides_mode", schema.sidesMode)
|
||||
put("sets", sets)
|
||||
put("weight_kg", weight?.let(::JsonPrimitive) ?: JsonNull)
|
||||
put("values", JsonArray(values.map(::JsonPrimitive)))
|
||||
put("left_values", buildJsonArray { repeat(sets) { add(JsonNull) } })
|
||||
put("right_values", buildJsonArray { repeat(sets) { add(JsonNull) } })
|
||||
}
|
||||
}
|
||||
|
||||
fun formatResultData(data: JsonObject): String {
|
||||
fun num(element: JsonElement?): String = element?.jsonPrimitive?.doubleOrNull?.let {
|
||||
if (it % 1.0 == 0.0) it.toInt().toString() else it.toString().trimEnd('0').trimEnd('.')
|
||||
} ?: "–"
|
||||
val mode = data.string("mode", "reps")
|
||||
val unit = when (mode) { "seconds" -> " s"; "minutes" -> " min"; else -> " Reps" }
|
||||
val weight = data["weight_kg"]?.jsonPrimitive?.doubleOrNull?.let { "${num(JsonPrimitive(it))} kg · " }.orEmpty()
|
||||
val laterality = data.string("laterality", "bilateral")
|
||||
val separate = laterality == "unilateral" && data.string("sides_mode") == "separate"
|
||||
return if (separate) {
|
||||
val left = data.array("left_values").joinToString("/") { num(it) }
|
||||
val right = data.array("right_values").joinToString("/") { num(it) }
|
||||
"$weight L $left · R $right$unit"
|
||||
} else {
|
||||
val values = data.array("values").joinToString("/") { num(it) }
|
||||
"$weight$values$unit${if (laterality == "unilateral") " je Seite" else ""}"
|
||||
}.trim()
|
||||
}
|
||||
|
||||
fun resultDataWithValue(data: JsonObject, field: String, index: Int, value: Double?): JsonObject {
|
||||
val list = data.array(field).toMutableList()
|
||||
while (list.size <= index) list.add(JsonNull)
|
||||
list[index] = value?.let(::JsonPrimitive) ?: JsonNull
|
||||
return data.updated(field, JsonArray(list))
|
||||
}
|
||||
|
||||
fun resultDataWithSets(data: JsonObject, sets: Int): JsonObject {
|
||||
val count = sets.coerceIn(1, 20)
|
||||
var result = data.updatedInt("sets", count)
|
||||
for (field in listOf("values", "left_values", "right_values")) {
|
||||
val list = result.array(field).take(count).toMutableList()
|
||||
while (list.size < count) list.add(JsonNull)
|
||||
result = result.updated(field, JsonArray(list))
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package de.drazz.boehmitools.training.ui
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.FitnessCenter
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import de.drazz.boehmitools.training.data.settings.SettingsRepository
|
||||
import de.drazz.boehmitools.training.ui.editor.EditorScreen
|
||||
import de.drazz.boehmitools.training.ui.setup.SettingsScreen
|
||||
import de.drazz.boehmitools.training.ui.tracker.TrackerScreen
|
||||
|
||||
private data class AppDestination(val route: String, val label: String, val icon: @Composable () -> Unit)
|
||||
|
||||
@Composable
|
||||
fun AppRoot(
|
||||
settings: SettingsRepository.PublicSettings,
|
||||
onTheme: (String) -> Unit,
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val destinations = listOf(
|
||||
AppDestination("tracker", "Tracker") { Icon(Icons.Default.FitnessCenter, null) },
|
||||
AppDestination("editor", "Planeditor") { Icon(Icons.Default.Edit, null) },
|
||||
AppDestination("settings", "Einstellungen") { Icon(Icons.Default.Settings, null) },
|
||||
)
|
||||
val backStack by navController.currentBackStackEntryAsState()
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
destinations.forEach { destination ->
|
||||
val selected = backStack?.destination?.hierarchy?.any { it.route == destination.route } == true
|
||||
NavigationBarItem(
|
||||
selected = selected,
|
||||
onClick = {
|
||||
navController.navigate(destination.route) {
|
||||
popUpTo("tracker") { saveState = true }
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
icon = destination.icon,
|
||||
label = { Text(destination.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
NavHost(navController = navController, startDestination = "tracker", modifier = Modifier) {
|
||||
composable("tracker") { TrackerScreen(contentPadding = padding) }
|
||||
composable("editor") { EditorScreen(contentPadding = padding) }
|
||||
composable("settings") {
|
||||
SettingsScreen(
|
||||
contentPadding = padding,
|
||||
current = settings,
|
||||
onTheme = onTheme,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package de.drazz.boehmitools.training.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.drazz.boehmitools.training.data.settings.SettingsRepository
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@HiltViewModel
|
||||
class AppViewModel @Inject constructor(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
) : ViewModel() {
|
||||
val settings = settingsRepository.publicSettings.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5_000),
|
||||
SettingsRepository.PublicSettings(
|
||||
SettingsRepository.DEFAULT_SERVER,
|
||||
"admin",
|
||||
false,
|
||||
"system",
|
||||
),
|
||||
)
|
||||
|
||||
fun setTheme(theme: String) {
|
||||
viewModelScope.launch { settingsRepository.setTheme(theme) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
package de.drazz.boehmitools.training.ui.editor
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.drazz.boehmitools.training.domain.array
|
||||
import de.drazz.boehmitools.training.domain.bool
|
||||
import de.drazz.boehmitools.training.domain.int
|
||||
import de.drazz.boehmitools.training.domain.obj
|
||||
import de.drazz.boehmitools.training.domain.string
|
||||
import de.drazz.boehmitools.training.domain.updatedBool
|
||||
import de.drazz.boehmitools.training.domain.updatedInt
|
||||
import de.drazz.boehmitools.training.domain.updatedString
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
private val editorTabs = listOf("Grunddaten", "Tage", "Progressionen", "Bibliothek", "Prüfen", "Vorschläge")
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EditorScreen(
|
||||
contentPadding: PaddingValues,
|
||||
viewModel: EditorViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
var selectedTab by remember { mutableIntStateOf(0) }
|
||||
var planMenu by remember { mutableStateOf(false) }
|
||||
var createDialog by remember { mutableStateOf(false) }
|
||||
var renameDialog by remember { mutableStateOf(false) }
|
||||
var deleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
state.conflict?.let { conflict ->
|
||||
AlertDialog(
|
||||
onDismissRequest = viewModel::dismissConflict,
|
||||
title = { Text("Revisionskonflikt") },
|
||||
text = { Text("Der Serverplan liegt inzwischen bei Revision ${conflict.currentRevision}. Deine Änderungen wurden nicht überschrieben.") },
|
||||
confirmButton = { TextButton(onClick = viewModel::reloadAfterConflict) { Text("Serverstand neu laden") } },
|
||||
dismissButton = { TextButton(onClick = viewModel::dismissConflict) { Text("Bearbeitung behalten") } },
|
||||
)
|
||||
}
|
||||
|
||||
if (createDialog) NameDialog(
|
||||
title = "Neuen Plan anlegen",
|
||||
initial = "",
|
||||
allowClone = state.selectedPlanId.isNotBlank(),
|
||||
onDismiss = { createDialog = false },
|
||||
onConfirm = { name, clone -> createDialog = false; viewModel.createPlan(name, clone) },
|
||||
)
|
||||
if (renameDialog) NameDialog(
|
||||
title = "Plan umbenennen",
|
||||
initial = state.wrapper.string("name"),
|
||||
allowClone = false,
|
||||
onDismiss = { renameDialog = false },
|
||||
onConfirm = { name, _ -> renameDialog = false; viewModel.renamePlan(name) },
|
||||
)
|
||||
if (deleteDialog) AlertDialog(
|
||||
onDismissRequest = { deleteDialog = false },
|
||||
title = { Text("Plan löschen?") },
|
||||
text = { Text("Der Server legt vor dem Löschen ein Backup an. Der letzte verbleibende Plan kann nicht gelöscht werden.") },
|
||||
confirmButton = { TextButton(onClick = { deleteDialog = false; viewModel.deletePlan() }) { Text("Löschen") } },
|
||||
dismissButton = { TextButton(onClick = { deleteDialog = false }) { Text("Abbrechen") } },
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.padding(contentPadding),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Box {
|
||||
TextButton(onClick = { planMenu = true }) {
|
||||
Text(state.wrapper.string("name", "Planeditor"), maxLines = 2)
|
||||
}
|
||||
DropdownMenu(expanded = planMenu, onDismissRequest = { planMenu = false }) {
|
||||
state.plans.forEach { plan ->
|
||||
DropdownMenuItem(
|
||||
text = { Text("${plan.name}${if (plan.hasUnpublishedChanges) " •" else ""}") },
|
||||
onClick = { planMenu = false; viewModel.selectPlan(plan.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { createDialog = true }) { Icon(Icons.Default.Add, "Plan anlegen") }
|
||||
Box {
|
||||
IconButton(onClick = { planMenu = true }) { Icon(Icons.Default.MoreVert, "Planaktionen") }
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { inner ->
|
||||
Column(Modifier.fillMaxSize().padding(inner)) {
|
||||
ScrollableTabRow(selectedTabIndex = selectedTab, edgePadding = 8.dp) {
|
||||
editorTabs.forEachIndexed { index, title ->
|
||||
Tab(selected = index == selectedTab, onClick = { selectedTab = index }, text = { Text(title) })
|
||||
}
|
||||
}
|
||||
if (state.loading) {
|
||||
Box(Modifier.fillMaxSize().padding(32.dp)) { CircularProgressIndicator() }
|
||||
} else if (state.selectedPlanId.isBlank()) {
|
||||
EmptyEditor(onCreate = { createDialog = true })
|
||||
} else {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
StatusStrip(state)
|
||||
when (selectedTab) {
|
||||
0 -> BasicsPane(state, viewModel, onRename = { renameDialog = true }, onDelete = { deleteDialog = true })
|
||||
1 -> DaysPane(state, viewModel)
|
||||
2 -> ProgressionsPane(state, viewModel)
|
||||
3 -> LibraryPane(state)
|
||||
4 -> ValidationPane(state, viewModel)
|
||||
else -> ProposalsPane(state, viewModel) { selectedTab = 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusStrip(state: EditorUiState) {
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(
|
||||
"Entwurf R${state.wrapper.int("revision", 1)} · veröffentlicht R${state.wrapper.int("published_revision", 1)}${if (state.dirty) " · ungespeichert" else ""}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
when {
|
||||
state.error.isNotBlank() -> Text(state.error, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
state.message.isNotBlank() -> Text(state.message, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyEditor(onCreate: () -> Unit) {
|
||||
Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Noch kein Trainingsplan verfügbar.", style = MaterialTheme.typography.headlineSmall)
|
||||
Button(onClick = onCreate) { Text("Plan anlegen") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BasicsPane(state: EditorUiState, vm: EditorViewModel, onRename: () -> Unit, onDelete: () -> Unit) {
|
||||
val meta = state.config.obj("meta")
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text("Plan und Entwurf", style = MaterialTheme.typography.headlineSmall)
|
||||
OutlinedTextField(meta.string("title"), { vm.updateMeta("title", it) }, Modifier.fillMaxWidth(), label = { Text("Titel") })
|
||||
OutlinedTextField(meta.string("subtitle"), { vm.updateMeta("subtitle", it) }, Modifier.fillMaxWidth(), label = { Text("Untertitel") }, minLines = 2)
|
||||
OutlinedTextField(
|
||||
value = meta.int("weeks", 1).toString(),
|
||||
onValueChange = { it.toIntOrNull()?.let(vm::updateWeeks) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Wochen") },
|
||||
singleLine = true,
|
||||
)
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Stabile Identität", fontWeight = FontWeight.Bold)
|
||||
Text("Plan-ID: ${state.wrapper.string("plan_id", state.selectedPlanId)}")
|
||||
Text("Datei-ID: ${state.selectedPlanId}")
|
||||
Text("Vorhandene Sessions: ${state.wrapper.int("tracked_sessions")}")
|
||||
}
|
||||
}
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = vm::save, enabled = !state.saving && state.dirty, modifier = Modifier.weight(1f)) { Text("Entwurf speichern") }
|
||||
OutlinedButton(onClick = vm::validate, enabled = !state.saving, modifier = Modifier.weight(1f)) { Text("Prüfen") }
|
||||
}
|
||||
Button(onClick = vm::publish, enabled = !state.saving && !state.dirty, modifier = Modifier.fillMaxWidth()) { Text("Entwurf veröffentlichen") }
|
||||
OutlinedButton(onClick = onRename, modifier = Modifier.fillMaxWidth()) { Text("Plan umbenennen") }
|
||||
TextButton(onClick = onDelete, modifier = Modifier.fillMaxWidth()) { Text("Plan löschen", color = MaterialTheme.colorScheme.error) }
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DaysPane(state: EditorUiState, vm: EditorViewModel) {
|
||||
val target = state.proposalTargetId
|
||||
val prepost = state.config.obj("prepost")
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp).testTag("editor-days"),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text("Trainingstage", style = MaterialTheme.typography.headlineSmall)
|
||||
state.config.array("days").mapNotNull { it as? JsonObject }.forEach { day ->
|
||||
DayEditor(day, prepost.obj(day.int("num").toString()), vm, highlightExerciseId = target)
|
||||
}
|
||||
OutlinedButton(onClick = vm::addDay, modifier = Modifier.fillMaxWidth()) { Icon(Icons.Default.Add, null); Text(" Tag hinzufügen") }
|
||||
Spacer(Modifier.height(80.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DayEditor(day: JsonObject, prepost: JsonObject, vm: EditorViewModel, highlightExerciseId: String) {
|
||||
val dayId = day.string("id")
|
||||
OutlinedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text("Tag ${day.int("num", 1)}", style = MaterialTheme.typography.titleLarge)
|
||||
IconButton(onClick = { vm.removeDay(dayId) }) { Icon(Icons.Default.Delete, "Tag löschen") }
|
||||
}
|
||||
OutlinedTextField(day.string("focus"), { value -> vm.updateDay(dayId) { it.updatedString("focus", value) } }, Modifier.fillMaxWidth(), label = { Text("Fokus") })
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
Checkbox(day.bool("light"), { value -> vm.updateDay(dayId) { it.updatedBool("light", value) } })
|
||||
Text("Leichter Trainingstag")
|
||||
}
|
||||
OutlinedTextField(
|
||||
prepost.string("warmup"),
|
||||
{ vm.updatePrePost(day.int("num"), "warmup", it) },
|
||||
Modifier.fillMaxWidth(),
|
||||
label = { Text("Warm-up") },
|
||||
minLines = 2,
|
||||
)
|
||||
OutlinedTextField(
|
||||
prepost.string("cooldown"),
|
||||
{ vm.updatePrePost(day.int("num"), "cooldown", it) },
|
||||
Modifier.fillMaxWidth(),
|
||||
label = { Text("Cool-down") },
|
||||
minLines = 2,
|
||||
)
|
||||
OutlinedTextField(
|
||||
prepost.string("stretch"),
|
||||
{ vm.updatePrePost(day.int("num"), "stretch", it) },
|
||||
Modifier.fillMaxWidth(),
|
||||
label = { Text("Stretching") },
|
||||
minLines = 2,
|
||||
)
|
||||
day.array("rotations").mapNotNull { it as? JsonObject }.forEach { rotation ->
|
||||
RotationEditor(dayId, rotation, vm, highlightExerciseId)
|
||||
}
|
||||
OutlinedButton(onClick = { vm.addRotation(dayId) }, modifier = Modifier.fillMaxWidth()) { Text("Block hinzufügen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RotationEditor(dayId: String, rotation: JsonObject, vm: EditorViewModel, highlightExerciseId: String) {
|
||||
val rotationId = rotation.string("id")
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
OutlinedTextField(
|
||||
rotation.string("label"),
|
||||
{ value -> vm.updateRotation(dayId, rotationId) { it.updatedString("label", value) } },
|
||||
Modifier.weight(1f),
|
||||
label = { Text("Block") },
|
||||
)
|
||||
IconButton(onClick = { vm.removeRotation(dayId, rotationId) }) { Icon(Icons.Default.Delete, "Block löschen") }
|
||||
}
|
||||
rotation.array("exercises").mapNotNull { it as? JsonObject }.forEach { exercise ->
|
||||
ExerciseEditor(dayId, rotationId, exercise, vm, highlightExerciseId == exercise.string("exercise_id"))
|
||||
}
|
||||
OutlinedButton(onClick = { vm.addExercise(dayId, rotationId) }, modifier = Modifier.fillMaxWidth()) { Text("Übung hinzufügen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExerciseEditor(dayId: String, rotationId: String, exercise: JsonObject, vm: EditorViewModel, highlighted: Boolean) {
|
||||
val placementId = exercise.string("id")
|
||||
OutlinedCard(Modifier.fillMaxWidth().testTag("exercise-$placementId")) {
|
||||
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
if (highlighted) AssistChip(onClick = {}, label = { Text("Ziel des ausgewählten Vorschlags") })
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(exercise.string("name", "Übung"), style = MaterialTheme.typography.titleMedium)
|
||||
IconButton(onClick = { vm.removeExercise(dayId, rotationId, placementId) }) { Icon(Icons.Default.Delete, "Übung löschen") }
|
||||
}
|
||||
OutlinedTextField(exercise.string("name"), { value -> vm.updateExercise(dayId, rotationId, placementId) { it.updatedString("name", value) } }, Modifier.fillMaxWidth(), label = { Text("Name") })
|
||||
OutlinedTextField(exercise.string("cue"), { value -> vm.updateExercise(dayId, rotationId, placementId) { it.updatedString("cue", value) } }, Modifier.fillMaxWidth(), label = { Text("Hinweis") }, minLines = 2)
|
||||
OutlinedTextField(exercise.string("exercise_id"), {}, Modifier.fillMaxWidth(), readOnly = true, label = { Text("Stabile Bewegungs-ID") })
|
||||
OutlinedTextField(exercise.string("progression_id"), { value -> vm.updateExercise(dayId, rotationId, placementId) { it.updatedString("progression_id", value) } }, Modifier.fillMaxWidth(), label = { Text("Progressions-ID") })
|
||||
ResultSchemaEditor(
|
||||
schema = exercise.obj("result_schema"),
|
||||
onField = { field, value -> vm.updateExerciseSchema(dayId, rotationId, placementId, field, value) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProgressionsPane(state: EditorUiState, vm: EditorViewModel) {
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Progressionsstufen", style = MaterialTheme.typography.headlineSmall)
|
||||
state.config.obj("stages").forEach { (key, element) ->
|
||||
val stage = element as? JsonObject ?: return@forEach
|
||||
OutlinedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(stage.string("name", key), style = MaterialTheme.typography.titleLarge)
|
||||
Text("ID: ${stage.string("id", key)} · Schlüssel: $key", style = MaterialTheme.typography.bodySmall)
|
||||
stage.array("steps").mapNotNull { it as? JsonObject }.forEach { step ->
|
||||
val stepId = step.string("id")
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(step.string("name"), { value -> vm.updateProgressionStep(key, stepId) { it.updatedString("name", value) } }, Modifier.fillMaxWidth(), label = { Text("Stufe") })
|
||||
OutlinedTextField(step.string("phase_id"), { value -> vm.updateProgressionStep(key, stepId) { it.updatedString("phase_id", value) } }, Modifier.fillMaxWidth(), label = { Text("Phasen-ID") })
|
||||
ResultSchemaEditor(step.obj("result_schema")) { field, value -> vm.updateStepSchema(key, stepId, field, value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedButton(onClick = { vm.addProgressionStep(key) }, modifier = Modifier.fillMaxWidth()) { Text("Stufe hinzufügen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(80.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultSchemaEditor(schema: JsonObject, onField: (String, JsonPrimitive) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
Text("Ergebnisschema", fontWeight = FontWeight.Bold)
|
||||
ChoiceRow("Modus", listOf("reps", "seconds", "minutes", "none"), schema.string("mode", "reps")) { onField("mode", JsonPrimitive(it)) }
|
||||
ChoiceRow("Gewicht", listOf("none", "optional", "required"), schema.string("weight_mode", "none")) { onField("weight_mode", JsonPrimitive(it)) }
|
||||
ChoiceRow("Seiten", listOf("bilateral", "unilateral"), schema.string("laterality", "bilateral")) { onField("laterality", JsonPrimitive(it)) }
|
||||
if (schema.string("laterality") == "unilateral") {
|
||||
ChoiceRow("Seitenwerte", listOf("same", "separate"), schema.string("sides_mode", "same")) { onField("sides_mode", JsonPrimitive(it)) }
|
||||
}
|
||||
OutlinedTextField(
|
||||
schema.int("sets", 3).toString(),
|
||||
{ it.toIntOrNull()?.coerceIn(1, 20)?.let { count -> onField("sets", JsonPrimitive(count)) } },
|
||||
Modifier.fillMaxWidth(),
|
||||
label = { Text("Standardsätze") },
|
||||
singleLine = true,
|
||||
)
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
Checkbox(schema.bool("locked_sets"), { onField("locked_sets", JsonPrimitive(it)) })
|
||||
Text("Satzanzahl sperren")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChoiceRow(label: String, choices: List<String>, selected: String, onSelect: (String) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.labelMedium)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
choices.forEach { value ->
|
||||
FilterChip(
|
||||
selected = selected == value,
|
||||
onClick = { onSelect(value) },
|
||||
label = { Text(value) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LibraryPane(state: EditorUiState) {
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Übungsbibliothek", style = MaterialTheme.typography.headlineSmall)
|
||||
Text("Die Bibliothek wird beim Speichern serverseitig aus stabilen Bewegungs- und Progressions-IDs neu aufgebaut.")
|
||||
val catalog = state.config.obj("exercise_catalog")
|
||||
if (catalog.isEmpty()) Text("Noch kein Katalog im Entwurf. Speichern oder validieren erzeugt ihn.")
|
||||
catalog.forEach { (id, element) ->
|
||||
val entry = element as? JsonObject ?: return@forEach
|
||||
OutlinedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(entry.string("movement_label", entry.string("name", id)), fontWeight = FontWeight.Bold)
|
||||
Text(id, style = MaterialTheme.typography.bodySmall)
|
||||
Text("Cluster: ${entry.string("movement_cluster", "general")}")
|
||||
Text("Varianten: ${entry.array("variants").size}")
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(80.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ValidationPane(state: EditorUiState, vm: EditorViewModel) {
|
||||
val validation = state.validation
|
||||
val errors = validation.array("errors")
|
||||
val warnings = validation.array("warnings")
|
||||
val diff = validation.obj("diff")
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Validierung und Vorschau", style = MaterialTheme.typography.headlineSmall)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = vm::validate, enabled = !state.saving, modifier = Modifier.weight(1f)) { Text("Validieren") }
|
||||
OutlinedButton(onClick = vm::save, enabled = !state.saving && state.dirty, modifier = Modifier.weight(1f)) { Text("Speichern") }
|
||||
}
|
||||
if (validation.isEmpty()) Text("Noch keine Prüfung für den aktuellen Entwurf ausgeführt.")
|
||||
errors.forEach { Text("Fehler: ${it.toString().trim('"')}", color = MaterialTheme.colorScheme.error) }
|
||||
warnings.forEach { Text("Warnung: ${it.toString().trim('"')}") }
|
||||
if (validation.isNotEmpty() && errors.isEmpty()) AssistChip(onClick = {}, label = { Text("Keine Validierungsfehler") })
|
||||
if (diff.isNotEmpty()) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Änderungsübersicht", style = MaterialTheme.typography.titleMedium)
|
||||
Text("Bestehende Sessions: ${diff.int("tracked_sessions")}")
|
||||
listOf("days" to "Tage", "rotations" to "Blöcke", "exercises" to "Übungen", "progression_steps" to "Progressionsstufen").forEach { (key, label) ->
|
||||
val section = diff.obj(key)
|
||||
Text("$label: +${section.array("added").size} / −${section.array("removed").size} / ~${section.array("changed").size}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TrackerPreview(state.config)
|
||||
Button(onClick = vm::publish, enabled = !state.saving && !state.dirty && errors.isEmpty(), modifier = Modifier.fillMaxWidth()) { Text("Geprüften Entwurf veröffentlichen") }
|
||||
Spacer(Modifier.height(80.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackerPreview(config: JsonObject) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Tracker-Vorschau", style = MaterialTheme.typography.titleMedium)
|
||||
config.array("days").mapNotNull { it as? JsonObject }.forEach { day ->
|
||||
Text("Tag ${day.int("num")}: ${day.string("focus")}", fontWeight = FontWeight.Bold)
|
||||
day.array("rotations").mapNotNull { it as? JsonObject }.forEach { rotation ->
|
||||
Text(rotation.string("label"))
|
||||
rotation.array("exercises").mapNotNull { it as? JsonObject }.forEach { exercise -> Text("• ${exercise.string("name")}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProposalsPane(state: EditorUiState, vm: EditorViewModel, showDays: () -> Unit) {
|
||||
val proposals = state.proposals.array("proposals").mapNotNull { it as? JsonObject }
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("KI-Vorschläge", style = MaterialTheme.typography.headlineSmall)
|
||||
Text("Vorschläge verändern den Plan niemals automatisch. Annahme und Ablehnung markieren ausschließlich den Prüfstatus.")
|
||||
if (proposals.isEmpty()) Text("Keine Vorschläge vorhanden.")
|
||||
proposals.forEach { proposal ->
|
||||
OutlinedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(proposal.string("target_label", proposal.string("target")), style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
|
||||
AssistChip(onClick = {}, label = { Text(proposal.string("status", "open")) })
|
||||
}
|
||||
Text(proposal.string("suggested_change"), fontWeight = FontWeight.Bold)
|
||||
Text(proposal.string("reason"))
|
||||
if (proposal.string("condition").isNotBlank()) Text("Bedingung: ${proposal.string("condition")}")
|
||||
if (proposal.string("manual_step").isNotBlank()) Text("Manueller Schritt: ${proposal.string("manual_step")}")
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
vm.navigateToProposal(proposal.string("target_exercise_id"))
|
||||
showDays()
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Zum Ziel navigieren") }
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { vm.setProposalStatus(proposal.string("id"), "accepted") }, modifier = Modifier.weight(1f)) { Text("Annehmen") }
|
||||
OutlinedButton(onClick = { vm.setProposalStatus(proposal.string("id"), "rejected") }, modifier = Modifier.weight(1f)) { Text("Ablehnen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(80.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NameDialog(
|
||||
title: String,
|
||||
initial: String,
|
||||
allowClone: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (String, Boolean) -> Unit,
|
||||
) {
|
||||
var name by remember(initial) { mutableStateOf(initial) }
|
||||
var clone by remember { mutableStateOf(false) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(name, { name = it }, Modifier.fillMaxWidth(), label = { Text("Name") })
|
||||
if (allowClone) Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) {
|
||||
Checkbox(clone, { clone = it })
|
||||
Text("Aktuellen Plan als Vorlage verwenden")
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = { onConfirm(name, clone) }, enabled = name.isNotBlank()) { Text("Übernehmen") } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Abbrechen") } },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
package de.drazz.boehmitools.training.ui.editor
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.drazz.boehmitools.training.data.repository.RevisionConflictException
|
||||
import de.drazz.boehmitools.training.data.repository.TrainingRepository
|
||||
import de.drazz.boehmitools.training.data.settings.SettingsRepository
|
||||
import de.drazz.boehmitools.training.domain.array
|
||||
import de.drazz.boehmitools.training.domain.int
|
||||
import de.drazz.boehmitools.training.domain.obj
|
||||
import de.drazz.boehmitools.training.domain.string
|
||||
import de.drazz.boehmitools.training.domain.updated
|
||||
import de.drazz.boehmitools.training.domain.updatedBool
|
||||
import de.drazz.boehmitools.training.domain.updatedInt
|
||||
import de.drazz.boehmitools.training.domain.updatedString
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
data class EditorPlanSummary(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val revision: Int,
|
||||
val publishedRevision: Int,
|
||||
val active: Boolean,
|
||||
val hasUnpublishedChanges: Boolean,
|
||||
)
|
||||
|
||||
data class EditorUiState(
|
||||
val loading: Boolean = true,
|
||||
val saving: Boolean = false,
|
||||
val plans: List<EditorPlanSummary> = emptyList(),
|
||||
val selectedPlanId: String = "",
|
||||
val wrapper: JsonObject = JsonObject(emptyMap()),
|
||||
val config: JsonObject = JsonObject(emptyMap()),
|
||||
val validation: JsonObject = JsonObject(emptyMap()),
|
||||
val proposals: JsonObject = JsonObject(emptyMap()),
|
||||
val dirty: Boolean = false,
|
||||
val message: String = "",
|
||||
val error: String = "",
|
||||
val conflict: RevisionConflictException? = null,
|
||||
val proposalTargetId: String = "",
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class EditorViewModel @Inject constructor(
|
||||
private val repository: TrainingRepository,
|
||||
private val settings: SettingsRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(EditorUiState())
|
||||
val state: StateFlow<EditorUiState> = _state.asStateFlow()
|
||||
|
||||
init { loadPlans() }
|
||||
|
||||
fun loadPlans(force: Boolean = false) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = "", message = "")
|
||||
runCatching { repository.editorPlans(force) }
|
||||
.onSuccess { body ->
|
||||
val plans = body.array("plans").mapNotNull { it as? JsonObject }.map {
|
||||
EditorPlanSummary(
|
||||
id = it.string("id"),
|
||||
name = it.string("name", it.string("id")),
|
||||
revision = it.int("revision", 1),
|
||||
publishedRevision = it.int("published_revision", 1),
|
||||
active = it["active"]?.toString() == "true",
|
||||
hasUnpublishedChanges = it["has_unpublished_changes"]?.toString() == "true",
|
||||
)
|
||||
}
|
||||
val stored = settings.selectedEditorPlan()
|
||||
val selected = _state.value.selectedPlanId.takeIf { id -> plans.any { it.id == id } }
|
||||
?: stored?.takeIf { id -> plans.any { it.id == id } }
|
||||
?: body.string("active").takeIf { id -> plans.any { it.id == id } }
|
||||
?: plans.firstOrNull()?.id.orEmpty()
|
||||
_state.value = _state.value.copy(loading = false, plans = plans)
|
||||
if (selected.isNotBlank()) selectPlan(selected, persistServer = false)
|
||||
}
|
||||
.onFailure { error ->
|
||||
_state.value = _state.value.copy(loading = false, error = error.message ?: "Planliste konnte nicht geladen werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectPlan(planId: String, persistServer: Boolean = true) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, selectedPlanId = planId, error = "", conflict = null)
|
||||
runCatching {
|
||||
if (persistServer) repository.selectEditorPlan(planId)
|
||||
settings.setSelectedEditorPlan(planId)
|
||||
val wrapper = repository.editorPlan(planId, force = true)
|
||||
val proposals = repository.proposals(planId)
|
||||
wrapper to proposals
|
||||
}.onSuccess { (wrapper, proposals) ->
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
wrapper = wrapper,
|
||||
config = wrapper.obj("config"),
|
||||
proposals = proposals,
|
||||
validation = JsonObject(emptyMap()),
|
||||
dirty = false,
|
||||
message = "",
|
||||
)
|
||||
}.onFailure { error ->
|
||||
_state.value = _state.value.copy(loading = false, error = error.message ?: "Plan konnte nicht geladen werden.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createPlan(name: String, cloneCurrent: Boolean) {
|
||||
if (name.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(saving = true, error = "")
|
||||
runCatching { repository.createPlan(name.trim(), _state.value.selectedPlanId.takeIf { cloneCurrent }) }
|
||||
.onSuccess { created ->
|
||||
_state.value = _state.value.copy(
|
||||
saving = false,
|
||||
selectedPlanId = created.string("id"),
|
||||
wrapper = created,
|
||||
config = created.obj("config"),
|
||||
message = "Plan angelegt.",
|
||||
)
|
||||
loadPlans(force = true)
|
||||
}
|
||||
.onFailure { error -> _state.value = _state.value.copy(saving = false, error = error.message ?: "Plan konnte nicht angelegt werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun renamePlan(name: String) {
|
||||
val id = _state.value.selectedPlanId
|
||||
if (id.isBlank() || name.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.renamePlan(id, name.trim()) }
|
||||
.onSuccess {
|
||||
_state.value = _state.value.copy(wrapper = _state.value.wrapper.updatedString("name", name.trim()), message = "Name geändert.")
|
||||
loadPlans(force = true)
|
||||
}
|
||||
.onFailure { error -> _state.value = _state.value.copy(error = error.message ?: "Umbenennen fehlgeschlagen.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun deletePlan() {
|
||||
val id = _state.value.selectedPlanId
|
||||
if (id.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.deletePlan(id) }
|
||||
.onSuccess {
|
||||
_state.value = EditorUiState(message = "Plan gelöscht.")
|
||||
loadPlans(force = true)
|
||||
}
|
||||
.onFailure { error -> _state.value = _state.value.copy(error = error.message ?: "Löschen fehlgeschlagen.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMeta(field: String, value: String) {
|
||||
val meta = _state.value.config.obj("meta").updatedString(field, value)
|
||||
mutateConfig(_state.value.config.updated("meta", meta))
|
||||
}
|
||||
|
||||
fun updateWeeks(value: Int) {
|
||||
val meta = _state.value.config.obj("meta").updatedInt("weeks", value.coerceAtLeast(1))
|
||||
mutateConfig(_state.value.config.updated("meta", meta))
|
||||
}
|
||||
|
||||
fun updatePrePost(dayNumber: Int, field: String, value: String) {
|
||||
val config = _state.value.config
|
||||
val prepost = config.obj("prepost").toMutableMap()
|
||||
val dayKey = dayNumber.toString()
|
||||
val entry = (prepost[dayKey] as? JsonObject ?: JsonObject(emptyMap())).updatedString(field, value)
|
||||
prepost[dayKey] = entry
|
||||
mutateConfig(config.updated("prepost", JsonObject(prepost)))
|
||||
}
|
||||
|
||||
fun updateDay(dayId: String, transform: (JsonObject) -> JsonObject) {
|
||||
val days = _state.value.config.array("days").map { element ->
|
||||
val day = element as? JsonObject ?: return@map element
|
||||
if (day.string("id") == dayId) transform(day) else day
|
||||
}
|
||||
mutateConfig(_state.value.config.updated("days", JsonArray(days)))
|
||||
}
|
||||
|
||||
fun addDay() {
|
||||
val config = _state.value.config
|
||||
val days = config.array("days").toMutableList()
|
||||
val number = (days.mapNotNull { (it as? JsonObject)?.int("num") }.maxOrNull() ?: 0) + 1
|
||||
days += buildJsonObject {
|
||||
put("id", stableId("day"))
|
||||
put("num", number)
|
||||
put("focus", "Neuer Trainingstag")
|
||||
put("light", false)
|
||||
put("rotations", JsonArray(emptyList()))
|
||||
}
|
||||
val prepost = config.obj("prepost").toMutableMap()
|
||||
prepost[number.toString()] = buildJsonObject {
|
||||
put("warmup", "")
|
||||
put("cooldown", "")
|
||||
put("stretch", "")
|
||||
}
|
||||
mutateConfig(config.updated("days", JsonArray(days)).updated("prepost", JsonObject(prepost)))
|
||||
}
|
||||
|
||||
fun removeDay(dayId: String) {
|
||||
val config = _state.value.config
|
||||
val removed = config.array("days").mapNotNull { it as? JsonObject }.firstOrNull { it.string("id") == dayId }
|
||||
val days = config.array("days").filterNot { (it as? JsonObject)?.string("id") == dayId }
|
||||
val prepost = config.obj("prepost").toMutableMap()
|
||||
removed?.let { prepost.remove(it.int("num").toString()) }
|
||||
mutateConfig(config.updated("days", JsonArray(days)).updated("prepost", JsonObject(prepost)))
|
||||
}
|
||||
|
||||
fun addRotation(dayId: String) {
|
||||
updateDay(dayId) { day ->
|
||||
val rotations = day.array("rotations").toMutableList()
|
||||
rotations += buildJsonObject {
|
||||
put("id", stableId("rotation"))
|
||||
put("label", "Neuer Block")
|
||||
put("exercises", JsonArray(emptyList()))
|
||||
}
|
||||
day.updated("rotations", JsonArray(rotations))
|
||||
}
|
||||
}
|
||||
|
||||
fun updateRotation(dayId: String, rotationId: String, transform: (JsonObject) -> JsonObject) {
|
||||
updateDay(dayId) { day ->
|
||||
val rotations = day.array("rotations").map { element ->
|
||||
val rotation = element as? JsonObject ?: return@map element
|
||||
if (rotation.string("id") == rotationId) transform(rotation) else rotation
|
||||
}
|
||||
day.updated("rotations", JsonArray(rotations))
|
||||
}
|
||||
}
|
||||
|
||||
fun removeRotation(dayId: String, rotationId: String) {
|
||||
updateDay(dayId) { day ->
|
||||
day.updated("rotations", JsonArray(day.array("rotations").filterNot { (it as? JsonObject)?.string("id") == rotationId }))
|
||||
}
|
||||
}
|
||||
|
||||
fun addExercise(dayId: String, rotationId: String) {
|
||||
updateRotation(dayId, rotationId) { rotation ->
|
||||
val exercises = rotation.array("exercises").toMutableList()
|
||||
val movementId = stableId("movement")
|
||||
exercises += buildJsonObject {
|
||||
put("id", stableId("exercise-placement"))
|
||||
put("legacy_id", "")
|
||||
put("exercise_id", movementId)
|
||||
put("progression_id", "")
|
||||
put("name", "Neue Übung")
|
||||
put("cue", "")
|
||||
put("movement_cluster", "general")
|
||||
put("result_schema", defaultSchema())
|
||||
}
|
||||
rotation.updated("exercises", JsonArray(exercises))
|
||||
}
|
||||
}
|
||||
|
||||
fun updateExercise(dayId: String, rotationId: String, exerciseId: String, transform: (JsonObject) -> JsonObject) {
|
||||
updateRotation(dayId, rotationId) { rotation ->
|
||||
val exercises = rotation.array("exercises").map { element ->
|
||||
val exercise = element as? JsonObject ?: return@map element
|
||||
if (exercise.string("id") == exerciseId) transform(exercise) else exercise
|
||||
}
|
||||
rotation.updated("exercises", JsonArray(exercises))
|
||||
}
|
||||
}
|
||||
|
||||
fun removeExercise(dayId: String, rotationId: String, exerciseId: String) {
|
||||
updateRotation(dayId, rotationId) { rotation ->
|
||||
rotation.updated("exercises", JsonArray(rotation.array("exercises").filterNot { (it as? JsonObject)?.string("id") == exerciseId }))
|
||||
}
|
||||
}
|
||||
|
||||
fun updateExerciseSchema(dayId: String, rotationId: String, exerciseId: String, field: String, value: JsonElement) {
|
||||
updateExercise(dayId, rotationId, exerciseId) { exercise ->
|
||||
val schema = (exercise["result_schema"] as? JsonObject ?: defaultSchema()).updated(field, value)
|
||||
exercise.updated("result_schema", schema)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateProgressionStep(progressionKey: String, stepId: String, transform: (JsonObject) -> JsonObject) {
|
||||
val stages = _state.value.config.obj("stages")
|
||||
val stage = stages[progressionKey] as? JsonObject ?: return
|
||||
val steps = stage.array("steps").map { element ->
|
||||
val step = element as? JsonObject ?: return@map element
|
||||
if (step.string("id") == stepId) transform(step) else step
|
||||
}
|
||||
val nextStages = stages.toMutableMap().apply { put(progressionKey, stage.updated("steps", JsonArray(steps))) }
|
||||
mutateConfig(_state.value.config.updated("stages", JsonObject(nextStages)))
|
||||
}
|
||||
|
||||
fun addProgressionStep(progressionKey: String) {
|
||||
val stages = _state.value.config.obj("stages")
|
||||
val stage = stages[progressionKey] as? JsonObject ?: return
|
||||
val steps = stage.array("steps").toMutableList()
|
||||
steps += buildJsonObject {
|
||||
put("id", stableId("step"))
|
||||
put("name", "Neue Stufe")
|
||||
put("phase_id", "")
|
||||
put("factor", 1.0)
|
||||
put("movement_cluster", "general")
|
||||
put("result_schema", defaultSchema())
|
||||
}
|
||||
val nextStages = stages.toMutableMap().apply { put(progressionKey, stage.updated("steps", JsonArray(steps))) }
|
||||
mutateConfig(_state.value.config.updated("stages", JsonObject(nextStages)))
|
||||
}
|
||||
|
||||
fun updateStepSchema(progressionKey: String, stepId: String, field: String, value: JsonElement) {
|
||||
updateProgressionStep(progressionKey, stepId) { step ->
|
||||
val schema = (step["result_schema"] as? JsonObject ?: defaultSchema()).updated(field, value)
|
||||
step.updated("result_schema", schema)
|
||||
}
|
||||
}
|
||||
|
||||
fun save() {
|
||||
val s = _state.value
|
||||
if (s.selectedPlanId.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
_state.value = s.copy(saving = true, error = "", conflict = null)
|
||||
runCatching { repository.savePlan(s.selectedPlanId, s.wrapper.int("revision", 1), s.config) }
|
||||
.onSuccess { body ->
|
||||
_state.value = _state.value.copy(
|
||||
saving = false,
|
||||
wrapper = body,
|
||||
config = body.obj("config"),
|
||||
validation = body.obj("validation"),
|
||||
dirty = false,
|
||||
message = "Entwurf gespeichert.",
|
||||
)
|
||||
loadPlans(force = true)
|
||||
}
|
||||
.onFailure(::handleFailure)
|
||||
}
|
||||
}
|
||||
|
||||
fun validate() {
|
||||
val s = _state.value
|
||||
if (s.selectedPlanId.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
_state.value = s.copy(saving = true, error = "")
|
||||
runCatching { repository.validatePlan(s.selectedPlanId, s.config) }
|
||||
.onSuccess { validation ->
|
||||
_state.value = _state.value.copy(
|
||||
saving = false,
|
||||
validation = validation,
|
||||
config = validation.obj("config").takeIf { it.isNotEmpty() } ?: s.config,
|
||||
message = if (validation.array("errors").isEmpty()) "Validierung erfolgreich." else "Validierung enthält Fehler.",
|
||||
)
|
||||
}
|
||||
.onFailure(::handleFailure)
|
||||
}
|
||||
}
|
||||
|
||||
fun publish() {
|
||||
val s = _state.value
|
||||
if (s.selectedPlanId.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
_state.value = s.copy(saving = true, error = "", conflict = null)
|
||||
runCatching { repository.publishPlan(s.selectedPlanId, s.wrapper.int("revision", 1)) }
|
||||
.onSuccess { body ->
|
||||
_state.value = _state.value.copy(
|
||||
saving = false,
|
||||
wrapper = body,
|
||||
config = body.obj("config"),
|
||||
validation = body.obj("validation"),
|
||||
dirty = false,
|
||||
message = "Entwurf veröffentlicht.",
|
||||
)
|
||||
loadPlans(force = true)
|
||||
}
|
||||
.onFailure(::handleFailure)
|
||||
}
|
||||
}
|
||||
|
||||
fun setProposalStatus(proposalId: String, status: String) {
|
||||
val planId = _state.value.selectedPlanId
|
||||
if (planId.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.setProposalStatus(planId, proposalId, status) }
|
||||
.onSuccess { body ->
|
||||
val proposals = _state.value.proposals.updated("proposals", body["proposals"] ?: JsonArray(emptyList()))
|
||||
_state.value = _state.value.copy(proposals = proposals, message = "Vorschlag als $status markiert.")
|
||||
}
|
||||
.onFailure { error -> _state.value = _state.value.copy(error = error.message ?: "Vorschlag konnte nicht aktualisiert werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun navigateToProposal(targetExerciseId: String) {
|
||||
_state.value = _state.value.copy(proposalTargetId = targetExerciseId)
|
||||
}
|
||||
|
||||
fun clearProposalTarget() { _state.value = _state.value.copy(proposalTargetId = "") }
|
||||
fun dismissConflict() { _state.value = _state.value.copy(conflict = null) }
|
||||
fun reloadAfterConflict() { selectPlan(_state.value.selectedPlanId) }
|
||||
|
||||
private fun mutateConfig(config: JsonObject) {
|
||||
_state.value = _state.value.copy(config = config, dirty = true, message = "Ungespeicherte Änderungen", error = "")
|
||||
}
|
||||
|
||||
private fun handleFailure(error: Throwable) {
|
||||
_state.value = if (error is RevisionConflictException) {
|
||||
_state.value.copy(saving = false, conflict = error, error = "")
|
||||
} else {
|
||||
_state.value.copy(saving = false, error = error.message ?: "Aktion fehlgeschlagen.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun stableId(prefix: String): String = "$prefix-${UUID.randomUUID().toString().replace("-", "").take(12)}"
|
||||
|
||||
private fun defaultSchema(): JsonObject = buildJsonObject {
|
||||
put("mode", "reps")
|
||||
put("weight_mode", "none")
|
||||
put("laterality", "bilateral")
|
||||
put("sides_mode", "same")
|
||||
put("sets", 3)
|
||||
put("locked_sets", false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package de.drazz.boehmitools.training.ui.setup
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CloudDone
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.drazz.boehmitools.training.data.settings.SettingsRepository
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
contentPadding: PaddingValues,
|
||||
current: SettingsRepository.PublicSettings,
|
||||
onTheme: (String) -> Unit,
|
||||
viewModel: SettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(contentPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text("Einstellungen", style = MaterialTheme.typography.headlineMedium)
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Server & Anmeldung", style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
"Basic-Auth-Zugangsdaten werden mit einem Android-Keystore-Schlüssel verschlüsselt. Release-Builds akzeptieren ausschließlich HTTPS.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.serverUrl,
|
||||
onValueChange = viewModel::updateServer,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Server-URL") },
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.username,
|
||||
onValueChange = viewModel::updateUsername,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Benutzername") },
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.password,
|
||||
onValueChange = viewModel::updatePassword,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Passwort") },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
)
|
||||
Button(onClick = viewModel::saveAndTest, enabled = !state.loading) {
|
||||
if (state.loading) CircularProgressIndicator(modifier = Modifier.height(18.dp), strokeWidth = 2.dp)
|
||||
else Icon(Icons.Default.CloudDone, null)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text("Speichern & Verbindung testen")
|
||||
}
|
||||
if (state.message.isNotBlank()) Text(state.message, color = MaterialTheme.colorScheme.primary)
|
||||
if (state.error.isNotBlank()) Text(state.error, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Darstellung", style = MaterialTheme.typography.titleLarge)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
listOf("system" to "System", "light" to "Hell", "dark" to "Dunkel").forEach { (value, label) ->
|
||||
FilterChip(
|
||||
selected = current.theme == value,
|
||||
onClick = { onTheme(value) },
|
||||
label = { Text(label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package de.drazz.boehmitools.training.ui.setup
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.drazz.boehmitools.training.data.repository.TrainingRepository
|
||||
import de.drazz.boehmitools.training.data.settings.SettingsRepository
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class SettingsUiState(
|
||||
val serverUrl: String = SettingsRepository.DEFAULT_SERVER,
|
||||
val username: String = "admin",
|
||||
val password: String = "",
|
||||
val loading: Boolean = false,
|
||||
val message: String = "",
|
||||
val error: String = "",
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val settings: SettingsRepository,
|
||||
private val repository: TrainingRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(SettingsUiState())
|
||||
val state: StateFlow<SettingsUiState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
val snapshot = settings.snapshot()
|
||||
_state.value = _state.value.copy(
|
||||
serverUrl = snapshot.serverUrl,
|
||||
username = snapshot.username,
|
||||
password = snapshot.password,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateServer(value: String) { _state.value = _state.value.copy(serverUrl = value, message = "", error = "") }
|
||||
fun updateUsername(value: String) { _state.value = _state.value.copy(username = value, message = "", error = "") }
|
||||
fun updatePassword(value: String) { _state.value = _state.value.copy(password = value, message = "", error = "") }
|
||||
|
||||
fun saveAndTest() {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, message = "", error = "")
|
||||
runCatching {
|
||||
settings.saveConnection(_state.value.serverUrl, _state.value.username, _state.value.password)
|
||||
repository.testConnection()
|
||||
}.onSuccess { health ->
|
||||
val versions = health["versions"]?.toString().orEmpty()
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
message = if (versions.isBlank()) "Verbindung erfolgreich." else "Verbindung erfolgreich · Vertrag erkannt.",
|
||||
)
|
||||
}.onFailure { error ->
|
||||
_state.value = _state.value.copy(loading = false, error = error.message ?: "Verbindung fehlgeschlagen.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package de.drazz.boehmitools.training.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
private val Light = lightColorScheme(
|
||||
primary = Color(0xFF566600),
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Color(0xFFD9F26A),
|
||||
onPrimaryContainer = Color(0xFF172000),
|
||||
secondary = Color(0xFF53643C),
|
||||
tertiary = Color(0xFF00696C),
|
||||
background = Color(0xFFF9FAF2),
|
||||
surface = Color(0xFFF9FAF2),
|
||||
surfaceVariant = Color(0xFFE5E8D8),
|
||||
)
|
||||
|
||||
private val Dark = darkColorScheme(
|
||||
primary = Color(0xFFC0D852),
|
||||
onPrimary = Color(0xFF2B3400),
|
||||
primaryContainer = Color(0xFF414D00),
|
||||
onPrimaryContainer = Color(0xFFD9F26A),
|
||||
secondary = Color(0xFFBACCA2),
|
||||
tertiary = Color(0xFF80D4D6),
|
||||
background = Color(0xFF11140E),
|
||||
surface = Color(0xFF11140E),
|
||||
surfaceVariant = Color(0xFF41443A),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun TrainingTheme(theme: String, content: @Composable () -> Unit) {
|
||||
val dark = when (theme) {
|
||||
"dark" -> true
|
||||
"light" -> false
|
||||
else -> isSystemInDarkTheme()
|
||||
}
|
||||
MaterialTheme(colorScheme = if (dark) Dark else Light, content = content)
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
package de.drazz.boehmitools.training.ui.tracker
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.weight
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.CloudOff
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import de.drazz.boehmitools.training.data.local.PendingMutation
|
||||
import de.drazz.boehmitools.training.domain.DayUi
|
||||
import de.drazz.boehmitools.training.domain.ExerciseUi
|
||||
import de.drazz.boehmitools.training.domain.PlanUi
|
||||
import de.drazz.boehmitools.training.domain.ResultSchemaUi
|
||||
import de.drazz.boehmitools.training.domain.array
|
||||
import de.drazz.boehmitools.training.domain.bool
|
||||
import de.drazz.boehmitools.training.domain.emptyResultData
|
||||
import de.drazz.boehmitools.training.domain.formatResultData
|
||||
import de.drazz.boehmitools.training.domain.int
|
||||
import de.drazz.boehmitools.training.domain.itemFor
|
||||
import de.drazz.boehmitools.training.domain.normalizedResultData
|
||||
import de.drazz.boehmitools.training.domain.obj
|
||||
import de.drazz.boehmitools.training.domain.resolveResultSchema
|
||||
import de.drazz.boehmitools.training.domain.resultDataWithSets
|
||||
import de.drazz.boehmitools.training.domain.resultDataWithValue
|
||||
import de.drazz.boehmitools.training.domain.string
|
||||
import de.drazz.boehmitools.training.domain.trackerSession
|
||||
import de.drazz.boehmitools.training.domain.updated
|
||||
import de.drazz.boehmitools.training.domain.updatedString
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TrackerScreen(
|
||||
contentPadding: PaddingValues,
|
||||
viewModel: TrackerViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val pending by viewModel.pending.collectAsStateWithLifecycle()
|
||||
val currentPending = pending.filter { it.planId == state.selectedPlanId }
|
||||
var planMenu by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.padding(contentPadding),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(state.plan?.title ?: "boehmitools Training", maxLines = 2)
|
||||
if (state.saveState.isNotBlank()) Text(state.saveState, style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
Box {
|
||||
IconButton(onClick = { planMenu = true }) {
|
||||
Icon(Icons.Default.MoreVert, contentDescription = "Plan wechseln")
|
||||
}
|
||||
DropdownMenu(expanded = planMenu, onDismissRequest = { planMenu = false }) {
|
||||
state.plans.forEach { plan ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(plan.title) },
|
||||
onClick = { planMenu = false; viewModel.selectPlan(plan.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { inner ->
|
||||
Column(Modifier.fillMaxSize().padding(inner)) {
|
||||
val tabs = listOf("session" to "Session", "progress" to "Fortschritt", "plan" to "Plan", "faq" to "FAQ")
|
||||
ScrollableTabRow(selectedTabIndex = tabs.indexOfFirst { it.first == state.tab }.coerceAtLeast(0)) {
|
||||
tabs.forEach { (id, label) ->
|
||||
Tab(selected = state.tab == id, onClick = { viewModel.setTab(id) }, text = { Text(label) })
|
||||
}
|
||||
}
|
||||
when {
|
||||
state.loading && state.plan == null -> Box(Modifier.fillMaxSize().padding(32.dp)) { CircularProgressIndicator() }
|
||||
state.error.isNotBlank() && state.plan == null -> ErrorPane(state.error) { viewModel.loadPlans(force = true) }
|
||||
state.plan == null -> ErrorPane("Kein Trainingsplan verfügbar.") { viewModel.loadPlans(force = true) }
|
||||
else -> when (state.tab) {
|
||||
"progress" -> ProgressPane(state, currentPending, viewModel)
|
||||
"plan" -> PlanPane(state.plan!!)
|
||||
"faq" -> FaqPane(state)
|
||||
else -> SessionPane(state, currentPending, viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.conflict?.let { conflict ->
|
||||
AlertDialog(
|
||||
onDismissRequest = viewModel::dismissConflict,
|
||||
title = { Text("Revisionskonflikt") },
|
||||
text = { Text("Der Server steht bereits auf Revision ${conflict.currentRevision}. Deine lokale Änderung wird nicht automatisch überschrieben. Lade den Serverstand neu und wende die Änderung bewusst erneut an.") },
|
||||
confirmButton = { TextButton(onClick = viewModel::reloadAfterConflict) { Text("Serverstand neu laden") } },
|
||||
dismissButton = { TextButton(onClick = viewModel::dismissConflict) { Text("Lokale Ansicht behalten") } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorPane(message: String, retry: () -> Unit) {
|
||||
Column(Modifier.fillMaxSize().padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(message, color = MaterialTheme.colorScheme.error)
|
||||
Button(onClick = retry) { Icon(Icons.Default.Refresh, null); Spacer(Modifier.width(8.dp)); Text("Neu laden") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionPane(state: TrackerUiState, pending: List<PendingMutation>, vm: TrackerViewModel) {
|
||||
val plan = state.plan ?: return
|
||||
val day = plan.days.firstOrNull { it.num == state.day } ?: return
|
||||
val session = trackerSession(state.tracker, de.drazz.boehmitools.training.domain.sessionKey(state.week, state.day), plan)
|
||||
Column(
|
||||
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (state.error.isNotBlank()) Text(state.error, color = MaterialTheme.colorScheme.error)
|
||||
if (pending.isNotEmpty()) PendingQueue(pending, vm)
|
||||
WeekDayPicker(plan, state.week, state.day, vm)
|
||||
SessionHeader(plan, day, session, state.week, vm)
|
||||
|
||||
SequenceCard("Aufwärmen", "warmup", day.warmup, session, vm)
|
||||
day.rotations.forEach { rotation ->
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(rotation.label, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
rotation.exercises.forEach { exercise ->
|
||||
ExerciseCard(exercise, session, plan, vm)
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SequenceCard("Cooldown", "cooldown", day.cooldown, session, vm)
|
||||
SequenceCard("Stretch", "stretch", day.stretch, session, vm)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PendingQueue(pending: List<PendingMutation>, vm: TrackerViewModel) {
|
||||
OutlinedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Default.CloudOff, null)
|
||||
Text("${pending.size} ausstehende Änderung(en)", fontWeight = FontWeight.Bold)
|
||||
}
|
||||
pending.take(3).forEach { item ->
|
||||
Text("${item.sessionKey} · Revision ${item.expectedRevision} · ${item.state}", style = MaterialTheme.typography.bodySmall)
|
||||
if (item.state == PendingMutation.STATE_CONFLICT) {
|
||||
Text(item.lastError, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
TextButton(onClick = { vm.discardQueuedConflict(item.id) }) { Text("Konflikt-Patch verwerfen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WeekDayPicker(plan: PlanUi, week: Int, day: Int, vm: TrackerViewModel) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
(1..plan.weeks).forEach { value -> FilterChip(selected = value == week, onClick = { vm.setWeek(value) }, label = { Text("W$value") }) }
|
||||
}
|
||||
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
plan.days.forEach { value -> FilterChip(selected = value.num == day, onClick = { vm.setDay(value.num) }, label = { Text("Tag ${value.num}") }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SessionHeader(plan: PlanUi, day: DayUi, session: JsonObject, week: Int, vm: TrackerViewModel) {
|
||||
val status = session.string("status", "planned")
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Woche $week · Tag ${day.num}", style = MaterialTheme.typography.labelLarge)
|
||||
Text(day.focus, style = MaterialTheme.typography.headlineSmall)
|
||||
Text("Planrevision R${session.int("plan_revision", plan.publishedRevision)} · Status: $status", style = MaterialTheme.typography.bodySmall)
|
||||
when (status) {
|
||||
"in_progress" -> Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = { vm.setSessionStatus("completed") }) { Icon(Icons.Default.CheckCircle, null); Spacer(Modifier.width(6.dp)); Text("Abschließen") }
|
||||
OutlinedButton(onClick = { vm.setSessionStatus("stopped") }) { Icon(Icons.Default.Stop, null); Spacer(Modifier.width(6.dp)); Text("Stoppen") }
|
||||
}
|
||||
"stopped" -> Button(onClick = { vm.setSessionStatus("in_progress") }) { Icon(Icons.Default.PlayArrow, null); Spacer(Modifier.width(6.dp)); Text("Fortsetzen") }
|
||||
"completed" -> OutlinedButton(onClick = { vm.setSessionStatus("in_progress") }) { Text("Wieder öffnen") }
|
||||
else -> Button(onClick = { vm.setSessionStatus("in_progress") }) { Icon(Icons.Default.PlayArrow, null); Spacer(Modifier.width(6.dp)); Text("Session starten") }
|
||||
}
|
||||
TextButton(onClick = vm::resetSession) { Text("Session zurücksetzen") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SequenceCard(title: String, prefix: String, items: List<String>, session: JsonObject, vm: TrackerViewModel) {
|
||||
if (items.isEmpty()) return
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
items.forEachIndexed { index, label ->
|
||||
val id = "$prefix-$index"
|
||||
val item = itemFor(session, id)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Checkbox(checked = item.string("completion_status") == "completed" || item.bool("done"), onCheckedChange = { vm.setSequenceStatus(id, it) })
|
||||
Text(label, modifier = Modifier.padding(top = 12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExerciseCard(exercise: ExerciseUi, session: JsonObject, plan: PlanUi, vm: TrackerViewModel) {
|
||||
val item = itemFor(session, exercise.id)
|
||||
val status = item.string("completion_status", if (item.bool("done")) "completed" else "planned")
|
||||
val progression = item.string("progression")
|
||||
val progressionModel = plan.progressions[exercise.progressionId]
|
||||
val schema = resolveResultSchema(exercise, item, progression, plan)
|
||||
val data = normalizedResultData(item, schema)
|
||||
var progressionMenu by remember(exercise.id, progression) { mutableStateOf(false) }
|
||||
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(exercise.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
if (exercise.cue.isNotBlank()) Text(exercise.cue, style = MaterialTheme.typography.bodySmall)
|
||||
Text("ID ${exercise.id}", style = MaterialTheme.typography.labelSmall)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Status", style = MaterialTheme.typography.labelMedium)
|
||||
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
listOf("planned" to "Offen", "completed" to "Erledigt", "partial" to "Teilweise", "skipped" to "Übersprungen").forEach { (value, label) ->
|
||||
FilterChip(selected = status == value, onClick = { vm.setItemStatus(exercise.id, value) }, label = { Text(label) })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (status == "skipped") {
|
||||
OutlinedTextField(
|
||||
value = item.string("skip_reason"),
|
||||
onValueChange = { vm.setItemText(exercise.id, "skip_reason", it) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Grund") },
|
||||
)
|
||||
} else {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
OutlinedTextField(
|
||||
value = progression,
|
||||
onValueChange = { vm.setItemProgression(exercise.id, it) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Progressionsstufe") },
|
||||
supportingText = { Text("Stufe überschreibt bei Bedarf das Ergebnisschema") },
|
||||
)
|
||||
if (progressionModel?.steps?.isNotEmpty() == true) {
|
||||
Box {
|
||||
OutlinedButton(onClick = { progressionMenu = true }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Stufe aus Plan auswählen")
|
||||
}
|
||||
DropdownMenu(expanded = progressionMenu, onDismissRequest = { progressionMenu = false }) {
|
||||
progressionModel.steps.forEach { step ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(step.name) },
|
||||
onClick = { progressionMenu = false; vm.setItemProgression(exercise.id, step.name, step.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ManualResultFormatEditor(
|
||||
inherited = schema,
|
||||
manualOverride = item["result_format"] as? JsonObject,
|
||||
onChange = { vm.setItemResultFormat(exercise.id, it) },
|
||||
)
|
||||
ResultEditor(schema, data) { vm.setItemResultData(exercise.id, it) }
|
||||
OutlinedTextField(
|
||||
value = item.string("note"),
|
||||
onValueChange = { vm.setItemText(exercise.id, "note", it) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Sessionnotiz") },
|
||||
minLines = 2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
private fun ManualResultFormatEditor(
|
||||
inherited: ResultSchemaUi,
|
||||
manualOverride: JsonObject?,
|
||||
onChange: (JsonObject?) -> Unit,
|
||||
) {
|
||||
var expanded by remember(manualOverride) { mutableStateOf(false) }
|
||||
val mode = manualOverride?.string("mode", inherited.mode) ?: inherited.mode
|
||||
val weightMode = manualOverride?.string("weight_mode", inherited.weightMode) ?: inherited.weightMode
|
||||
val laterality = manualOverride?.string("laterality", inherited.laterality) ?: inherited.laterality
|
||||
val sidesMode = if (laterality == "unilateral") {
|
||||
manualOverride?.string("sides_mode", inherited.sidesMode) ?: inherited.sidesMode
|
||||
} else {
|
||||
"same"
|
||||
}
|
||||
|
||||
fun changed(field: String, value: String): JsonObject {
|
||||
val values = linkedMapOf<String, kotlinx.serialization.json.JsonElement>(
|
||||
"mode" to JsonPrimitive(mode),
|
||||
"weight_mode" to JsonPrimitive(weightMode),
|
||||
"laterality" to JsonPrimitive(laterality),
|
||||
"sides_mode" to JsonPrimitive(sidesMode),
|
||||
)
|
||||
values[field] = JsonPrimitive(value)
|
||||
if ((values["laterality"] as JsonPrimitive).content == "bilateral") {
|
||||
values["sides_mode"] = JsonPrimitive("same")
|
||||
}
|
||||
return JsonObject(values)
|
||||
}
|
||||
|
||||
OutlinedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Manuelle Session-Anpassung", style = MaterialTheme.typography.labelLarge)
|
||||
Text(
|
||||
if (manualOverride == null) "Vererbt: ${inherited.source}" else "Überschreibt Progressionsstufe und Übung",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
TextButton(onClick = { expanded = !expanded }) { Text(if (expanded) "Schließen" else "Anpassen") }
|
||||
}
|
||||
if (expanded) {
|
||||
ChoiceChips("Messwert", listOf("reps", "seconds", "minutes", "none"), mode) { onChange(changed("mode", it)) }
|
||||
ChoiceChips("Gewicht", listOf("none", "optional", "required"), weightMode) { onChange(changed("weight_mode", it)) }
|
||||
ChoiceChips("Ausführung", listOf("bilateral", "unilateral"), laterality) { onChange(changed("laterality", it)) }
|
||||
if (laterality == "unilateral") {
|
||||
ChoiceChips("Seitenwerte", listOf("same", "separate"), sidesMode) { onChange(changed("sides_mode", it)) }
|
||||
}
|
||||
if (manualOverride != null) {
|
||||
OutlinedButton(onClick = { onChange(null) }, modifier = Modifier.fillMaxWidth()) {
|
||||
Text("Manuelle Anpassung zurücksetzen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChoiceChips(label: String, choices: List<String>, selected: String, onSelect: (String) -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(label, style = MaterialTheme.typography.labelMedium)
|
||||
choices.forEach { value ->
|
||||
FilterChip(
|
||||
selected = selected == value,
|
||||
onClick = { onSelect(value) },
|
||||
label = { Text(value) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultEditor(schema: ResultSchemaUi, data: JsonObject, onChange: (JsonObject) -> Unit) {
|
||||
OutlinedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Ergebnis · ${schema.source}", style = MaterialTheme.typography.labelLarge)
|
||||
Text("${schema.mode}, ${schema.laterality}, Gewicht ${schema.weightMode}", style = MaterialTheme.typography.bodySmall)
|
||||
if (schema.mode == "none") {
|
||||
Text("Für diese Stufe ist kein numerischer Messwert vorgesehen.")
|
||||
return@Column
|
||||
}
|
||||
if (schema.weightMode != "none") {
|
||||
val weight = data["weight_kg"]?.jsonPrimitive?.doubleOrNull?.toString().orEmpty()
|
||||
OutlinedTextField(
|
||||
value = weight,
|
||||
onValueChange = { raw -> onChange(data.updated("weight_kg", raw.replace(',', '.').toDoubleOrNull()?.let(::JsonPrimitive) ?: JsonNull)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text(if (schema.weightMode == "required") "Gewicht kg (Pflicht)" else "Gewicht kg (optional)") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
if (!schema.lockedSets) {
|
||||
OutlinedTextField(
|
||||
value = data.int("sets", schema.defaultSets).toString(),
|
||||
onValueChange = { raw -> raw.toIntOrNull()?.let { onChange(resultDataWithSets(data, it)) } },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Sätze") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
val count = data.int("sets", schema.defaultSets).coerceIn(1, 20)
|
||||
repeat(count) { index ->
|
||||
if (data.string("laterality", schema.laterality) == "unilateral" && data.string("sides_mode", schema.sidesMode) == "separate") {
|
||||
OutlinedTextField(
|
||||
value = data.array("left_values").getOrNull(index)?.jsonPrimitive?.doubleOrNull?.toString().orEmpty(),
|
||||
onValueChange = { raw -> onChange(resultDataWithValue(data, "left_values", index, raw.replace(',', '.').toDoubleOrNull())) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Satz ${index + 1} links") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
singleLine = true,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = data.array("right_values").getOrNull(index)?.jsonPrimitive?.doubleOrNull?.toString().orEmpty(),
|
||||
onValueChange = { raw -> onChange(resultDataWithValue(data, "right_values", index, raw.replace(',', '.').toDoubleOrNull())) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Satz ${index + 1} rechts") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
singleLine = true,
|
||||
)
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
value = data.array("values").getOrNull(index)?.jsonPrimitive?.doubleOrNull?.toString().orEmpty(),
|
||||
onValueChange = { raw -> onChange(resultDataWithValue(data, "values", index, raw.replace(',', '.').toDoubleOrNull())) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Satz ${index + 1}${if (schema.laterality == "unilateral") " je Seite" else ""}") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text("Gespeichert als: ${formatResultData(data)}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProgressPane(state: TrackerUiState, pending: List<PendingMutation>, vm: TrackerViewModel) {
|
||||
val plan = state.plan ?: return
|
||||
val sessions = state.tracker.obj("sessions").values.mapNotNull { it as? JsonObject }
|
||||
val completed = sessions.count { it.string("status") == "completed" }
|
||||
val stopped = sessions.count { it.string("status") == "stopped" }
|
||||
val analysisState = state.response.obj("analysis_state").takeIf { it.isNotEmpty() }
|
||||
?: state.tracker.obj("analysis_state")
|
||||
val running = analysisState.string("status") == "running"
|
||||
val cache = state.response.obj("analysis_cache").takeIf { it.isNotEmpty() }
|
||||
?: state.tracker.obj("analysis_cache")
|
||||
val weekRecord = cache.obj("weeks")[state.week.toString()] as? JsonObject
|
||||
val overall = cache["overall"] as? JsonObject
|
||||
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Fortschritt", style = MaterialTheme.typography.headlineSmall)
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Lokale Übersicht", style = MaterialTheme.typography.titleMedium)
|
||||
Text("$completed abgeschlossene Sessions · $stopped gestoppte Sessions · ${sessions.size} angelegte Sessions")
|
||||
Text("Trackerrevision ${state.tracker.int("revision", 1)} · Planrevision ${plan.publishedRevision}")
|
||||
}
|
||||
}
|
||||
if (pending.isNotEmpty()) PendingQueue(pending, vm)
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Wochenstatus", style = MaterialTheme.typography.titleMedium)
|
||||
val closed = state.tracker.obj("week_statuses").obj(state.week.toString()).string("status") == "closed"
|
||||
FilterChip(selected = closed, onClick = { vm.setWeekClosed(!closed) }, label = { Text(if (closed) "Woche ${state.week} abgeschlossen" else "Woche ${state.week} läuft") })
|
||||
Text("Eine laufende Woche erzeugt eine Zwischenanalyse, eine geschlossene Woche eine Abschlussanalyse.", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text("Manuelle Analyse", style = MaterialTheme.typography.titleMedium)
|
||||
Button(onClick = { vm.startAnalysis("week", state.week) }, enabled = !running) { Text("Woche ${state.week} analysieren") }
|
||||
OutlinedButton(onClick = { vm.startAnalysis("overall") }, enabled = !running) { Text("Gesamtanalyse") }
|
||||
if (running) {
|
||||
CircularProgressIndicator()
|
||||
Text(analysisState.string("message", "Analysejob läuft …"))
|
||||
} else if (state.analysisMessage.isNotBlank()) Text(state.analysisMessage)
|
||||
if (state.error.isNotBlank()) Text(state.error, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
AnalysisCard("Analyse Woche ${state.week}", weekRecord)
|
||||
AnalysisCard("Gesamtanalyse", overall)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AnalysisCard(title: String, record: JsonObject?) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
if (record == null) {
|
||||
Text("Noch keine aktuelle Analyse vorhanden.")
|
||||
} else {
|
||||
val response = record.obj("response")
|
||||
Text(response.string("headline", "Analyse"), style = MaterialTheme.typography.titleLarge)
|
||||
Text(response.string("summary"))
|
||||
response.array("metrics").mapNotNull { it as? JsonObject }.forEach { metric ->
|
||||
AssistChip(onClick = {}, label = { Text("${metric.string("label")}: ${metric.string("value")}") })
|
||||
}
|
||||
response.array("exercise_updates").mapNotNull { it as? JsonObject }.take(6).forEach { update ->
|
||||
OutlinedCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Text(update.string("name"), fontWeight = FontWeight.Bold)
|
||||
Text(update.string("next_action"))
|
||||
if (update.string("criterion").isNotBlank()) Text("Kriterium: ${update.string("criterion")}", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlanPane(plan: PlanUi) {
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(plan.title, style = MaterialTheme.typography.headlineSmall)
|
||||
Text(plan.subtitle)
|
||||
Text("${plan.weeks} Wochen · veröffentlichte Revision ${plan.publishedRevision}")
|
||||
plan.days.forEach { day ->
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Tag ${day.num}: ${day.focus}", style = MaterialTheme.typography.titleMedium)
|
||||
day.rotations.forEach { rotation ->
|
||||
Text(rotation.label, fontWeight = FontWeight.Bold)
|
||||
rotation.exercises.forEach { exercise -> Text("• ${exercise.name}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FaqPane(state: TrackerUiState) {
|
||||
val plan = state.plan ?: return
|
||||
val guide = state.response.obj("equivalence_guide")
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("FAQ & Variantencluster", style = MaterialTheme.typography.headlineSmall)
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Trainingsformat", style = MaterialTheme.typography.titleMedium)
|
||||
Text("Modus: ${plan.trainingFormat.string("mode", "sets_reps")}")
|
||||
if (plan.trainingFormat.bool("fixed_interval")) {
|
||||
Text("Festes Intervall: ${plan.trainingFormat.int("work_seconds")} s Arbeit / ${plan.trainingFormat.int("rest_seconds")} s Pause · ${plan.trainingFormat.int("rounds")} Intervalle pro Block")
|
||||
Text("Die App implementiert bewusst keinen Timer und verändert dieses Format nicht.")
|
||||
}
|
||||
}
|
||||
}
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Planbezogene Übungsbibliothek", style = MaterialTheme.typography.titleMedium)
|
||||
plan.exerciseCatalog.values.mapNotNull { it as? JsonObject }.take(100).forEach { entry ->
|
||||
Text("${entry.string("movement_label", entry.string("name"))} · ${entry.array("variants").size} Variante(n)")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (guide.isNotEmpty()) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text("Fallbackregeln des Servers", style = MaterialTheme.typography.titleMedium)
|
||||
Text(guide.toString(), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+433
@@ -0,0 +1,433 @@
|
||||
package de.drazz.boehmitools.training.ui.tracker
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import de.drazz.boehmitools.training.data.local.PendingMutation
|
||||
import de.drazz.boehmitools.training.data.repository.ApiException
|
||||
import de.drazz.boehmitools.training.data.repository.RevisionConflictException
|
||||
import de.drazz.boehmitools.training.data.repository.SaveOutcome
|
||||
import de.drazz.boehmitools.training.data.repository.TrainingRepository
|
||||
import de.drazz.boehmitools.training.data.settings.SettingsRepository
|
||||
import de.drazz.boehmitools.training.domain.DayUi
|
||||
import de.drazz.boehmitools.training.domain.adaptResultDataToSchema
|
||||
import de.drazz.boehmitools.training.domain.PlanUi
|
||||
import de.drazz.boehmitools.training.domain.blankSession
|
||||
import de.drazz.boehmitools.training.domain.formatResultData
|
||||
import de.drazz.boehmitools.training.domain.int
|
||||
import de.drazz.boehmitools.training.domain.migrateLegacyItems
|
||||
import de.drazz.boehmitools.training.domain.mergeResultSchemaLayers
|
||||
import de.drazz.boehmitools.training.domain.obj
|
||||
import de.drazz.boehmitools.training.domain.overlaySessionPatches
|
||||
import de.drazz.boehmitools.training.domain.parsePlan
|
||||
import de.drazz.boehmitools.training.domain.resolveResultSchema
|
||||
import de.drazz.boehmitools.training.domain.sessionKey
|
||||
import de.drazz.boehmitools.training.domain.sessionPatch
|
||||
import de.drazz.boehmitools.training.domain.string
|
||||
import de.drazz.boehmitools.training.domain.trackerSession
|
||||
import de.drazz.boehmitools.training.domain.updateItem
|
||||
import de.drazz.boehmitools.training.domain.updateTrackerSession
|
||||
import de.drazz.boehmitools.training.domain.updated
|
||||
import de.drazz.boehmitools.training.domain.updatedBool
|
||||
import de.drazz.boehmitools.training.domain.updatedInt
|
||||
import de.drazz.boehmitools.training.domain.updatedString
|
||||
import java.time.Instant
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
data class TrackerPlanSummary(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val weeks: Int,
|
||||
val completedSessions: Int,
|
||||
val sourceChanged: Boolean,
|
||||
)
|
||||
|
||||
data class TrackerUiState(
|
||||
val loading: Boolean = true,
|
||||
val error: String = "",
|
||||
val plans: List<TrackerPlanSummary> = emptyList(),
|
||||
val selectedPlanId: String = "",
|
||||
val response: JsonObject = JsonObject(emptyMap()),
|
||||
val plan: PlanUi? = null,
|
||||
val tracker: JsonObject = JsonObject(emptyMap()),
|
||||
val tab: String = "session",
|
||||
val week: Int = 1,
|
||||
val day: Int = 1,
|
||||
val saveState: String = "",
|
||||
val conflict: RevisionConflictException? = null,
|
||||
val analysisMessage: String = "",
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class TrackerViewModel @Inject constructor(
|
||||
private val repository: TrainingRepository,
|
||||
private val settings: SettingsRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(TrackerUiState())
|
||||
val state: StateFlow<TrackerUiState> = _state.asStateFlow()
|
||||
val pending = repository.pendingMutations().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
private var saveJob: Job? = null
|
||||
private var pollJob: Job? = null
|
||||
private val saveMutex = Mutex()
|
||||
private val wireJson = Json { ignoreUnknownKeys = true; explicitNulls = false }
|
||||
|
||||
init { loadPlans() }
|
||||
|
||||
fun loadPlans(force: Boolean = false) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = "")
|
||||
runCatching { repository.trackerPlans(force) }
|
||||
.onSuccess { payload ->
|
||||
val plans = payload["plans"]?.jsonArray?.mapNotNull { it as? JsonObject }?.map { item ->
|
||||
TrackerPlanSummary(
|
||||
id = item.string("id"),
|
||||
title = item.string("title", item.string("name")),
|
||||
subtitle = item.string("subtitle"),
|
||||
weeks = item.int("weeks", 1),
|
||||
completedSessions = item.int("completed_sessions"),
|
||||
sourceChanged = item["source_changed"]?.jsonPrimitive?.content == "true",
|
||||
)
|
||||
}.orEmpty()
|
||||
val preferred = settings.selectedTrackerPlan()
|
||||
val selected = preferred?.takeIf { id -> plans.any { it.id == id } }
|
||||
?: payload.string("selected").takeIf { id -> plans.any { it.id == id } }
|
||||
?: plans.firstOrNull()?.id.orEmpty()
|
||||
_state.value = _state.value.copy(plans = plans, loading = false)
|
||||
if (selected.isNotBlank()) selectPlan(selected, persistServer = false)
|
||||
}
|
||||
.onFailure { error -> _state.value = _state.value.copy(loading = false, error = error.message ?: "Planliste konnte nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun selectPlan(planId: String, persistServer: Boolean = true) {
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(loading = true, error = "", conflict = null)
|
||||
runCatching {
|
||||
if (persistServer) repository.selectTrackerPlan(planId)
|
||||
settings.setSelectedTrackerPlan(planId)
|
||||
val ui = settings.trackerUi(planId)
|
||||
Triple(repository.trackerPlan(planId, force = true), ui, repository.pendingForPlan(planId))
|
||||
}.onSuccess { (response, ui, queued) ->
|
||||
val plan = parsePlan(response.obj("plan"))
|
||||
var tracker = overlayPendingPatches(response.obj("tracker"), queued)
|
||||
val week = ui.week.coerceIn(1, plan.weeks)
|
||||
val day = ui.day.takeIf { candidate -> plan.days.any { it.num == candidate } } ?: plan.days.firstOrNull()?.num ?: 1
|
||||
val currentDay = plan.days.firstOrNull { it.num == day }
|
||||
if (currentDay != null) {
|
||||
val key = sessionKey(week, day)
|
||||
val (migrated, changed) = migrateLegacyItems(trackerSession(tracker, key, plan), currentDay)
|
||||
if (changed) tracker = updateTrackerSession(tracker, key, migrated)
|
||||
}
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
selectedPlanId = planId,
|
||||
response = response,
|
||||
plan = plan,
|
||||
tracker = tracker,
|
||||
tab = ui.tab,
|
||||
week = week,
|
||||
day = day,
|
||||
saveState = "",
|
||||
)
|
||||
if (response.obj("tracker").obj("analysis_state").string("status") == "running") startPolling()
|
||||
}.onFailure { error -> _state.value = _state.value.copy(loading = false, error = error.message ?: "Plan konnte nicht geladen werden.") }
|
||||
}
|
||||
}
|
||||
|
||||
fun setTab(tab: String) {
|
||||
_state.value = _state.value.copy(tab = tab)
|
||||
persistUi()
|
||||
}
|
||||
|
||||
fun setWeek(week: Int) {
|
||||
val plan = _state.value.plan ?: return
|
||||
_state.value = _state.value.copy(week = week.coerceIn(1, plan.weeks))
|
||||
migrateCurrentLegacy()
|
||||
persistUi()
|
||||
}
|
||||
|
||||
fun setDay(day: Int) {
|
||||
val plan = _state.value.plan ?: return
|
||||
if (plan.days.none { it.num == day }) return
|
||||
_state.value = _state.value.copy(day = day)
|
||||
migrateCurrentLegacy()
|
||||
persistUi()
|
||||
}
|
||||
|
||||
private fun persistUi() {
|
||||
val s = _state.value
|
||||
if (s.selectedPlanId.isBlank()) return
|
||||
viewModelScope.launch { settings.saveTrackerUi(s.selectedPlanId, s.tab, s.week, s.day) }
|
||||
}
|
||||
|
||||
fun currentDay(): DayUi? = _state.value.plan?.days?.firstOrNull { it.num == _state.value.day }
|
||||
fun currentSession(): JsonObject? {
|
||||
val s = _state.value
|
||||
val plan = s.plan ?: return null
|
||||
return trackerSession(s.tracker, sessionKey(s.week, s.day), plan)
|
||||
}
|
||||
|
||||
fun setSessionStatus(status: String) {
|
||||
mutateSession { session ->
|
||||
var next = session.updatedString("status", status)
|
||||
val now = Instant.now().toString()
|
||||
when (status) {
|
||||
"in_progress" -> if (session.string("started_at").isBlank()) next = next.updatedString("started_at", now)
|
||||
"stopped" -> next = next.updatedString("stopped_at", now)
|
||||
"completed" -> next = next.updatedString("completed_at", now)
|
||||
}
|
||||
next
|
||||
}
|
||||
}
|
||||
|
||||
fun resetSession() {
|
||||
val plan = _state.value.plan ?: return
|
||||
val key = sessionKey(_state.value.week, _state.value.day)
|
||||
val tracker = updateTrackerSession(_state.value.tracker, key, blankSession(plan))
|
||||
_state.value = _state.value.copy(tracker = tracker)
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
fun setSequenceStatus(id: String, done: Boolean) {
|
||||
mutateSession { session ->
|
||||
val item = de.drazz.boehmitools.training.domain.itemFor(session, id)
|
||||
.updatedString("completion_status", if (done) "completed" else "planned")
|
||||
.updatedBool("done", done)
|
||||
updateItem(session, id, item)
|
||||
}
|
||||
}
|
||||
|
||||
fun setItemStatus(id: String, status: String) {
|
||||
mutateSession { session ->
|
||||
var item = de.drazz.boehmitools.training.domain.itemFor(session, id)
|
||||
.updatedString("completion_status", status)
|
||||
.updatedBool("done", status == "completed" || status == "partial")
|
||||
if (status != "skipped") item = item.updated("skip_reason", null)
|
||||
updateItem(session, id, item)
|
||||
}
|
||||
}
|
||||
|
||||
fun setItemProgression(id: String, value: String, stepId: String = "") {
|
||||
val plan = _state.value.plan ?: return
|
||||
val exercise = currentDay()?.rotations?.flatMap { it.exercises }?.firstOrNull { it.id == id }
|
||||
mutateSession { session ->
|
||||
var item = de.drazz.boehmitools.training.domain.itemFor(session, id).updatedString("progression", value)
|
||||
item = if (stepId.isBlank()) item.updated("progression_step_id", null) else item.updatedString("progression_step_id", stepId)
|
||||
val current = item["result_data"] as? JsonObject
|
||||
if (exercise != null && current != null) {
|
||||
val schema = resolveResultSchema(exercise, item, value, plan)
|
||||
val adapted = adaptResultDataToSchema(current, schema)
|
||||
item = if (adapted == null) {
|
||||
item.updated("result_data", null).updatedString("result", "")
|
||||
} else {
|
||||
item.updated("result_data", adapted).updatedString("result", formatResultData(adapted))
|
||||
}
|
||||
}
|
||||
updateItem(session, id, item)
|
||||
}
|
||||
}
|
||||
|
||||
fun setItemText(id: String, field: String, value: String) {
|
||||
mutateSession { session ->
|
||||
val item = de.drazz.boehmitools.training.domain.itemFor(session, id).updatedString(field, value)
|
||||
updateItem(session, id, item)
|
||||
}
|
||||
}
|
||||
|
||||
fun setItemResultData(id: String, data: JsonObject) {
|
||||
mutateSession { session ->
|
||||
val item = de.drazz.boehmitools.training.domain.itemFor(session, id)
|
||||
.updated("result_data", data)
|
||||
.updatedString("result", de.drazz.boehmitools.training.domain.formatResultData(data))
|
||||
updateItem(session, id, item)
|
||||
}
|
||||
}
|
||||
|
||||
fun setItemResultFormat(id: String, format: JsonObject?) {
|
||||
mutateSession { session ->
|
||||
var item = de.drazz.boehmitools.training.domain.itemFor(session, id)
|
||||
.updated("result_format", format)
|
||||
val current = item["result_data"] as? JsonObject
|
||||
if (format == null) {
|
||||
item = item.updated("result_data", null).updatedString("result", "")
|
||||
return@mutateSession updateItem(session, id, item)
|
||||
}
|
||||
|
||||
val nextMode = format.string("mode", "reps")
|
||||
if (nextMode == "none") {
|
||||
item = item.updated("result_data", null).updatedString("result", "")
|
||||
return@mutateSession updateItem(session, id, item)
|
||||
}
|
||||
|
||||
if (current != null) {
|
||||
val schema = mergeResultSchemaLayers(null, null, format)
|
||||
val adapted = adaptResultDataToSchema(current, schema)
|
||||
item = if (adapted == null) {
|
||||
item.updated("result_data", null).updatedString("result", "")
|
||||
} else {
|
||||
item.updated("result_data", adapted).updatedString("result", formatResultData(adapted))
|
||||
}
|
||||
}
|
||||
updateItem(session, id, item)
|
||||
}
|
||||
}
|
||||
|
||||
fun setWeekClosed(closed: Boolean) {
|
||||
val s = _state.value
|
||||
val statuses = s.tracker.obj("week_statuses").toMutableMap()
|
||||
statuses[s.week.toString()] = buildJsonObject {
|
||||
put("status", if (closed) "closed" else "open")
|
||||
put("updated_at", Instant.now().toString())
|
||||
}
|
||||
val tracker = s.tracker.updated("week_statuses", JsonObject(statuses))
|
||||
_state.value = s.copy(tracker = tracker)
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
private fun mutateSession(block: (JsonObject) -> JsonObject) {
|
||||
val s = _state.value
|
||||
val plan = s.plan ?: return
|
||||
val key = sessionKey(s.week, s.day)
|
||||
val nextSession = block(trackerSession(s.tracker, key, plan))
|
||||
_state.value = s.copy(tracker = updateTrackerSession(s.tracker, key, nextSession), saveState = "Ungespeichert")
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
private fun migrateCurrentLegacy() {
|
||||
val s = _state.value
|
||||
val plan = s.plan ?: return
|
||||
val day = plan.days.firstOrNull { it.num == s.day } ?: return
|
||||
val key = sessionKey(s.week, s.day)
|
||||
val (session, changed) = migrateLegacyItems(trackerSession(s.tracker, key, plan), day)
|
||||
if (changed) {
|
||||
_state.value = s.copy(tracker = updateTrackerSession(s.tracker, key, session))
|
||||
scheduleSave()
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleSave() {
|
||||
saveJob?.cancel()
|
||||
saveJob = viewModelScope.launch {
|
||||
delay(650)
|
||||
saveCurrent()
|
||||
}
|
||||
}
|
||||
|
||||
fun saveCurrent() {
|
||||
viewModelScope.launch {
|
||||
saveMutex.withLock {
|
||||
val snapshot = _state.value
|
||||
val plan = snapshot.plan ?: return@withLock
|
||||
val key = sessionKey(snapshot.week, snapshot.day)
|
||||
val session = trackerSession(snapshot.tracker, key, plan)
|
||||
val patch = sessionPatch(snapshot.tracker, key, session)
|
||||
_state.value = _state.value.copy(saveState = "Speichert …")
|
||||
runCatching { repository.patchSession(snapshot.selectedPlanId, key, patch) }
|
||||
.onSuccess { outcome ->
|
||||
when (outcome) {
|
||||
is SaveOutcome.Saved -> {
|
||||
val revision = outcome.body.int("revision", snapshot.tracker.int("revision", 1) + 1)
|
||||
_state.value = _state.value.copy(
|
||||
tracker = _state.value.tracker.updatedInt("revision", revision),
|
||||
saveState = "Gespeichert",
|
||||
response = _state.value.response
|
||||
.updated("analysis_catalog", outcome.body["analysis_catalog"])
|
||||
.updated("analysis_state", outcome.body["analysis_state"]),
|
||||
)
|
||||
}
|
||||
is SaveOutcome.Queued -> {
|
||||
_state.value = _state.value.copy(
|
||||
tracker = _state.value.tracker.updatedInt("revision", snapshot.tracker.int("revision", 1) + 1),
|
||||
saveState = "Offline vorgemerkt",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onFailure { error ->
|
||||
if (error is RevisionConflictException) {
|
||||
_state.value = _state.value.copy(conflict = error, saveState = "Konflikt")
|
||||
} else {
|
||||
_state.value = _state.value.copy(saveState = "Fehler", error = error.message ?: "Speichern fehlgeschlagen")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun overlayPendingPatches(base: JsonObject, queued: List<PendingMutation>): JsonObject {
|
||||
val patches = queued.mapNotNull { mutation ->
|
||||
runCatching { wireJson.parseToJsonElement(mutation.payloadJson).jsonObject }.getOrNull()
|
||||
}
|
||||
return overlaySessionPatches(base, patches)
|
||||
}
|
||||
|
||||
fun dismissConflict() { _state.value = _state.value.copy(conflict = null) }
|
||||
fun reloadAfterConflict() { selectPlan(_state.value.selectedPlanId, persistServer = false) }
|
||||
fun discardQueuedConflict(id: Long) { viewModelScope.launch { repository.discardConflict(id) } }
|
||||
|
||||
fun startAnalysis(scope: String, week: Int? = null) {
|
||||
val planId = _state.value.selectedPlanId
|
||||
if (planId.isBlank()) return
|
||||
viewModelScope.launch {
|
||||
_state.value = _state.value.copy(analysisMessage = "Analyse wird gestartet …", error = "")
|
||||
runCatching { repository.analysisStart(planId, scope, week) }
|
||||
.onSuccess { outcome ->
|
||||
_state.value = _state.value.copy(
|
||||
response = _state.value.response
|
||||
.updated("analysis_state", outcome.body["analysis_state"])
|
||||
.updated("analysis_cache", outcome.body["analysis_cache"])
|
||||
.updated("analysis_catalog", outcome.body["analysis_catalog"]),
|
||||
analysisMessage = if (outcome.statusCode == 202) "Serverjob läuft …" else "Aktuelle Analyse aus Cache geladen.",
|
||||
)
|
||||
if (outcome.statusCode == 202) startPolling()
|
||||
}
|
||||
.onFailure { error -> _state.value = _state.value.copy(analysisMessage = "", error = error.message ?: "Analyse fehlgeschlagen") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPolling() {
|
||||
if (pollJob?.isActive == true) return
|
||||
val planId = _state.value.selectedPlanId
|
||||
pollJob = viewModelScope.launch {
|
||||
while (true) {
|
||||
delay(2_000)
|
||||
val body = runCatching { repository.analysisStatus(planId) }.getOrElse {
|
||||
_state.value = _state.value.copy(error = it.message ?: "Analysestatus konnte nicht gelesen werden")
|
||||
return@launch
|
||||
}
|
||||
_state.value = _state.value.copy(
|
||||
response = _state.value.response
|
||||
.updated("analysis_state", body["analysis_state"])
|
||||
.updated("analysis_cache", body["analysis_cache"])
|
||||
.updated("analysis_catalog", body["analysis_catalog"]),
|
||||
)
|
||||
val status = body.obj("analysis_state").string("status", "idle")
|
||||
_state.value = _state.value.copy(analysisMessage = body.obj("analysis_state").string("message"))
|
||||
if (status != "running") {
|
||||
selectPlan(planId, persistServer = false)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
|
||||
<path android:fillColor="#17212B" android:pathData="M0,0h108v108h-108z"/>
|
||||
<path android:fillColor="#D9F26A" android:pathData="M20,46h12v16h-12zM76,46h12v16h-12zM32,50h44v8h-44zM14,42h6v24h-6zM88,42h6v24h-6z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">boehmitools Training</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<resources>
|
||||
<style name="Theme.BoehmitoolsTraining" parent="android:style/Theme.Material.Light.NoActionBar">
|
||||
<item name="android:fontFamily">sans</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowActionModeOverlay">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
package de.drazz.boehmitools.training.domain
|
||||
|
||||
import java.io.InputStreamReader
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class FixtureContractTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
fun bothPlansRespectVersionAndStableIdContracts() {
|
||||
listOf("phase-1", "calihoss-newbie-8-wochen").forEach { name ->
|
||||
val wrapper = fixture("samples/plans/$name.json")
|
||||
val config = wrapper.obj("config")
|
||||
assertEquals(3, config.int("schema_version"))
|
||||
assertEquals(2, config.int("contract_version"))
|
||||
assertEquals(name, config.string("plan_id"))
|
||||
|
||||
val dayIds = mutableListOf<String>()
|
||||
val rotationIds = mutableListOf<String>()
|
||||
val placementIds = mutableListOf<String>()
|
||||
val stepIds = mutableListOf<String>()
|
||||
config.array("days").mapNotNull { it as? JsonObject }.forEach { day ->
|
||||
dayIds += day.string("id")
|
||||
day.array("rotations").mapNotNull { it as? JsonObject }.forEach { rotation ->
|
||||
rotationIds += rotation.string("id")
|
||||
rotation.array("exercises").mapNotNull { it as? JsonObject }.forEach { exercise ->
|
||||
placementIds += exercise.string("id")
|
||||
assertTrue(exercise.string("legacy_id").matches(Regex("d\\d+-r\\d+-e\\d+")))
|
||||
assertTrue(exercise.string("exercise_id").startsWith("movement-"))
|
||||
assertTrue(exercise.string("progression_id").isNotBlank())
|
||||
assertFalse(exercise.obj("result_schema").isEmpty())
|
||||
}
|
||||
}
|
||||
}
|
||||
config.obj("stages").values.mapNotNull { it as? JsonObject }.forEach { stage ->
|
||||
stage.array("steps").mapNotNull { it as? JsonObject }.forEach { step ->
|
||||
stepIds += step.string("id")
|
||||
assertFalse(step.obj("result_schema").isEmpty())
|
||||
}
|
||||
}
|
||||
listOf(dayIds, rotationIds, placementIds, stepIds).forEach { ids ->
|
||||
assertTrue(ids.all { it.isNotBlank() })
|
||||
assertEquals(ids.size, ids.toSet().size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsePlanSupportsBothProvidedEditorConfigs() {
|
||||
listOf("phase-1", "calihoss-newbie-8-wochen").forEach { name ->
|
||||
val config = fixture("samples/plans/$name.json").obj("config")
|
||||
val plan = parsePlan(config)
|
||||
assertEquals(8, plan.weeks)
|
||||
assertEquals(6, plan.days.size)
|
||||
assertEquals(31, plan.progressions.size)
|
||||
assertTrue(plan.days.flatMap { it.rotations }.flatMap { it.exercises }.isNotEmpty())
|
||||
assertTrue(plan.title.isNotBlank())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bothLegacySessionsLoadAndMigrateWithoutDroppingItems() {
|
||||
listOf("phase-1", "calihoss-newbie-8-wochen").forEach { name ->
|
||||
val plan = parsePlan(fixture("samples/plans/$name.json").obj("config"))
|
||||
val tracker = fixture("samples/sessions/$name.json")
|
||||
assertTrue(tracker.int("version") in 1..7)
|
||||
val firstSessionEntry = tracker.obj("sessions").entries.first()
|
||||
val day = plan.days.first { it.num == firstSessionEntry.key.substringAfter("-d").toInt() }
|
||||
val original = firstSessionEntry.value.jsonObject
|
||||
val originalCount = original.obj("items").size
|
||||
val (migrated, changed) = migrateLegacyItems(original, day)
|
||||
assertTrue(changed)
|
||||
assertEquals(originalCount, migrated.obj("items").size)
|
||||
day.rotations.flatMap { it.exercises }.forEach { exercise ->
|
||||
if (original.obj("items").containsKey(exercise.legacyId)) {
|
||||
assertTrue(migrated.obj("items").containsKey(exercise.id))
|
||||
assertFalse(migrated.obj("items").containsKey(exercise.legacyId))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resultSchemaPriorityIsManualThenStepThenExerciseThenFallback() {
|
||||
val config = fixture("samples/plans/phase-1.json").obj("config")
|
||||
val plan = parsePlan(config)
|
||||
val exercise = plan.days.flatMap { it.rotations }.flatMap { it.exercises }
|
||||
.first { it.progressionId == "pike" }
|
||||
val progression = plan.progressions.getValue("pike")
|
||||
val step = progression.steps.first { it.resultSchema != null }
|
||||
|
||||
val fromStep = resolveResultSchema(
|
||||
exercise = exercise,
|
||||
item = buildJsonObject {
|
||||
put("progression_step_id", step.id)
|
||||
},
|
||||
progressionText = step.name,
|
||||
plan = plan,
|
||||
)
|
||||
assertEquals("progression", fromStep.source)
|
||||
assertEquals(step.resultSchema?.mode, fromStep.mode)
|
||||
|
||||
val manual = resolveResultSchema(
|
||||
exercise = exercise,
|
||||
item = buildJsonObject {
|
||||
put("result_format", buildJsonObject {
|
||||
put("mode", "minutes")
|
||||
put("weight_mode", "none")
|
||||
put("laterality", "bilateral")
|
||||
put("sides_mode", "same")
|
||||
})
|
||||
},
|
||||
progressionText = step.name,
|
||||
plan = plan,
|
||||
)
|
||||
assertEquals("session", manual.source)
|
||||
assertEquals("minutes", manual.mode)
|
||||
assertNotEquals(fromStep.mode, manual.mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun partialProgressionSchemaInheritsUnspecifiedExerciseFields() {
|
||||
val exercise = buildJsonObject {
|
||||
put("mode", "reps")
|
||||
put("weight_mode", "required")
|
||||
put("laterality", "unilateral")
|
||||
put("sides_mode", "separate")
|
||||
put("sets", 4)
|
||||
put("locked_sets", true)
|
||||
}
|
||||
val progression = buildJsonObject { put("mode", "seconds") }
|
||||
val merged = mergeResultSchemaLayers(exercise, progression, null)
|
||||
assertEquals("seconds", merged.mode)
|
||||
assertEquals("required", merged.weightMode)
|
||||
assertEquals("unilateral", merged.laterality)
|
||||
assertEquals("separate", merged.sidesMode)
|
||||
assertEquals(4, merged.defaultSets)
|
||||
assertTrue(merged.lockedSets)
|
||||
assertEquals("progression", merged.source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun progressionUnitChangeNeverReinterpretsOldNumbers() {
|
||||
val current = buildJsonObject {
|
||||
put("version", 2)
|
||||
put("mode", "reps")
|
||||
put("laterality", "bilateral")
|
||||
put("sides_mode", "same")
|
||||
put("sets", 3)
|
||||
put("values", kotlinx.serialization.json.buildJsonArray { add(8); add(7); add(6) })
|
||||
put("left_values", kotlinx.serialization.json.JsonArray(emptyList()))
|
||||
put("right_values", kotlinx.serialization.json.JsonArray(emptyList()))
|
||||
put("weight_kg", 8.0)
|
||||
}
|
||||
val adapted = adaptResultDataToSchema(
|
||||
current,
|
||||
ResultSchemaUi(mode = "seconds", weightMode = "none", defaultSets = 2, lockedSets = true),
|
||||
)!!
|
||||
assertEquals("seconds", adapted.string("mode"))
|
||||
assertEquals(2, adapted.int("sets"))
|
||||
assertTrue(adapted.array("values").all { it == kotlinx.serialization.json.JsonNull })
|
||||
assertTrue(adapted["weight_kg"] == kotlinx.serialization.json.JsonNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resultDataV2NormalizesLegacyTextAndSetCount() {
|
||||
val schema = ResultSchemaUi(mode = "seconds", defaultSets = 3)
|
||||
val parsed = parseLegacyResult("3 x 20 s, 8 kg", schema)!!
|
||||
assertEquals(2, parsed.int("version"))
|
||||
assertEquals("seconds", parsed.string("mode"))
|
||||
assertEquals(3, parsed.int("sets"))
|
||||
assertEquals(3, parsed.array("values").size)
|
||||
assertTrue(formatResultData(parsed).contains("20"))
|
||||
assertTrue(formatResultData(parsed).contains("8 kg"))
|
||||
|
||||
val resized = resultDataWithSets(parsed, 20)
|
||||
assertEquals(20, resized.int("sets"))
|
||||
assertEquals(20, resized.array("values").size)
|
||||
assertEquals(20, resultDataWithSets(resized, 99).int("sets"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun queuedPatchesOverlayInFifoOrderAfterRestart() {
|
||||
val base = buildJsonObject {
|
||||
put("revision", 5)
|
||||
put("profile", buildJsonObject { put("display_name", "Server") })
|
||||
put("week_statuses", JsonObject(emptyMap()))
|
||||
put("sessions", JsonObject(emptyMap()))
|
||||
}
|
||||
val first = buildJsonObject {
|
||||
put("expected_revision", 5)
|
||||
put("profile", buildJsonObject { put("display_name", "Lokal") })
|
||||
put("session_key", "w01-d01")
|
||||
put("session", buildJsonObject { put("status", "in_progress") })
|
||||
}
|
||||
val second = buildJsonObject {
|
||||
put("expected_revision", 6)
|
||||
put("week_statuses", buildJsonObject { put("1", buildJsonObject { put("status", "closed") }) })
|
||||
put("session_key", "w01-d02")
|
||||
put("session", buildJsonObject { put("status", "completed") })
|
||||
}
|
||||
val overlaid = overlaySessionPatches(base, listOf(first, second))
|
||||
assertEquals(7, overlaid.int("revision"))
|
||||
assertEquals("Lokal", overlaid.obj("profile").string("display_name"))
|
||||
assertEquals("closed", overlaid.obj("week_statuses").obj("1").string("status"))
|
||||
assertEquals("in_progress", overlaid.obj("sessions").obj("w01-d01").string("status"))
|
||||
assertEquals("completed", overlaid.obj("sessions").obj("w01-d02").string("status"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun analysesAndProposalsMatchOverwriteOnlyModel() {
|
||||
val week = fixture("samples/analyses/phase-1/week-01.json")
|
||||
val overall = fixture("samples/analyses/phase-1/overall.json")
|
||||
val proposals = fixture("samples/proposals/phase-1.json")
|
||||
assertEquals("week", week.string("type"))
|
||||
assertEquals("overall", overall.string("type"))
|
||||
assertTrue(proposals.array("proposals").isNotEmpty())
|
||||
proposals.array("proposals").mapNotNull { it as? JsonObject }.forEach {
|
||||
assertTrue(it.string("id").isNotBlank())
|
||||
assertTrue(it.string("target_exercise_id").startsWith("movement-"))
|
||||
assertEquals("open", it.string("status"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun fixture(path: String): JsonObject {
|
||||
val stream = checkNotNull(javaClass.classLoader?.getResourceAsStream(path)) { "Fixture fehlt: $path" }
|
||||
return InputStreamReader(stream).use { json.parseToJsonElement(it.readText()).jsonObject }
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"version": 2,
|
||||
"source_file": "phase-1.json",
|
||||
"plan_id": "phase-1",
|
||||
"published_revision": 1,
|
||||
"updated_at": "2026-07-23T12:01:33+00:00",
|
||||
"proposals": [
|
||||
{
|
||||
"id": "overall:overall-1",
|
||||
"status": "open",
|
||||
"scope_key": "overall:overall",
|
||||
"analysis_type": "overall",
|
||||
"week": null,
|
||||
"target": "Woche 2 / Pike",
|
||||
"target_label": "Pike: nächste Stufe prüfen",
|
||||
"target_exercise_id": "movement-pike-17f7716e00",
|
||||
"target_progression_id": "pike",
|
||||
"target_step_id": "step-hold-kleine-senkung-4f3bfac4b2",
|
||||
"suggested_change": "Progressionsstufe von „Pike Hold“ auf „Hold + kleine Senkung“ setzen.",
|
||||
"reason": "Zwei Einheiten mit 6x20 s Pike Hold sprechen für einen vorsichtigen Variantenwechsel.",
|
||||
"condition": "Nur wenn beide Pike-Einheiten technisch sauber waren; sonst Pike Hold beibehalten.",
|
||||
"manual_step": "Im Trainingsplan-Modul bei Pike die nächste Progressionsstufe auswählen.",
|
||||
"analysis_created_at": "2026-07-23T12:01:33+00:00"
|
||||
},
|
||||
{
|
||||
"id": "overall:overall-2",
|
||||
"status": "open",
|
||||
"scope_key": "overall:overall",
|
||||
"analysis_type": "overall",
|
||||
"week": null,
|
||||
"target": "Woche 2 / Einarm-Rudern ENG",
|
||||
"target_label": "Einarm-Rudern ENG: Last oder Stufe prüfen",
|
||||
"target_exercise_id": "movement-einarm-rudern-eng-kb-4907fbb5c7",
|
||||
"target_progression_id": "rowe",
|
||||
"target_step_id": "step-etwas-mehr-4f2775725c",
|
||||
"suggested_change": "Von „leicht“ auf „etwas mehr“ wechseln.",
|
||||
"reason": "Die Wiederholungen lagen in beiden Einheiten stabil bei etwa 8–9 pro Intervall.",
|
||||
"condition": "Nur wenn Rückenposition und Zugweg sauber bleiben; sonst aktuelle Stufe beibehalten.",
|
||||
"manual_step": "Im Trainingsplan-Modul die nächste Rudern-ENG-Progression oder passende Last eintragen.",
|
||||
"analysis_created_at": "2026-07-23T12:01:33+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
{
|
||||
"created_at": "2026-07-23T09:14:02+00:00",
|
||||
"profile": {
|
||||
"display_name": "",
|
||||
"plan_notes": "",
|
||||
"start_date": ""
|
||||
},
|
||||
"progressions": {
|
||||
"ls": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:1-2-reps-reserve-pruefen": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:beine-protokollieren": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:brustwirbelsaeule-schultern": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:bulletproof-shoulders": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:core-protokollieren": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:finger-plank": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:full-body-mobility": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:hueft-mobilitaet": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:lockerer-spaziergang": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:negative-pruefen": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:prone-y-t-w-technik": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:pull-protokollieren": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:push-protokollieren": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:regeneration-pruefen": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:schlaf-priorisieren": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:schmerzcheck": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:schwierigkeit-erhoehen": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:sonst-vollstaendig-pausieren": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:spaziergang": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:sprunggelenk-mobilitaet": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:startposition-pruefen": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:volle-reps-pruefen": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"name:wrist-conditioning": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"plank": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"rowe": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
},
|
||||
"squat": {
|
||||
"note": "",
|
||||
"value": ""
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"w01-d01": {
|
||||
"completed_at": "",
|
||||
"items": {
|
||||
"cooldown-0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e1": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e2": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e3": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e1": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e2": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e3": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"stretch-0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-1": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-2": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-3": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-4": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
}
|
||||
},
|
||||
"note": "",
|
||||
"started_at": "",
|
||||
"status": "planned"
|
||||
},
|
||||
"w02-d01": {
|
||||
"completed_at": "",
|
||||
"items": {
|
||||
"cooldown-0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e1": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e2": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e3": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e1": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e2": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e3": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"stretch-0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-1": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-2": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-3": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-4": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
}
|
||||
},
|
||||
"note": "",
|
||||
"started_at": "",
|
||||
"status": "planned"
|
||||
},
|
||||
"w03-d01": {
|
||||
"completed_at": "",
|
||||
"items": {
|
||||
"cooldown-0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e1": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e2": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e3": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e1": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e2": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e3": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"stretch-0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-1": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-2": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-3": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-4": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
}
|
||||
},
|
||||
"note": "",
|
||||
"started_at": "",
|
||||
"status": "planned"
|
||||
},
|
||||
"w08-d01": {
|
||||
"completed_at": "",
|
||||
"items": {
|
||||
"cooldown-0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e1": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e2": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r0-e3": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e1": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e2": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"d1-r1-e3": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"stretch-0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-0": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-1": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-2": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-3": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
},
|
||||
"warmup-4": {
|
||||
"done": false,
|
||||
"note": "",
|
||||
"result": ""
|
||||
}
|
||||
},
|
||||
"note": "",
|
||||
"started_at": "",
|
||||
"status": "planned"
|
||||
},
|
||||
"w01-d02": {
|
||||
"status": "planned",
|
||||
"items": {
|
||||
"warmup-0": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"warmup-1": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d2-r0-e0": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d2-r0-e1": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d2-r0-e2": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d2-r0-e3": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d2-r1-e0": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d2-r1-e1": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d2-r1-e2": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d2-r1-e3": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"cooldown-0": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"cooldown-1": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"stretch-0": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
}
|
||||
},
|
||||
"note": "",
|
||||
"started_at": "",
|
||||
"completed_at": ""
|
||||
},
|
||||
"w01-d03": {
|
||||
"status": "planned",
|
||||
"items": {
|
||||
"warmup-0": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"warmup-1": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"warmup-2": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"warmup-3": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"warmup-4": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d3-r0-e0": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d3-r0-e1": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d3-r0-e2": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d3-r0-e3": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d3-r1-e0": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d3-r1-e1": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d3-r1-e2": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"d3-r1-e3": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"cooldown-0": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
},
|
||||
"stretch-0": {
|
||||
"done": false,
|
||||
"result": "",
|
||||
"note": ""
|
||||
}
|
||||
},
|
||||
"note": "",
|
||||
"started_at": "",
|
||||
"completed_at": ""
|
||||
}
|
||||
},
|
||||
"source_file": "calihoss-newbie-8-wochen.json",
|
||||
"source_hash": "6d9ab7972384bb711693ced004c6a7053fbe858323e98dfea7e7a6b0c56c83b1",
|
||||
"updated_at": "2026-07-23T09:42:21+00:00",
|
||||
"version": 1
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user