Android patch
This commit is contained in:
+9
-10
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
-44
@@ -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)
|
||||
}
|
||||
}
|
||||
+50
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<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) {
|
||||
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.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?")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+342
-48
@@ -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<PendingMutation>, 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<PendingMutation>, 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<PendingMutation>, 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 -> ""
|
||||
}
|
||||
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}") }) }
|
||||
FilterChip(
|
||||
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
|
||||
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)
|
||||
}
|
||||
"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") }
|
||||
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")
|
||||
}
|
||||
}
|
||||
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)
|
||||
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<PendingMutation>,
|
||||
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<PendingMutation>,
|
||||
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("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") })
|
||||
Text("Eine laufende Woche erzeugt eine Zwischenanalyse, eine geschlossene Woche eine Abschlussanalyse.", style = MaterialTheme.typography.bodySmall)
|
||||
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<PendingMutation>,
|
||||
@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
|
||||
}
|
||||
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()) {
|
||||
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(4.dp)) {
|
||||
Text("Datenbasis", fontWeight = FontWeight.Bold)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 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))
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">boehmitools Training</string>
|
||||
<string name="app_name">RipMe</string>
|
||||
</resources>
|
||||
|
||||
@@ -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
|
||||
|
||||
+29
-27
@@ -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
|
||||
|
||||
@@ -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('<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('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"]:
|
||||
|
||||
Reference in New Issue
Block a user