diff --git a/android/README.md b/android/README.md index c600ea4..49bc130 100644 --- a/android/README.md +++ b/android/README.md @@ -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 -- Application ID `de.drazz.boehmitools.training` -- Minimum SDK 26 -- konfigurierbarer Server, Standard `https://tools.d-razz.de/` -- HTTP Basic Auth mit Android-Keystore-geschütztem Passwort -- Room-Cache und FIFO-Queue für Session-Patches -- WorkManager-Synchronisation mit sichtbarer `409`-Konfliktbehandlung -- nativer Tracker, Fortschritt/Analyse und Planeditor +- Session-Tracker, Fortschritt, Analyse, Plan-Leseansicht und FAQ +- Room-Cache, Offline-Queue, DataStore und WorkManager +- sichtbarer Revisionskonflikt statt stiller Überschreibung +- App-Version 2.1.0 -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. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 3fb041e..0935f10 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -15,8 +15,8 @@ android { applicationId = "de.drazz.boehmitools.training" minSdk = 26 targetSdk = 35 - versionCode = 1 - versionName = "2.0.0" + versionCode = 2 + versionName = "2.1.0" testInstrumentationRunner = "de.drazz.boehmitools.training.HiltTestRunner" vectorDrawables.useSupportLibrary = true } diff --git a/android/app/src/androidTest/java/de/drazz/boehmitools/training/ui/CompactEditorUiTest.kt b/android/app/src/androidTest/java/de/drazz/boehmitools/training/ui/CompactEditorUiTest.kt deleted file mode 100644 index 039cd3c..0000000 --- a/android/app/src/androidTest/java/de/drazz/boehmitools/training/ui/CompactEditorUiTest.kt +++ /dev/null @@ -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() - - @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) - } -} diff --git a/android/app/src/androidTest/java/de/drazz/boehmitools/training/ui/CompactTrackerUiTest.kt b/android/app/src/androidTest/java/de/drazz/boehmitools/training/ui/CompactTrackerUiTest.kt new file mode 100644 index 0000000..87c8a3f --- /dev/null +++ b/android/app/src/androidTest/java/de/drazz/boehmitools/training/ui/CompactTrackerUiTest.kt @@ -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() + + @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) + } + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f573f72..3523219 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,7 +8,7 @@ android:name=".TrainingApplication" android:allowBackup="false" android:icon="@drawable/ic_launcher_foreground" - android:label="boehmitools Training" + android:label="@string/app_name" android:networkSecurityConfig="@xml/network_security_config" android:supportsRtl="true" android:theme="@style/Theme.BoehmitoolsTraining" diff --git a/android/app/src/main/java/de/drazz/boehmitools/training/MainActivity.kt b/android/app/src/main/java/de/drazz/boehmitools/training/MainActivity.kt index 2aaa706..a7a3624 100644 --- a/android/app/src/main/java/de/drazz/boehmitools/training/MainActivity.kt +++ b/android/app/src/main/java/de/drazz/boehmitools/training/MainActivity.kt @@ -20,8 +20,15 @@ class MainActivity : ComponentActivity() { setContent { val vm: AppViewModel = hiltViewModel() val settings by vm.settings.collectAsStateWithLifecycle() + val uiState by vm.uiState.collectAsStateWithLifecycle() TrainingTheme(settings.theme) { - AppRoot(settings = settings, onTheme = vm::setTheme) + AppRoot( + settings = settings, + showWhatsNew = uiState.showWhatsNew, + onDismissWhatsNew = vm::dismissWhatsNew, + onShowWhatsNew = vm::showWhatsNew, + onTheme = vm::setTheme, + ) } } } diff --git a/android/app/src/main/java/de/drazz/boehmitools/training/ui/App.kt b/android/app/src/main/java/de/drazz/boehmitools/training/ui/App.kt index 91d9a04..5aa9eb7 100644 --- a/android/app/src/main/java/de/drazz/boehmitools/training/ui/App.kt +++ b/android/app/src/main/java/de/drazz/boehmitools/training/ui/App.kt @@ -1,14 +1,15 @@ package de.drazz.boehmitools.training.ui import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.FitnessCenter import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier @@ -17,8 +18,8 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import de.drazz.boehmitools.training.BuildConfig import de.drazz.boehmitools.training.data.settings.SettingsRepository -import de.drazz.boehmitools.training.ui.editor.EditorScreen import de.drazz.boehmitools.training.ui.setup.SettingsScreen import de.drazz.boehmitools.training.ui.tracker.TrackerScreen @@ -27,15 +28,18 @@ private data class AppDestination(val route: String, val label: String, val icon @Composable fun AppRoot( settings: SettingsRepository.PublicSettings, + showWhatsNew: Boolean, + onDismissWhatsNew: () -> Unit, + onShowWhatsNew: () -> Unit, onTheme: (String) -> Unit, ) { val navController = rememberNavController() val destinations = listOf( AppDestination("tracker", "Tracker") { Icon(Icons.Default.FitnessCenter, null) }, - AppDestination("editor", "Planeditor") { Icon(Icons.Default.Edit, null) }, AppDestination("settings", "Einstellungen") { Icon(Icons.Default.Settings, null) }, ) val backStack by navController.currentBackStackEntryAsState() + Scaffold( bottomBar = { NavigationBar { @@ -59,14 +63,38 @@ fun AppRoot( ) { padding -> NavHost(navController = navController, startDestination = "tracker", modifier = Modifier) { composable("tracker") { TrackerScreen(contentPadding = padding) } - composable("editor") { EditorScreen(contentPadding = padding) } composable("settings") { SettingsScreen( contentPadding = padding, current = settings, onTheme = onTheme, + 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") } + }, + ) } diff --git a/android/app/src/main/java/de/drazz/boehmitools/training/ui/AppViewModel.kt b/android/app/src/main/java/de/drazz/boehmitools/training/ui/AppViewModel.kt index 532024a..0276d91 100644 --- a/android/app/src/main/java/de/drazz/boehmitools/training/ui/AppViewModel.kt +++ b/android/app/src/main/java/de/drazz/boehmitools/training/ui/AppViewModel.kt @@ -1,17 +1,28 @@ package de.drazz.boehmitools.training.ui +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope 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 javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +data class AppUiState( + val showWhatsNew: Boolean = false, +) + @HiltViewModel class AppViewModel @Inject constructor( private val settingsRepository: SettingsRepository, + @ApplicationContext private val context: Context, ) : ViewModel() { val settings = settingsRepository.publicSettings.stateIn( viewModelScope, @@ -24,7 +35,42 @@ class AppViewModel @Inject constructor( ), ) + private val _uiState = MutableStateFlow(AppUiState()) + val uiState: StateFlow = _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) { 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) + } } diff --git a/android/app/src/main/java/de/drazz/boehmitools/training/ui/editor/EditorScreen.kt b/android/app/src/main/java/de/drazz/boehmitools/training/ui/editor/EditorScreen.kt deleted file mode 100644 index 6e04eb8..0000000 --- a/android/app/src/main/java/de/drazz/boehmitools/training/ui/editor/EditorScreen.kt +++ /dev/null @@ -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, 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") } }, - ) -} diff --git a/android/app/src/main/java/de/drazz/boehmitools/training/ui/editor/EditorViewModel.kt b/android/app/src/main/java/de/drazz/boehmitools/training/ui/editor/EditorViewModel.kt deleted file mode 100644 index 36342f2..0000000 --- a/android/app/src/main/java/de/drazz/boehmitools/training/ui/editor/EditorViewModel.kt +++ /dev/null @@ -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 = 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 = _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) - } -} diff --git a/android/app/src/main/java/de/drazz/boehmitools/training/ui/setup/SettingsScreen.kt b/android/app/src/main/java/de/drazz/boehmitools/training/ui/setup/SettingsScreen.kt index 879a58e..f68c859 100644 --- a/android/app/src/main/java/de/drazz/boehmitools/training/ui/setup/SettingsScreen.kt +++ b/android/app/src/main/java/de/drazz/boehmitools/training/ui/setup/SettingsScreen.kt @@ -9,16 +9,19 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CloudDone +import androidx.compose.material.icons.filled.Info import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -28,6 +31,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import de.drazz.boehmitools.training.BuildConfig import de.drazz.boehmitools.training.data.settings.SettingsRepository @Composable @@ -35,6 +39,7 @@ fun SettingsScreen( contentPadding: PaddingValues, current: SettingsRepository.PublicSettings, onTheme: (String) -> Unit, + onShowWhatsNew: () -> Unit, viewModel: SettingsViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -46,7 +51,8 @@ fun SettingsScreen( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Text("Einstellungen", style = MaterialTheme.typography.headlineMedium) + Text("RipMe Einstellungen", style = MaterialTheme.typography.headlineMedium) + Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Text("Server & Anmeldung", style = MaterialTheme.typography.titleLarge) @@ -76,10 +82,17 @@ fun SettingsScreen( visualTransformation = PasswordVisualTransformation(), singleLine = true, ) - Button(onClick = viewModel::saveAndTest, enabled = !state.loading) { - if (state.loading) CircularProgressIndicator(modifier = Modifier.height(18.dp), strokeWidth = 2.dp) - else Icon(Icons.Default.CloudDone, null) - Spacer(Modifier.padding(horizontal = 4.dp)) + Button( + onClick = viewModel::saveAndTest, + enabled = !state.loading, + 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") } if (state.message.isNotBlank()) Text(state.message, color = MaterialTheme.colorScheme.primary) @@ -90,16 +103,36 @@ fun SettingsScreen( Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Text("Darstellung", style = MaterialTheme.typography.titleLarge) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { listOf("system" to "System", "light" to "Hell", "dark" to "Dunkel").forEach { (value, label) -> FilterChip( selected = current.theme == value, onClick = { onTheme(value) }, - label = { Text(label) }, + 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?") + } + } + } } } diff --git a/android/app/src/main/java/de/drazz/boehmitools/training/ui/tracker/TrackerScreen.kt b/android/app/src/main/java/de/drazz/boehmitools/training/ui/tracker/TrackerScreen.kt index 00de35e..9ca2cd1 100644 --- a/android/app/src/main/java/de/drazz/boehmitools/training/ui/tracker/TrackerScreen.kt +++ b/android/app/src/main/java/de/drazz/boehmitools/training/ui/tracker/TrackerScreen.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle @@ -23,12 +22,11 @@ import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.AlertDialog -import androidx.compose.material3.AssistChip import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -40,7 +38,7 @@ 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.TabRow import androidx.compose.material3.Tab import androidx.compose.material3.Text 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.trackerSession import de.drazz.boehmitools.training.domain.updated -import de.drazz.boehmitools.training.domain.updatedString -import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.doubleOrNull -import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @OptIn(ExperimentalMaterial3Api::class) @@ -102,7 +97,7 @@ fun TrackerScreen( TopAppBar( title = { 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) } }, @@ -126,9 +121,13 @@ fun TrackerScreen( ) { inner -> Column(Modifier.fillMaxSize().padding(inner)) { val tabs = listOf("session" to "Session", "progress" to "Fortschritt", "plan" to "Plan", "faq" to "FAQ") - ScrollableTabRow(selectedTabIndex = tabs.indexOfFirst { it.first == state.tab }.coerceAtLeast(0)) { + TabRow(selectedTabIndex = tabs.indexOfFirst { it.first == state.tab }.coerceAtLeast(0)) { tabs.forEach { (id, label) -> - Tab(selected = state.tab == id, onClick = { viewModel.setTab(id) }, text = { Text(label) }) + Tab( + selected = state.tab == id, + onClick = { viewModel.setTab(id) }, + text = { Text(label, maxLines = 2, style = MaterialTheme.typography.labelMedium) }, + ) } } when { @@ -175,7 +174,7 @@ private fun SessionPane(state: TrackerUiState, pending: List, v ) { if (state.error.isNotBlank()) Text(state.error, color = MaterialTheme.colorScheme.error) 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) SequenceCard("Aufwärmen", "warmup", day.warmup, session, vm) @@ -185,7 +184,7 @@ private fun SessionPane(state: TrackerUiState, pending: List, v Text(rotation.label, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) rotation.exercises.forEach { exercise -> ExerciseCard(exercise, session, plan, vm) - Divider() + HorizontalDivider() } } } @@ -216,13 +215,54 @@ private fun PendingQueue(pending: List, vm: TrackerViewModel) { } @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)) { - Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - (1..plan.weeks).forEach { value -> FilterChip(selected = value == week, onClick = { vm.setWeek(value) }, label = { Text("W$value") }) } + WeekPicker(plan, tracker, week, vm) + 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 -> "" + } + FilterChip( + selected = value.num == day, + onClick = { vm.setDay(value.num) }, + label = { Text("Tag ${value.num}$marker") }, + ) + } } - Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - plan.days.forEach { value -> FilterChip(selected = value.num == day, onClick = { vm.setDay(value.num) }, label = { Text("Tag ${value.num}") }) } + } +} + +@Composable +private fun 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 private fun SessionHeader(plan: PlanUi, day: DayUi, session: JsonObject, week: Int, vm: TrackerViewModel) { val status = session.string("status", "planned") + val statusLabel = when (status) { + "in_progress" -> "Läuft" + "stopped" -> "Gestoppt" + "completed" -> "Abgeschlossen" + else -> "Geplant" + } Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Text("Woche $week · Tag ${day.num}", style = MaterialTheme.typography.labelLarge) Text(day.focus, style = MaterialTheme.typography.headlineSmall) - Text("Planrevision R${session.int("plan_revision", plan.publishedRevision)} · Status: $status", style = MaterialTheme.typography.bodySmall) + Text("Planrevision R${session.int("plan_revision", plan.publishedRevision)} · Status: $statusLabel", style = MaterialTheme.typography.bodySmall) when (status) { - "in_progress" -> Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { vm.setSessionStatus("completed") }) { Icon(Icons.Default.CheckCircle, null); Spacer(Modifier.width(6.dp)); Text("Abschließen") } - OutlinedButton(onClick = { vm.setSessionStatus("stopped") }) { Icon(Icons.Default.Stop, null); Spacer(Modifier.width(6.dp)); Text("Stoppen") } + "in_progress" -> Row( + modifier = Modifier.fillMaxWidth(), + 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) + } + OutlinedButton( + onClick = { vm.setSessionStatus("stopped") }, + 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") } - "stopped" -> Button(onClick = { vm.setSessionStatus("in_progress") }) { Icon(Icons.Default.PlayArrow, null); Spacer(Modifier.width(6.dp)); Text("Fortsetzen") } - "completed" -> OutlinedButton(onClick = { vm.setSessionStatus("in_progress") }) { Text("Wieder öffnen") } - else -> Button(onClick = { vm.setSessionStatus("in_progress") }) { Icon(Icons.Default.PlayArrow, null); Spacer(Modifier.width(6.dp)); Text("Session starten") } } - TextButton(onClick = vm::resetSession) { Text("Session zurücksetzen") } + TextButton(onClick = vm::resetSession, modifier = Modifier.fillMaxWidth()) { + 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) Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { 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) -> - 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, val cache = state.response.obj("analysis_cache").takeIf { it.isNotEmpty() } ?: state.tracker.obj("analysis_cache") val weekRecord = cache.obj("weeks")[state.week.toString()] as? JsonObject - val overall = cache["overall"] as? JsonObject + 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) + Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Text("Lokale Übersicht", style = MaterialTheme.typography.titleMedium) @@ -502,29 +614,83 @@ private fun ProgressPane(state: TrackerUiState, pending: List, Text("Trackerrevision ${state.tracker.int("revision", 1)} · Planrevision ${plan.publishedRevision}") } } + if (pending.isNotEmpty()) PendingQueue(pending, vm) + Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text("Wochenstatus", style = MaterialTheme.typography.titleMedium) - val closed = state.tracker.obj("week_statuses").obj(state.week.toString()).string("status") == "closed" - FilterChip(selected = closed, onClick = { vm.setWeekClosed(!closed) }, label = { Text(if (closed) "Woche ${state.week} abgeschlossen" else "Woche ${state.week} läuft") }) - Text("Eine laufende Woche erzeugt eine Zwischenanalyse, eine geschlossene Woche eine Abschlussanalyse.", style = MaterialTheme.typography.bodySmall) + 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()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text("Wochenstatus", style = MaterialTheme.typography.titleMedium) + val closed = state.tracker.obj("week_statuses").obj(state.week.toString()).string("status") == "closed" + FilterChip( + selected = closed, + onClick = { vm.setWeekClosed(!closed) }, + label = { Text(if (closed) "Woche ${state.week} abgeschlossen" else "Woche ${state.week} läuft") }, + modifier = Modifier.fillMaxWidth(), + ) + Text( + "Eine laufende Woche erzeugt eine Zwischenanalyse, eine geschlossene Woche eine Abschlussanalyse.", + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Text("Manuelle Analyse", style = MaterialTheme.typography.titleMedium) - Button(onClick = { vm.startAnalysis("week", state.week) }, enabled = !running) { Text("Woche ${state.week} analysieren") } - OutlinedButton(onClick = { vm.startAnalysis("overall") }, enabled = !running) { Text("Gesamtanalyse") } + Button( + 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) { CircularProgressIndicator() 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) } } - AnalysisCard("Analyse Woche ${state.week}", weekRecord) - AnalysisCard("Gesamtanalyse", overall) + + AnalysisCard(selectedTitle, selectedRecord) Spacer(Modifier.height(24.dp)) } } @@ -532,27 +698,155 @@ private fun ProgressPane(state: TrackerUiState, pending: List, @Composable private fun AnalysisCard(title: String, record: JsonObject?) { 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) if (record == null) { - Text("Noch keine aktuelle Analyse vorhanden.") - } else { - val response = record.obj("response") - Text(response.string("headline", "Analyse"), style = MaterialTheme.typography.titleLarge) - Text(response.string("summary")) - response.array("metrics").mapNotNull { it as? JsonObject }.forEach { metric -> - AssistChip(onClick = {}, label = { Text("${metric.string("label")}: ${metric.string("value")}") }) + Text("Für diese Auswahl liegt noch keine Analyse vor.") + return@Column + } + + 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()) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Datenbasis", fontWeight = FontWeight.Bold) + Text(dataQuality) + } } - response.array("exercise_updates").mapNotNull { it as? JsonObject }.take(6).forEach { update -> + } + + 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(10.dp)) { - Text(update.string("name"), fontWeight = FontWeight.Bold) - Text(update.string("next_action")) + 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) } } } } + + 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) + } } } } diff --git a/android/app/src/main/java/de/drazz/boehmitools/training/ui/tracker/TrackerViewModel.kt b/android/app/src/main/java/de/drazz/boehmitools/training/ui/tracker/TrackerViewModel.kt index 3ac5a44..39b0d37 100644 --- a/android/app/src/main/java/de/drazz/boehmitools/training/ui/tracker/TrackerViewModel.kt +++ b/android/app/src/main/java/de/drazz/boehmitools/training/ui/tracker/TrackerViewModel.kt @@ -75,6 +75,7 @@ data class TrackerUiState( val saveState: String = "", val conflict: RevisionConflictException? = null, val analysisMessage: String = "", + val analysisScope: String = "week", ) @HiltViewModel @@ -159,6 +160,11 @@ class TrackerViewModel @Inject constructor( persistUi() } + fun setAnalysisScope(scope: String) { + if (scope !in setOf("week", "overall")) return + _state.value = _state.value.copy(analysisScope = scope) + } + fun setWeek(week: Int) { val plan = _state.value.plan ?: return _state.value = _state.value.copy(week = week.coerceIn(1, plan.weeks)) diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 6576291..6c1bf05 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,3 +1,3 @@ - boehmitools Training + RipMe diff --git a/android/docs/IMPLEMENTATION_PLAN.md b/android/docs/IMPLEMENTATION_PLAN.md index 2c7df07..abdc3cc 100644 --- a/android/docs/IMPLEMENTATION_PLAN.md +++ b/android/docs/IMPLEMENTATION_PLAN.md @@ -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. - -## Meilenstein 2 - -Trackerplan laden, UI-Zustand pro Plan persistieren, Legacy-IDs migrieren, Sessionstatus und Übungsstatus abbilden, Ergebnisfelder aus Stufe/Übung/Fallback bestimmen, debounced Autosave und Offline-Queue implementieren. - -## Meilenstein 3 - -Lokale Fortschrittskarten, Variantencluster/FAQ, Wochenabschluss, Analyseauswahl, `200`/`202`, persistente Jobanzeige und Polling. - -## Meilenstein 4 - -Entwurfs-CRUD, strukturierte Planansicht, Result-Schema-Editor, Bibliothek, Validierung, Diff, Publish und manuell bestätigte KI-Vorschläge. - -## Meilenstein 5 - -Unit-, Repository-, MockWebServer- und Compose-Tests mit allen gelieferten Fixtures; 360-dp-Prüfung; Light/Dark; Gradle-Test-, Lint- und Assemble-Läufe; ZIP/APK/Build- und Testbericht. +- Planeditor und Trainingsplan-Schreibzugriffe +- direkte Dateisystemzugriffe +- direkte OpenAI-Verbindung +- Timer, RIR, RPE oder Technikrating diff --git a/android/docs/SCREEN_MAP.md b/android/docs/SCREEN_MAP.md index ed01975..dc870f1 100644 --- a/android/docs/SCREEN_MAP.md +++ b/android/docs/SCREEN_MAP.md @@ -1,36 +1,38 @@ -# Screen Map +# Screen Map RipMe -## App-Shell +## App-Navigation -- Start/Verbindung: Server-URL, Benutzername, Passwort, Verbindungstest. -- Hauptnavigation: Tracker, Planeditor, Einstellungen. -- Material-3-Theme mit Light/Dark/System. +- **Tracker**: gesamte Trainingsoberfläche. +- **Einstellungen**: Server, Anmeldung, Theme, Version und WhatsNew. ## Tracker -- Planauswahl. -- Session-Ansicht mit Wochen- und Tageschips. -- Sessionstatus starten, stoppen, fortsetzen, wieder öffnen, abschließen, zurücksetzen. -- Warm-up, Rotationen, Übungen, Cooldown und Stretch. -- Übungsstatus offen, erledigt, teilweise, übersprungen. -- Progressionsauswahl und progressionsabhängiger Ergebnis-Editor. -- Fortschritt: lokale Sessionkennzahlen, Wochenstatus, Analyseauswahl, Cache/Jobstatus. -- Plan: veröffentlichte Struktur und Planrevision. -- FAQ: Trainingsformat, Variantencluster und Backend-Fallbackregeln. +Die obere Navigation ist fest und besteht aus vier gleich breiten Bereichen: -## 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. -- 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. +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. -## 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 diff --git a/android/tools/static_acceptance.py b/android/tools/static_acceptance.py index b6ef493..c97f06c 100755 --- a/android/tools/static_acceptance.py +++ b/android/tools/static_acceptance.py @@ -24,17 +24,21 @@ require('compileSdk = 35' in app_gradle, "Compile SDK 35") require('android:usesCleartextTraffic="false"' in manifest, "Release-Manifest verbietet Klartext") require('cleartextTrafficPermitted="false"' in network, "Release Network Security verbietet Klartext") require('https://tools.d-razz.de/' in source, "Standardserver ist gesetzt") +require('RipMe' 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('api.openai.com' not in source.lower(), "Keine direkte OpenAI-URL") require('sk-' not in source, "Kein erkennbarer OpenAI-Key") require('Credentials.basic' in api, "Basic Auth über OkHttp") -for route in [ - 'tracker/session', '/analysis', '/analysis/status', - 'trainingsplan/api/plans/{planId}/validate', - 'trainingsplan/api/plans/{planId}/publish', - 'proposals/{proposalId}', -]: +for route in ['tracker/session', '/analysis', '/analysis/status']: 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" for name in ["phase-1", "calihoss-newbie-8-wochen"]: