Android patch

This commit is contained in:
Mboehmlaender
2026-07-25 21:13:37 +02:00
parent 6dd827b0b4
commit 1744e444b7
17 changed files with 596 additions and 1133 deletions
+9 -10
View File
@@ -1,14 +1,13 @@
# boehmitools Training # RipMe
Native Android-App für die boehmitools-Plugins `trainingsplan` und `trainingstracker` 2.0. Native Android-App für den boehmitools-Trainingstracker 2.0.
- Application ID: `de.drazz.boehmitools.training`
- Minimum SDK: 26
- Kotlin, Jetpack Compose und Material 3 - Kotlin, Jetpack Compose und Material 3
- Application ID `de.drazz.boehmitools.training` - Session-Tracker, Fortschritt, Analyse, Plan-Leseansicht und FAQ
- Minimum SDK 26 - Room-Cache, Offline-Queue, DataStore und WorkManager
- konfigurierbarer Server, Standard `https://tools.d-razz.de/` - sichtbarer Revisionskonflikt statt stiller Überschreibung
- HTTP Basic Auth mit Android-Keystore-geschütztem Passwort - App-Version 2.1.0
- Room-Cache und FIFO-Queue für Session-Patches
- WorkManager-Synchronisation mit sichtbarer `409`-Konfliktbehandlung
- nativer Tracker, Fortschritt/Analyse und Planeditor
Der technische Vertrag steht in `docs/API_CONTRACT.md`, die Quellenanalyse in `docs/SOURCE_ANALYSIS.md`. Lokale Buildschritte stehen in `BUILD.md`. Der separate Planeditor ist nicht Bestandteil der Android-App. Pläne werden ausschließlich gelesen; Sessions werden über die vorhandenen HTTP-APIs bearbeitet.
+2 -2
View File
@@ -15,8 +15,8 @@ android {
applicationId = "de.drazz.boehmitools.training" applicationId = "de.drazz.boehmitools.training"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 2
versionName = "2.0.0" versionName = "2.1.0"
testInstrumentationRunner = "de.drazz.boehmitools.training.HiltTestRunner" testInstrumentationRunner = "de.drazz.boehmitools.training.HiltTestRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true
} }
@@ -1,44 +0,0 @@
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,50 @@
package de.drazz.boehmitools.training.ui
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
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 CompactTrackerUiTest {
@get:Rule
val compose = createAndroidComposeRule<ComponentActivity>()
@Test
fun fourStatusButtonsStayInside360Dp() {
compose.setContent {
MaterialTheme {
Row(
modifier = Modifier.width(360.dp).testTag("status-row"),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
listOf("Offen", "Erledigt", "Teilweise", "Übersprungen").forEachIndexed { index, label ->
FilterChip(
selected = index == 0,
onClick = {},
label = { Text(label, maxLines = 2) },
modifier = Modifier.weight(1f).testTag("status-$index"),
)
}
}
}
}
compose.onNodeWithTag("status-row").assertIsDisplayed().assertWidthIsAtMost(360.dp)
repeat(4) { index ->
compose.onNodeWithTag("status-$index").assertIsDisplayed().assertWidthIsAtMost(90.dp)
}
}
}
+1 -1
View File
@@ -8,7 +8,7 @@
android:name=".TrainingApplication" android:name=".TrainingApplication"
android:allowBackup="false" android:allowBackup="false"
android:icon="@drawable/ic_launcher_foreground" android:icon="@drawable/ic_launcher_foreground"
android:label="boehmitools Training" android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.BoehmitoolsTraining" android:theme="@style/Theme.BoehmitoolsTraining"
@@ -20,8 +20,15 @@ class MainActivity : ComponentActivity() {
setContent { setContent {
val vm: AppViewModel = hiltViewModel() val vm: AppViewModel = hiltViewModel()
val settings by vm.settings.collectAsStateWithLifecycle() val settings by vm.settings.collectAsStateWithLifecycle()
val uiState by vm.uiState.collectAsStateWithLifecycle()
TrainingTheme(settings.theme) { TrainingTheme(settings.theme) {
AppRoot(settings = settings, onTheme = vm::setTheme) AppRoot(
settings = settings,
showWhatsNew = uiState.showWhatsNew,
onDismissWhatsNew = vm::dismissWhatsNew,
onShowWhatsNew = vm::showWhatsNew,
onTheme = vm::setTheme,
)
} }
} }
} }
@@ -1,14 +1,15 @@
package de.drazz.boehmitools.training.ui package de.drazz.boehmitools.training.ui
import androidx.compose.material.icons.Icons 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.FitnessCenter
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -17,8 +18,8 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import de.drazz.boehmitools.training.BuildConfig
import de.drazz.boehmitools.training.data.settings.SettingsRepository 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.setup.SettingsScreen
import de.drazz.boehmitools.training.ui.tracker.TrackerScreen import de.drazz.boehmitools.training.ui.tracker.TrackerScreen
@@ -27,15 +28,18 @@ private data class AppDestination(val route: String, val label: String, val icon
@Composable @Composable
fun AppRoot( fun AppRoot(
settings: SettingsRepository.PublicSettings, settings: SettingsRepository.PublicSettings,
showWhatsNew: Boolean,
onDismissWhatsNew: () -> Unit,
onShowWhatsNew: () -> Unit,
onTheme: (String) -> Unit, onTheme: (String) -> Unit,
) { ) {
val navController = rememberNavController() val navController = rememberNavController()
val destinations = listOf( val destinations = listOf(
AppDestination("tracker", "Tracker") { Icon(Icons.Default.FitnessCenter, null) }, AppDestination("tracker", "Tracker") { Icon(Icons.Default.FitnessCenter, null) },
AppDestination("editor", "Planeditor") { Icon(Icons.Default.Edit, null) },
AppDestination("settings", "Einstellungen") { Icon(Icons.Default.Settings, null) }, AppDestination("settings", "Einstellungen") { Icon(Icons.Default.Settings, null) },
) )
val backStack by navController.currentBackStackEntryAsState() val backStack by navController.currentBackStackEntryAsState()
Scaffold( Scaffold(
bottomBar = { bottomBar = {
NavigationBar { NavigationBar {
@@ -59,14 +63,38 @@ fun AppRoot(
) { padding -> ) { padding ->
NavHost(navController = navController, startDestination = "tracker", modifier = Modifier) { NavHost(navController = navController, startDestination = "tracker", modifier = Modifier) {
composable("tracker") { TrackerScreen(contentPadding = padding) } composable("tracker") { TrackerScreen(contentPadding = padding) }
composable("editor") { EditorScreen(contentPadding = padding) }
composable("settings") { composable("settings") {
SettingsScreen( SettingsScreen(
contentPadding = padding, contentPadding = padding,
current = settings, current = settings,
onTheme = onTheme, onTheme = onTheme,
onShowWhatsNew = onShowWhatsNew,
) )
} }
} }
} }
if (showWhatsNew) {
WhatsNewDialog(onDismiss = onDismissWhatsNew)
}
}
@Composable
private fun WhatsNewDialog(onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Neu in RipMe ${BuildConfig.VERSION_NAME}") },
text = {
Text(
"• RipMe konzentriert sich vollständig auf den Trainingstracker.\n" +
"• Analyseergebnisse werden jetzt vollständig in der App angezeigt.\n" +
"• Navigation und Statusfelder bleiben auch auf schmalen Displays fest im Bildschirm.\n" +
"• Wochen und Trainingstage zeigen ihren aktuellen Abschlussstatus.\n" +
"• Offline erfasste Sessionänderungen werden weiterhin sicher synchronisiert.",
)
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("Verstanden") }
},
)
} }
@@ -1,17 +1,28 @@
package de.drazz.boehmitools.training.ui package de.drazz.boehmitools.training.ui
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import de.drazz.boehmitools.training.BuildConfig
import de.drazz.boehmitools.training.data.settings.SettingsRepository import de.drazz.boehmitools.training.data.settings.SettingsRepository
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class AppUiState(
val showWhatsNew: Boolean = false,
)
@HiltViewModel @HiltViewModel
class AppViewModel @Inject constructor( class AppViewModel @Inject constructor(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
@ApplicationContext private val context: Context,
) : ViewModel() { ) : ViewModel() {
val settings = settingsRepository.publicSettings.stateIn( val settings = settingsRepository.publicSettings.stateIn(
viewModelScope, viewModelScope,
@@ -24,7 +35,42 @@ class AppViewModel @Inject constructor(
), ),
) )
private val _uiState = MutableStateFlow(AppUiState())
val uiState: StateFlow<AppUiState> = _uiState.asStateFlow()
init {
viewModelScope.launch {
val lastSeen = settingsRepository.lastSeenVersionCode()
val updatedExistingInstall = packageWasUpdated()
val shouldShow = lastSeen in 1 until BuildConfig.VERSION_CODE ||
(lastSeen == 0 && updatedExistingInstall)
if (shouldShow) {
_uiState.value = AppUiState(showWhatsNew = true)
} else {
settingsRepository.markVersionSeen(BuildConfig.VERSION_CODE)
}
}
}
fun setTheme(theme: String) { fun setTheme(theme: String) {
viewModelScope.launch { settingsRepository.setTheme(theme) } viewModelScope.launch { settingsRepository.setTheme(theme) }
} }
fun dismissWhatsNew() {
_uiState.value = AppUiState(showWhatsNew = false)
viewModelScope.launch { settingsRepository.markVersionSeen(BuildConfig.VERSION_CODE) }
}
fun showWhatsNew() {
_uiState.value = AppUiState(showWhatsNew = true)
}
@Suppress("DEPRECATION")
private fun packageWasUpdated(): Boolean {
return runCatching {
val info = context.packageManager.getPackageInfo(context.packageName, 0)
info.lastUpdateTime > info.firstInstallTime + 1_000L
}.getOrDefault(false)
}
} }
@@ -1,530 +0,0 @@
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") } },
)
}
@@ -1,429 +0,0 @@
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)
}
}
@@ -9,16 +9,19 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CloudDone import androidx.compose.material.icons.filled.CloudDone
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -28,6 +31,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import de.drazz.boehmitools.training.BuildConfig
import de.drazz.boehmitools.training.data.settings.SettingsRepository import de.drazz.boehmitools.training.data.settings.SettingsRepository
@Composable @Composable
@@ -35,6 +39,7 @@ fun SettingsScreen(
contentPadding: PaddingValues, contentPadding: PaddingValues,
current: SettingsRepository.PublicSettings, current: SettingsRepository.PublicSettings,
onTheme: (String) -> Unit, onTheme: (String) -> Unit,
onShowWhatsNew: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel(), viewModel: SettingsViewModel = hiltViewModel(),
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
@@ -46,7 +51,8 @@ fun SettingsScreen(
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
Text("Einstellungen", style = MaterialTheme.typography.headlineMedium) Text("RipMe Einstellungen", style = MaterialTheme.typography.headlineMedium)
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Server & Anmeldung", style = MaterialTheme.typography.titleLarge) Text("Server & Anmeldung", style = MaterialTheme.typography.titleLarge)
@@ -76,10 +82,17 @@ fun SettingsScreen(
visualTransformation = PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
singleLine = true, singleLine = true,
) )
Button(onClick = viewModel::saveAndTest, enabled = !state.loading) { Button(
if (state.loading) CircularProgressIndicator(modifier = Modifier.height(18.dp), strokeWidth = 2.dp) onClick = viewModel::saveAndTest,
else Icon(Icons.Default.CloudDone, null) enabled = !state.loading,
Spacer(Modifier.padding(horizontal = 4.dp)) modifier = Modifier.fillMaxWidth(),
) {
if (state.loading) {
CircularProgressIndicator(modifier = Modifier.height(18.dp), strokeWidth = 2.dp)
} else {
Icon(Icons.Default.CloudDone, null)
}
Spacer(Modifier.width(8.dp))
Text("Speichern & Verbindung testen") Text("Speichern & Verbindung testen")
} }
if (state.message.isNotBlank()) Text(state.message, color = MaterialTheme.colorScheme.primary) if (state.message.isNotBlank()) Text(state.message, color = MaterialTheme.colorScheme.primary)
@@ -90,16 +103,36 @@ fun SettingsScreen(
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Darstellung", style = MaterialTheme.typography.titleLarge) Text("Darstellung", style = MaterialTheme.typography.titleLarge)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
listOf("system" to "System", "light" to "Hell", "dark" to "Dunkel").forEach { (value, label) -> listOf("system" to "System", "light" to "Hell", "dark" to "Dunkel").forEach { (value, label) ->
FilterChip( FilterChip(
selected = current.theme == value, selected = current.theme == value,
onClick = { onTheme(value) }, onClick = { onTheme(value) },
label = { Text(label) }, label = { Text(label, maxLines = 1) },
modifier = Modifier.weight(1f),
) )
} }
} }
} }
} }
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Über RipMe", style = MaterialTheme.typography.titleLarge)
Text("Version ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
Text(
"Native Android-App für deinen Trainingstracker.",
style = MaterialTheme.typography.bodyMedium,
)
OutlinedButton(onClick = onShowWhatsNew, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Default.Info, null)
Spacer(Modifier.width(8.dp))
Text("Was ist neu?")
}
}
}
} }
} }
@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
@@ -23,12 +22,11 @@ import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -40,7 +38,7 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.TabRow
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -76,13 +74,10 @@ import de.drazz.boehmitools.training.domain.resultDataWithValue
import de.drazz.boehmitools.training.domain.string import de.drazz.boehmitools.training.domain.string
import de.drazz.boehmitools.training.domain.trackerSession import de.drazz.boehmitools.training.domain.trackerSession
import de.drazz.boehmitools.training.domain.updated 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.JsonNull
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.doubleOrNull import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -102,7 +97,7 @@ fun TrackerScreen(
TopAppBar( TopAppBar(
title = { title = {
Column { Column {
Text(state.plan?.title ?: "boehmitools Training", maxLines = 2) Text(state.plan?.title ?: "RipMe", maxLines = 2)
if (state.saveState.isNotBlank()) Text(state.saveState, style = MaterialTheme.typography.labelSmall) if (state.saveState.isNotBlank()) Text(state.saveState, style = MaterialTheme.typography.labelSmall)
} }
}, },
@@ -126,9 +121,13 @@ fun TrackerScreen(
) { inner -> ) { inner ->
Column(Modifier.fillMaxSize().padding(inner)) { Column(Modifier.fillMaxSize().padding(inner)) {
val tabs = listOf("session" to "Session", "progress" to "Fortschritt", "plan" to "Plan", "faq" to "FAQ") 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)) { TabRow(selectedTabIndex = tabs.indexOfFirst { it.first == state.tab }.coerceAtLeast(0)) {
tabs.forEach { (id, label) -> tabs.forEach { (id, label) ->
Tab(selected = state.tab == id, onClick = { viewModel.setTab(id) }, text = { Text(label) }) Tab(
selected = state.tab == id,
onClick = { viewModel.setTab(id) },
text = { Text(label, maxLines = 2, style = MaterialTheme.typography.labelMedium) },
)
} }
} }
when { when {
@@ -175,7 +174,7 @@ private fun SessionPane(state: TrackerUiState, pending: List<PendingMutation>, v
) { ) {
if (state.error.isNotBlank()) Text(state.error, color = MaterialTheme.colorScheme.error) if (state.error.isNotBlank()) Text(state.error, color = MaterialTheme.colorScheme.error)
if (pending.isNotEmpty()) PendingQueue(pending, vm) if (pending.isNotEmpty()) PendingQueue(pending, vm)
WeekDayPicker(plan, state.week, state.day, vm) WeekDayPicker(plan, state.tracker, state.week, state.day, vm)
SessionHeader(plan, day, session, state.week, vm) SessionHeader(plan, day, session, state.week, vm)
SequenceCard("Aufwärmen", "warmup", day.warmup, session, vm) SequenceCard("Aufwärmen", "warmup", day.warmup, session, vm)
@@ -185,7 +184,7 @@ private fun SessionPane(state: TrackerUiState, pending: List<PendingMutation>, v
Text(rotation.label, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text(rotation.label, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
rotation.exercises.forEach { exercise -> rotation.exercises.forEach { exercise ->
ExerciseCard(exercise, session, plan, vm) ExerciseCard(exercise, session, plan, vm)
Divider() HorizontalDivider()
} }
} }
} }
@@ -216,13 +215,54 @@ private fun PendingQueue(pending: List<PendingMutation>, vm: TrackerViewModel) {
} }
@Composable @Composable
private fun WeekDayPicker(plan: PlanUi, week: Int, day: Int, vm: TrackerViewModel) { private fun WeekDayPicker(plan: PlanUi, tracker: JsonObject, week: Int, day: Int, vm: TrackerViewModel) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) { WeekPicker(plan, tracker, week, vm)
(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 ->
val session = tracker.obj("sessions")[de.drazz.boehmitools.training.domain.sessionKey(week, value.num)] as? JsonObject
val marker = when (session?.string("status")) {
"completed" -> ""
"stopped" -> ""
"in_progress" -> ""
else -> ""
} }
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) { FilterChip(
plan.days.forEach { value -> FilterChip(selected = value.num == day, onClick = { vm.setDay(value.num) }, label = { Text("Tag ${value.num}") }) } selected = value.num == day,
onClick = { vm.setDay(value.num) },
label = { Text("Tag ${value.num}$marker") },
)
}
}
}
}
@Composable
private fun WeekPicker(plan: PlanUi, tracker: JsonObject, week: Int, vm: TrackerViewModel) {
Row(
Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
(1..plan.weeks).forEach { value ->
val sessions = plan.days.mapNotNull { day ->
tracker.obj("sessions")[de.drazz.boehmitools.training.domain.sessionKey(value, day.num)] as? JsonObject
}
val closed = tracker.obj("week_statuses").obj(value.toString()).string("status") == "closed"
val allCompleted = sessions.size == plan.days.size && sessions.all { it.string("status") == "completed" }
val hasCompleted = sessions.any { it.string("status") == "completed" }
val marker = when {
closed || allCompleted -> ""
hasCompleted -> ""
else -> ""
}
FilterChip(
selected = value == week,
onClick = { vm.setWeek(value) },
label = { Text("W$value$marker") },
)
} }
} }
} }
@@ -230,21 +270,63 @@ private fun WeekDayPicker(plan: PlanUi, week: Int, day: Int, vm: TrackerViewMode
@Composable @Composable
private fun SessionHeader(plan: PlanUi, day: DayUi, session: JsonObject, week: Int, vm: TrackerViewModel) { private fun SessionHeader(plan: PlanUi, day: DayUi, session: JsonObject, week: Int, vm: TrackerViewModel) {
val status = session.string("status", "planned") val status = session.string("status", "planned")
val statusLabel = when (status) {
"in_progress" -> "Läuft"
"stopped" -> "Gestoppt"
"completed" -> "Abgeschlossen"
else -> "Geplant"
}
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Woche $week · Tag ${day.num}", style = MaterialTheme.typography.labelLarge) Text("Woche $week · Tag ${day.num}", style = MaterialTheme.typography.labelLarge)
Text(day.focus, style = MaterialTheme.typography.headlineSmall) Text(day.focus, style = MaterialTheme.typography.headlineSmall)
Text("Planrevision R${session.int("plan_revision", plan.publishedRevision)} · Status: $status", style = MaterialTheme.typography.bodySmall) Text("Planrevision R${session.int("plan_revision", plan.publishedRevision)} · Status: $statusLabel", style = MaterialTheme.typography.bodySmall)
when (status) { when (status) {
"in_progress" -> Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { "in_progress" -> Row(
Button(onClick = { vm.setSessionStatus("completed") }) { Icon(Icons.Default.CheckCircle, null); Spacer(Modifier.width(6.dp)); Text("Abschließen") } modifier = Modifier.fillMaxWidth(),
OutlinedButton(onClick = { vm.setSessionStatus("stopped") }) { Icon(Icons.Default.Stop, null); Spacer(Modifier.width(6.dp)); Text("Stoppen") } horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = { vm.setSessionStatus("completed") },
modifier = Modifier.weight(1f),
) {
Icon(Icons.Default.CheckCircle, null)
Spacer(Modifier.width(6.dp))
Text("Abschließen", maxLines = 1)
} }
"stopped" -> Button(onClick = { vm.setSessionStatus("in_progress") }) { Icon(Icons.Default.PlayArrow, null); Spacer(Modifier.width(6.dp)); Text("Fortsetzen") } OutlinedButton(
"completed" -> OutlinedButton(onClick = { vm.setSessionStatus("in_progress") }) { Text("Wieder öffnen") } onClick = { vm.setSessionStatus("stopped") },
else -> Button(onClick = { vm.setSessionStatus("in_progress") }) { Icon(Icons.Default.PlayArrow, null); Spacer(Modifier.width(6.dp)); Text("Session starten") } modifier = Modifier.weight(1f),
) {
Icon(Icons.Default.Stop, null)
Spacer(Modifier.width(6.dp))
Text("Stoppen", maxLines = 1)
}
}
"stopped" -> Button(
onClick = { vm.setSessionStatus("in_progress") },
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Default.PlayArrow, null)
Spacer(Modifier.width(6.dp))
Text("Session fortsetzen")
}
"completed" -> OutlinedButton(
onClick = { vm.setSessionStatus("in_progress") },
modifier = Modifier.fillMaxWidth(),
) { Text("Session wieder öffnen") }
else -> Button(
onClick = { vm.setSessionStatus("in_progress") },
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Default.PlayArrow, null)
Spacer(Modifier.width(6.dp))
Text("Session starten")
}
}
TextButton(onClick = vm::resetSession, modifier = Modifier.fillMaxWidth()) {
Text("Session zurücksetzen")
} }
TextButton(onClick = vm::resetSession) { Text("Session zurücksetzen") }
} }
} }
} }
@@ -283,9 +365,17 @@ private fun ExerciseCard(exercise: ExerciseUi, session: JsonObject, plan: PlanUi
Text("ID ${exercise.id}", style = MaterialTheme.typography.labelSmall) Text("ID ${exercise.id}", style = MaterialTheme.typography.labelSmall)
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Status", style = MaterialTheme.typography.labelMedium) Text("Status", style = MaterialTheme.typography.labelMedium)
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) { Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
listOf("planned" to "Offen", "completed" to "Erledigt", "partial" to "Teilweise", "skipped" to "Übersprungen").forEach { (value, label) -> 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) }) FilterChip(
selected = status == value,
onClick = { vm.setItemStatus(exercise.id, value) },
label = { Text(label, maxLines = 2, style = MaterialTheme.typography.labelSmall) },
modifier = Modifier.weight(1f),
)
} }
} }
} }
@@ -491,10 +581,32 @@ private fun ProgressPane(state: TrackerUiState, pending: List<PendingMutation>,
val cache = state.response.obj("analysis_cache").takeIf { it.isNotEmpty() } val cache = state.response.obj("analysis_cache").takeIf { it.isNotEmpty() }
?: state.tracker.obj("analysis_cache") ?: state.tracker.obj("analysis_cache")
val weekRecord = cache.obj("weeks")[state.week.toString()] as? JsonObject val weekRecord = cache.obj("weeks")[state.week.toString()] as? JsonObject
val overall = cache["overall"] as? JsonObject val overallRecord = cache["overall"] as? JsonObject
val selectedRecord = if (state.analysisScope == "overall") overallRecord else weekRecord
val selectedTitle = if (state.analysisScope == "overall") "Gesamtanalyse" else "Analyse Woche ${state.week}"
val catalog = state.response.obj("analysis_catalog")
val catalogEntry = if (state.analysisScope == "overall") {
catalog.obj("overall")
} else {
catalog.array("weeks")
.mapNotNull { it as? JsonObject }
.firstOrNull { it.int("week") == state.week }
?: JsonObject(emptyMap())
}
val catalogStatus = when (catalogEntry.string("status")) {
"current" -> "aktuell"
"stale" -> "veraltet"
"missing" -> "noch nicht erstellt"
"needs_weeks" -> "Wochen zuerst aktualisieren"
else -> catalogEntry.string("status", "unbekannt")
}
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Fortschritt", style = MaterialTheme.typography.headlineSmall) Text("Fortschritt", style = MaterialTheme.typography.headlineSmall)
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Lokale Übersicht", style = MaterialTheme.typography.titleMedium) Text("Lokale Übersicht", style = MaterialTheme.typography.titleMedium)
@@ -502,29 +614,83 @@ private fun ProgressPane(state: TrackerUiState, pending: List<PendingMutation>,
Text("Trackerrevision ${state.tracker.int("revision", 1)} · Planrevision ${plan.publishedRevision}") Text("Trackerrevision ${state.tracker.int("revision", 1)} · Planrevision ${plan.publishedRevision}")
} }
} }
if (pending.isNotEmpty()) PendingQueue(pending, vm) if (pending.isNotEmpty()) PendingQueue(pending, vm)
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Auswertung auswählen", style = MaterialTheme.typography.titleMedium)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
FilterChip(
selected = state.analysisScope == "week",
onClick = { vm.setAnalysisScope("week") },
label = { Text("Woche", maxLines = 1) },
modifier = Modifier.weight(1f),
)
FilterChip(
selected = state.analysisScope == "overall",
onClick = { vm.setAnalysisScope("overall") },
label = { Text("Gesamt", maxLines = 1) },
modifier = Modifier.weight(1f),
)
}
if (state.analysisScope == "week") {
WeekPicker(plan, state.tracker, state.week, vm)
}
Text("Status: $catalogStatus", style = MaterialTheme.typography.bodySmall)
}
}
if (state.analysisScope == "week") {
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Wochenstatus", style = MaterialTheme.typography.titleMedium) Text("Wochenstatus", style = MaterialTheme.typography.titleMedium)
val closed = state.tracker.obj("week_statuses").obj(state.week.toString()).string("status") == "closed" 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") }) FilterChip(
Text("Eine laufende Woche erzeugt eine Zwischenanalyse, eine geschlossene Woche eine Abschlussanalyse.", style = MaterialTheme.typography.bodySmall) selected = closed,
onClick = { vm.setWeekClosed(!closed) },
label = { Text(if (closed) "Woche ${state.week} abgeschlossen" else "Woche ${state.week} läuft") },
modifier = Modifier.fillMaxWidth(),
)
Text(
"Eine laufende Woche erzeugt eine Zwischenanalyse, eine geschlossene Woche eine Abschlussanalyse.",
style = MaterialTheme.typography.bodySmall,
)
} }
} }
}
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text("Manuelle Analyse", style = MaterialTheme.typography.titleMedium) Text("Manuelle Analyse", style = MaterialTheme.typography.titleMedium)
Button(onClick = { vm.startAnalysis("week", state.week) }, enabled = !running) { Text("Woche ${state.week} analysieren") } Button(
OutlinedButton(onClick = { vm.startAnalysis("overall") }, enabled = !running) { Text("Gesamtanalyse") } onClick = {
if (state.analysisScope == "overall") vm.startAnalysis("overall")
else vm.startAnalysis("week", state.week)
},
enabled = !running,
modifier = Modifier.fillMaxWidth(),
) {
Text(
if (running) "Analyse läuft …"
else if (state.analysisScope == "overall") "Gesamtanalyse starten"
else "Woche ${state.week} analysieren",
)
}
if (running) { if (running) {
CircularProgressIndicator() CircularProgressIndicator()
Text(analysisState.string("message", "Analysejob läuft …")) Text(analysisState.string("message", "Analysejob läuft …"))
} else if (state.analysisMessage.isNotBlank()) Text(state.analysisMessage) } else if (state.analysisMessage.isNotBlank()) {
Text(state.analysisMessage)
}
if (state.error.isNotBlank()) Text(state.error, color = MaterialTheme.colorScheme.error) if (state.error.isNotBlank()) Text(state.error, color = MaterialTheme.colorScheme.error)
} }
} }
AnalysisCard("Analyse Woche ${state.week}", weekRecord)
AnalysisCard("Gesamtanalyse", overall) AnalysisCard(selectedTitle, selectedRecord)
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(24.dp))
} }
} }
@@ -532,27 +698,155 @@ private fun ProgressPane(state: TrackerUiState, pending: List<PendingMutation>,
@Composable @Composable
private fun AnalysisCard(title: String, record: JsonObject?) { private fun AnalysisCard(title: String, record: JsonObject?) {
Card(Modifier.fillMaxWidth()) { Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium) Text(title, style = MaterialTheme.typography.titleMedium)
if (record == null) { if (record == null) {
Text("Noch keine aktuelle Analyse vorhanden.") Text("Für diese Auswahl liegt noch keine Analyse vor.")
} else { return@Column
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 ->
val result = record.obj("result").takeIf { it.isNotEmpty() }
?: record.obj("response")
if (result.isEmpty()) {
Text("Der Server hat einen Analysedatensatz ohne darstellbares Ergebnis geliefert.")
return@Column
}
val scope = if (record.string("type") == "week") "Woche ${record.int("week")}" else "Gesamt"
val metadata = buildList {
add(scope)
if (record.string("created_at").isNotBlank()) add("erstellt ${record.string("created_at")}")
if (record.int("sessions_considered") > 0) add("${record.int("sessions_considered")} Sessions")
if (record.string("model").isNotBlank()) add(record.string("model"))
}.joinToString(" · ")
if (metadata.isNotBlank()) Text(metadata, style = MaterialTheme.typography.labelSmall)
Text(result.string("headline", "Progressionsanalyse"), style = MaterialTheme.typography.headlineSmall)
val summary = result.string("summary", result.obj("overview").string("summary"))
if (summary.isNotBlank()) Text(summary)
val dataQuality = result.string("data_quality", result.obj("overview").string("data_quality"))
if (dataQuality.isNotBlank()) {
OutlinedCard(Modifier.fillMaxWidth()) { OutlinedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(10.dp)) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(update.string("name"), fontWeight = FontWeight.Bold) Text("Datenbasis", fontWeight = FontWeight.Bold)
Text(update.string("next_action")) Text(dataQuality)
}
}
}
val metrics = result.array("metrics").mapNotNull { it as? JsonObject }
if (metrics.isNotEmpty()) {
Text("Kennzahlen", style = MaterialTheme.typography.titleMedium)
metrics.forEach { metric ->
OutlinedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(metric.string("label"), style = MaterialTheme.typography.labelMedium)
Text(metric.string("value"), style = MaterialTheme.typography.titleMedium)
if (metric.string("detail").isNotBlank()) Text(metric.string("detail"), style = MaterialTheme.typography.bodySmall)
}
}
}
}
AnalysisVisuals(record.obj("visuals"))
val exercises = result.array("exercise_updates")
.ifEmpty { result.array("exercises") }
.mapNotNull { it as? JsonObject }
if (exercises.isNotEmpty()) {
Text("Übungsstand", style = MaterialTheme.typography.titleMedium)
exercises.forEach { update ->
OutlinedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(update.string("name", "Übung"), fontWeight = FontWeight.Bold)
val trend = when (update.string("trend")) {
"up" -> "↗ aufwärts"
"stable" -> "→ stabil"
"down" -> "↘ rückläufig"
else -> "? unklar"
}
Text(trend, style = MaterialTheme.typography.labelMedium)
if (update.string("current_level").isNotBlank()) Text("Stand: ${update.string("current_level")}")
if (update.string("evidence").isNotBlank()) Text(update.string("evidence"), style = MaterialTheme.typography.bodySmall)
val next = update.string("next_action", update.string("next_step"))
if (next.isNotBlank()) Text("Nächster Schritt: $next")
if (update.string("criterion").isNotBlank()) Text("Kriterium: ${update.string("criterion")}", style = MaterialTheme.typography.bodySmall) if (update.string("criterion").isNotBlank()) Text("Kriterium: ${update.string("criterion")}", style = MaterialTheme.typography.bodySmall)
} }
} }
} }
} }
val adjustments = result.array("plan_adjustments").mapNotNull { it as? JsonObject }
if (adjustments.isNotEmpty()) {
Text("Optionale Planvorschläge", style = MaterialTheme.typography.titleMedium)
Text(
"Diese Vorschläge werden niemals automatisch übernommen.",
style = MaterialTheme.typography.bodySmall,
)
adjustments.forEach { item ->
OutlinedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(item.string("target", "Vorschlag"), fontWeight = FontWeight.Bold)
if (item.string("suggested_change").isNotBlank()) Text(item.string("suggested_change"))
if (item.string("reason").isNotBlank()) Text("Warum: ${item.string("reason")}", style = MaterialTheme.typography.bodySmall)
if (item.string("manual_step").isNotBlank()) Text("Manueller Schritt: ${item.string("manual_step")}", style = MaterialTheme.typography.bodySmall)
if (item.string("condition").isNotBlank()) Text("Bedingung: ${item.string("condition")}", style = MaterialTheme.typography.bodySmall)
}
}
}
}
val warnings = result.array("warnings")
if (warnings.isNotEmpty()) {
OutlinedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text("Hinweise", fontWeight = FontWeight.Bold)
warnings.forEach { warning ->
Text("${warning.jsonPrimitive.content}")
}
}
}
}
val conclusion = result.string("conclusion", result.string("closing"))
if (conclusion.isNotBlank()) {
Text(conclusion, style = MaterialTheme.typography.titleSmall)
}
}
}
}
@Composable
private fun AnalysisVisuals(visuals: JsonObject) {
val charts = visuals.array("charts").mapNotNull { it as? JsonObject }
val flowcharts = visuals.array("flowcharts").mapNotNull { it as? JsonObject }
if (charts.isEmpty() && flowcharts.isEmpty()) return
Text("Visualisierte Erkenntnisse", style = MaterialTheme.typography.titleMedium)
charts.forEach { chart ->
OutlinedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(chart.string("title", "Verlauf"), fontWeight = FontWeight.Bold)
val series = chart.array("series").mapNotNull { it as? JsonObject }
series.forEach { row ->
val values = row.array("values").joinToString(" · ") { it.jsonPrimitive.content }
Text("${row.string("name", "Werte")}: $values", style = MaterialTheme.typography.bodySmall)
}
if (chart.string("insight").isNotBlank()) Text(chart.string("insight"), style = MaterialTheme.typography.bodySmall)
}
}
}
flowcharts.forEach { flow ->
OutlinedCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(flow.string("title", "Progressionspfad"), fontWeight = FontWeight.Bold)
val nodes = flow.array("nodes").mapNotNull { it as? JsonObject }
nodes.forEachIndexed { index, node ->
Text("${if (index == nodes.lastIndex) "└" else "├"} ${node.string("label", node.string("id"))}")
}
if (flow.string("insight").isNotBlank()) Text(flow.string("insight"), style = MaterialTheme.typography.bodySmall)
}
} }
} }
} }
@@ -75,6 +75,7 @@ data class TrackerUiState(
val saveState: String = "", val saveState: String = "",
val conflict: RevisionConflictException? = null, val conflict: RevisionConflictException? = null,
val analysisMessage: String = "", val analysisMessage: String = "",
val analysisScope: String = "week",
) )
@HiltViewModel @HiltViewModel
@@ -159,6 +160,11 @@ class TrackerViewModel @Inject constructor(
persistUi() persistUi()
} }
fun setAnalysisScope(scope: String) {
if (scope !in setOf("week", "overall")) return
_state.value = _state.value.copy(analysisScope = scope)
}
fun setWeek(week: Int) { fun setWeek(week: Int) {
val plan = _state.value.plan ?: return val plan = _state.value.plan ?: return
_state.value = _state.value.copy(week = week.coerceIn(1, plan.weeks)) _state.value = _state.value.copy(week = week.coerceIn(1, plan.weeks))
+1 -1
View File
@@ -1,3 +1,3 @@
<resources> <resources>
<string name="app_name">boehmitools Training</string> <string name="app_name">RipMe</string>
</resources> </resources>
+18 -21
View File
@@ -1,25 +1,22 @@
# Implementierungsplan # Implementierungsstand RipMe 2.1
## Meilenstein 0 ## Enthalten
Backendcode, Mounts, Authentifizierung, Datenmodelle, Migrationen, Beispielpläne, alte Sessions, Analysen, Vorschläge und Web-UIs analysieren. Vertrag und Konfliktmodell dokumentieren. - native Single-Activity-App mit Compose und Material 3
- Tracker-Navigation mit Session, Fortschritt, Plan-Leseansicht und FAQ
- Retrofit, OkHttp, Basic Auth und HTTPS-Sperre im Release
- Room-Cache und FIFO-Queue für Offline-Session-Patches
- WorkManager-Synchronisation mit `expected_revision`
- sichtbare Behandlung von HTTP 409
- strukturierte Ergebnisdaten und Legacy-Migration
- manuelle Wochen- und Gesamtanalysen mit 200/202 und Status-Polling
- vollständige Darstellung des Serverfelds `analysis_cache.*.result`
- WhatsNew-Anzeige nach App-Updates
- Versionsanzeige in den Einstellungen
## Meilenstein 1 ## Bewusst nicht enthalten
Single-Activity-App, Compose/Material 3, Navigation, Hilt, Retrofit/OkHttp, Kotlin Serialization, Room, DataStore, Android-Keystore-Verschlüsselung, WorkManager und Verbindungstest. - Planeditor und Trainingsplan-Schreibzugriffe
- direkte Dateisystemzugriffe
## Meilenstein 2 - direkte OpenAI-Verbindung
- Timer, RIR, RPE oder Technikrating
Trackerplan laden, UI-Zustand pro Plan persistieren, Legacy-IDs migrieren, Sessionstatus und Übungsstatus abbilden, Ergebnisfelder aus Stufe/Übung/Fallback bestimmen, debounced Autosave und Offline-Queue implementieren.
## Meilenstein 3
Lokale Fortschrittskarten, Variantencluster/FAQ, Wochenabschluss, Analyseauswahl, `200`/`202`, persistente Jobanzeige und Polling.
## Meilenstein 4
Entwurfs-CRUD, strukturierte Planansicht, Result-Schema-Editor, Bibliothek, Validierung, Diff, Publish und manuell bestätigte KI-Vorschläge.
## Meilenstein 5
Unit-, Repository-, MockWebServer- und Compose-Tests mit allen gelieferten Fixtures; 360-dp-Prüfung; Light/Dark; Gradle-Test-, Lint- und Assemble-Läufe; ZIP/APK/Build- und Testbericht.
+29 -27
View File
@@ -1,36 +1,38 @@
# Screen Map # Screen Map RipMe
## App-Shell ## App-Navigation
- Start/Verbindung: Server-URL, Benutzername, Passwort, Verbindungstest. - **Tracker**: gesamte Trainingsoberfläche.
- Hauptnavigation: Tracker, Planeditor, Einstellungen. - **Einstellungen**: Server, Anmeldung, Theme, Version und WhatsNew.
- Material-3-Theme mit Light/Dark/System.
## Tracker ## Tracker
- Planauswahl. Die obere Navigation ist fest und besteht aus vier gleich breiten Bereichen:
- Session-Ansicht mit Wochen- und Tageschips.
- Sessionstatus starten, stoppen, fortsetzen, wieder öffnen, abschließen, zurücksetzen.
- Warm-up, Rotationen, Übungen, Cooldown und Stretch.
- Übungsstatus offen, erledigt, teilweise, übersprungen.
- Progressionsauswahl und progressionsabhängiger Ergebnis-Editor.
- Fortschritt: lokale Sessionkennzahlen, Wochenstatus, Analyseauswahl, Cache/Jobstatus.
- Plan: veröffentlichte Struktur und Planrevision.
- FAQ: Trainingsformat, Variantencluster und Backend-Fallbackregeln.
## Planeditor 1. **Session**
- Planauswahl
- horizontal scrollbare Wochen- und Tagesauswahl
- Statusmarkierungen für laufende, gestoppte und abgeschlossene Sessions
- Start, Stop, Fortsetzen, Abschluss und Reset
- strukturierte Ergebnisse, Progressionsstufen und Notizen
- Offline-Queue und Konflikthinweise
2. **Fortschritt**
- lokale Sessionübersicht
- Wochen- oder Gesamtanalyse auswählen
- Woche öffnen oder abschließen
- Analyse manuell starten und Status pollen
- vollständige Analyseausgabe mit Kennzahlen, Übungsständen, Vorschlägen, Warnungen und Fazit
3. **Plan**
- ausschließlich lesende Übersicht des veröffentlichten Plans
4. **FAQ**
- Trainingsformat, Übungsvarianten und serverseitige Fallbackregeln
- Planliste, Auswahl, neuer Plan, Umbenennen, Löschen. Nur die Wochen- und Tagesleisten dürfen horizontal scrollen. Alle anderen Bedienelemente müssen innerhalb der Bildschirmbreite umbrechen oder die verfügbare Breite gleichmäßig aufteilen.
- Entwurfsübersicht und Metadaten.
- Tage, Rotationen und Übungen mit stabilen IDs.
- Ergebnisschema pro Übung.
- Progressionsstufen und überschreibende Ergebnisschemata.
- Übungsbibliothek.
- Prüfung mit Fehlern/Warnungen.
- Tracker-Vorschau und Änderungsübersicht.
- Veröffentlichen.
- Vorschlags-Postfach: prüfen, Ziel öffnen, angenommen/abgelehnt/geprüft markieren.
## Visuelle Referenz ## Einstellungen
In den gelieferten Archiven sind keine Bilddateien oder Screenshots enthalten. Als vorhandene visuelle Referenz wurden daher die mobile-first HTML/CSS-Oberflächen der beiden Plugins ausgewertet. Die native Umsetzung übernimmt Informationshierarchie und Funktionsgruppen, nicht DOM-Struktur oder Web-Komponenten. - Server-URL und Basic-Auth-Zugangsdaten
- Verbindungstest
- System-, Hell- oder Dunkelmodus
- sichtbare Versionsnummer
- WhatsNew erneut öffnen
+10 -6
View File
@@ -24,17 +24,21 @@ require('compileSdk = 35' in app_gradle, "Compile SDK 35")
require('android:usesCleartextTraffic="false"' in manifest, "Release-Manifest verbietet Klartext") require('android:usesCleartextTraffic="false"' in manifest, "Release-Manifest verbietet Klartext")
require('cleartextTrafficPermitted="false"' in network, "Release Network Security verbietet Klartext") require('cleartextTrafficPermitted="false"' in network, "Release Network Security verbietet Klartext")
require('https://tools.d-razz.de/' in source, "Standardserver ist gesetzt") require('https://tools.d-razz.de/' in source, "Standardserver ist gesetzt")
require('<string name="app_name">RipMe</string>' in (ROOT / 'app/src/main/res/values/strings.xml').read_text(), "Sichtbarer App-Name RipMe")
require('Planeditor' not in source and 'EditorScreen' not in source, "Planeditor aus App entfernt")
require('WebView' not in source, "Keine WebView") require('WebView' not in source, "Keine WebView")
require('api.openai.com' not in source.lower(), "Keine direkte OpenAI-URL") require('api.openai.com' not in source.lower(), "Keine direkte OpenAI-URL")
require('sk-' not in source, "Kein erkennbarer OpenAI-Key") require('sk-' not in source, "Kein erkennbarer OpenAI-Key")
require('Credentials.basic' in api, "Basic Auth über OkHttp") require('Credentials.basic' in api, "Basic Auth über OkHttp")
for route in [ for route in ['tracker/session', '/analysis', '/analysis/status']:
'tracker/session', '/analysis', '/analysis/status',
'trainingsplan/api/plans/{planId}/validate',
'trainingsplan/api/plans/{planId}/publish',
'proposals/{proposalId}',
]:
require(route in api, f"API-Route vorhanden: {route}") require(route in api, f"API-Route vorhanden: {route}")
require('trainingsplan/api' not in api, "Keine Planeditor-API im Android-Client")
tracker_screen = (ROOT / 'app/src/main/java/de/drazz/boehmitools/training/ui/tracker/TrackerScreen.kt').read_text()
require('ScrollableTabRow' not in tracker_screen, "Hauptnavigation ist nicht horizontal scrollbar")
require(tracker_screen.count('horizontalScroll(') == 2, "Nur Wochen und Tage scrollen horizontal")
require('record.obj("result")' in tracker_screen, "Analyseergebnis liest Serverfeld result")
require('last_seen_version_code' in source, "WhatsNew-Version wird gespeichert")
require('BuildConfig.VERSION_NAME' in source, "Versionsanzeige ist vorhanden")
fixtures = ROOT / "app/src/test/resources/samples" fixtures = ROOT / "app/src/test/resources/samples"
for name in ["phase-1", "calihoss-newbie-8-wochen"]: for name in ["phase-1", "calihoss-newbie-8-wochen"]: