This commit is contained in:
2026-09-01 14:57:28 +02:00
parent 56b1f4263b
commit e97ffe1701
21 changed files with 2028 additions and 25528 deletions
@@ -511,3 +511,19 @@ fun resultDataWithSets(data: JsonObject, sets: Int): JsonObject {
}
return result
}
fun resultDataWithoutSet(data: JsonObject, index: Int): JsonObject {
val currentCount = data.int("sets", 1).coerceIn(1, 20)
if (currentCount <= 1 || index !in 0 until currentCount) return data
val values = data.toMutableMap()
val newCount = currentCount - 1
values["sets"] = JsonPrimitive(newCount)
for (field in listOf("values", "left_values", "right_values")) {
val list = data.array(field).toMutableList()
if (index in list.indices) list.removeAt(index)
while (list.size < newCount) list.add(JsonNull)
values[field] = JsonArray(list.take(newCount))
}
return JsonObject(values)
}
@@ -1,29 +1,35 @@
package de.drazz.boehmitools.training.ui
import androidx.compose.foundation.background
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Analytics
import androidx.compose.material.icons.filled.FitnessCenter
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
@@ -45,64 +51,106 @@ fun AppRoot(
) {
val navController = rememberNavController()
val destinations = listOf(
AppDestination("tracker", "Tracking") { Icon(Icons.Default.FitnessCenter, null) },
AppDestination("settings", "Mehr") { Icon(Icons.Default.Settings, null) },
AppDestination("plan", "Plan") { Icon(Icons.Default.Home, null) },
AppDestination("tracking", "Tracking") { Icon(Icons.Default.FitnessCenter, null) },
AppDestination("analysis", "Analyse") { Icon(Icons.Default.Analytics, null) },
AppDestination("more", "Mehr") { Icon(Icons.Default.Settings, null) },
)
val backStack by navController.currentBackStackEntryAsState()
val currentRoute = backStack?.destination?.route ?: "tracking"
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
bottomBar = {
Box(Modifier.padding(horizontal = 18.dp, vertical = 12.dp)) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
) {
Scaffold(
containerColor = Color.Transparent,
bottomBar = {
CompactBottomBar(destinations, currentRoute) { route ->
navController.navigate(route) {
popUpTo(navController.graph.startDestinationId) { saveState = true }
launchSingleTop = true
restoreState = true
}
}
},
) { padding ->
NavHost(navController = navController, startDestination = "tracking") {
composable("plan") { TrackerScreen(contentPadding = paddedContent(padding), initialTab = "plan") }
composable("tracking") { TrackerScreen(contentPadding = paddedContent(padding), initialTab = "session") }
composable("analysis") { TrackerScreen(contentPadding = paddedContent(padding), initialTab = "progress") }
composable("more") {
SettingsScreen(
contentPadding = paddedContent(padding),
current = settings,
onTheme = onTheme,
onShowWhatsNew = onShowWhatsNew,
)
}
}
}
if (showWhatsNew) {
WhatsNewDialog(onDismiss = onDismissWhatsNew)
}
}
}
private fun paddedContent(original: PaddingValues): PaddingValues = PaddingValues(
top = original.calculateTopPadding(),
bottom = original.calculateBottomPadding(),
)
@Composable
private fun CompactBottomBar(
destinations: List<AppDestination>,
currentRoute: String,
onNavigate: (String) -> Unit,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 1.dp,
shadowElevation = 6.dp,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 6.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
destinations.forEach { destination ->
val selected = currentRoute == destination.route
Surface(
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surface,
shadowElevation = 14.dp,
tonalElevation = 4.dp,
onClick = { onNavigate(destination.route) },
modifier = Modifier.weight(1f),
color = Color.Transparent,
) {
NavigationBar(
containerColor = Color.Transparent,
tonalElevation = 0.dp,
Column(
modifier = Modifier.padding(vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
destinations.forEach { destination ->
val selected = backStack?.destination?.hierarchy?.any { it.route == destination.route } == true
NavigationBarItem(
selected = selected,
onClick = {
navController.navigate(destination.route) {
popUpTo("tracker") { saveState = true }
launchSingleTop = true
restoreState = true
}
},
icon = destination.icon,
label = { Text(destination.label) },
colors = NavigationBarItemDefaults.colors(
indicatorColor = MaterialTheme.colorScheme.primaryContainer,
selectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.primary,
),
)
Box(
modifier = Modifier
.size(34.dp)
.clip(CircleShape)
.background(if (selected) MaterialTheme.colorScheme.primary.copy(alpha = 0.16f) else Color.Transparent),
contentAlignment = Alignment.Center,
) {
destination.icon()
}
Text(
destination.label,
style = MaterialTheme.typography.labelMedium,
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
)
}
}
}
},
) { padding ->
NavHost(navController = navController, startDestination = "tracker", modifier = Modifier.fillMaxSize()) {
composable("tracker") { TrackerScreen(contentPadding = padding) }
composable("settings") {
SettingsScreen(
contentPadding = padding,
current = settings,
onTheme = onTheme,
onShowWhatsNew = onShowWhatsNew,
)
}
}
}
if (showWhatsNew) WhatsNewDialog(onDismiss = onDismissWhatsNew)
}
@Composable
@@ -110,7 +158,14 @@ private fun WhatsNewDialog(onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Neu in RipMe ${BuildConfig.VERSION_NAME}") },
text = { Text("Modern Card UI, sicherer Offline-Tracker und vollständige Analyseansichten.") },
text = {
Text(
"• RipMe konzentriert sich vollständig auf den Trainingstracker.\n" +
"• Analyseergebnisse werden jetzt vollständig in der App angezeigt.\n" +
"• Wochen und Trainingstage zeigen ihren aktuellen Abschlussstatus.\n" +
"• Offline erfasste Sessionänderungen werden weiterhin sicher synchronisiert.",
)
},
confirmButton = { TextButton(onClick = onDismiss) { Text("Verstanden") } },
)
}
@@ -2,6 +2,7 @@ package de.drazz.boehmitools.training.ui.setup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -9,24 +10,27 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
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.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@@ -49,90 +53,60 @@ fun SettingsScreen(
.padding(contentPadding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text("RipMe Einstellungen", style = MaterialTheme.typography.headlineMedium)
Text("Mehr", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text("Einstellungen & App", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Server & Anmeldung", style = MaterialTheme.typography.titleLarge)
Text(
"Basic-Auth-Zugangsdaten werden mit einem Android-Keystore-Schlüssel verschlüsselt. Release-Builds akzeptieren ausschließlich HTTPS.",
style = MaterialTheme.typography.bodyMedium,
)
OutlinedTextField(
value = state.serverUrl,
onValueChange = viewModel::updateServer,
modifier = Modifier.fillMaxWidth(),
label = { Text("Server-URL") },
singleLine = true,
)
OutlinedTextField(
value = state.username,
onValueChange = viewModel::updateUsername,
modifier = Modifier.fillMaxWidth(),
label = { Text("Benutzername") },
singleLine = true,
)
OutlinedTextField(
value = state.password,
onValueChange = viewModel::updatePassword,
modifier = Modifier.fillMaxWidth(),
label = { Text("Passwort") },
visualTransformation = PasswordVisualTransformation(),
singleLine = true,
)
Button(
onClick = viewModel::saveAndTest,
enabled = !state.loading,
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")
SettingsCard(title = "Server & Anmeldung", subtitle = "Zugangsdaten werden lokal verschlüsselt gespeichert.") {
OutlinedTextField(value = state.serverUrl, onValueChange = viewModel::updateServer, modifier = Modifier.fillMaxWidth(), label = { Text("Server-URL") }, singleLine = true)
OutlinedTextField(value = state.username, onValueChange = viewModel::updateUsername, modifier = Modifier.fillMaxWidth(), label = { Text("Benutzername") }, singleLine = true)
OutlinedTextField(value = state.password, onValueChange = viewModel::updatePassword, modifier = Modifier.fillMaxWidth(), label = { Text("Passwort") }, visualTransformation = PasswordVisualTransformation(), singleLine = true)
Button(onClick = viewModel::saveAndTest, enabled = !state.loading, modifier = Modifier.fillMaxWidth()) {
if (state.loading) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Icon(Icons.Default.CloudDone, null)
}
if (state.message.isNotBlank()) Text(state.message, color = MaterialTheme.colorScheme.primary)
if (state.error.isNotBlank()) Text(state.error, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.size(8.dp))
Text("Speichern & Verbindung testen")
}
if (state.message.isNotBlank()) Text(state.message, color = MaterialTheme.colorScheme.primary)
if (state.error.isNotBlank()) Text(state.error, color = MaterialTheme.colorScheme.error)
}
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text("Darstellung", style = MaterialTheme.typography.titleLarge)
Row(
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, maxLines = 1) },
modifier = Modifier.weight(1f),
)
}
SettingsCard(title = "Darstellung", subtitle = "Modern Card Style in Hell, Dunkel oder System.") {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf("system" to "System", "light" to "Hell", "dark" to "Dunkel").forEach { (value, label) ->
FilterChip(selected = current.theme == value, onClick = { onTheme(value) }, label = { Text(label) }, 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?")
}
SettingsCard(title = "Über RipMe", subtitle = "Version ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") {
Text("Native Android-App für deinen Trainingstracker im Modern-Card-Stil.")
OutlinedButton(onClick = onShowWhatsNew, modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Default.Info, null)
Spacer(Modifier.size(8.dp))
Text("Was ist neu?")
}
}
}
}
@Composable
private fun SettingsCard(title: String, subtitle: String, content: @Composable ColumnScope.() -> Unit) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 1.dp,
shadowElevation = 8.dp,
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp), content = {
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
content()
})
}
}
@@ -1,46 +1,66 @@
package de.drazz.boehmitools.training.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.Typography
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
private val Light = lightColorScheme(
primary = Color(0xFF7C3AED),
primary = Color(0xFF8E56FF),
onPrimary = Color.White,
primaryContainer = Color(0xFFEDE9FE),
onPrimaryContainer = Color(0xFF2E1065),
secondary = Color(0xFF4F46E5),
primaryContainer = Color(0xFFEDE3FF),
onPrimaryContainer = Color(0xFF2B1459),
secondary = Color(0xFF6A7086),
onSecondary = Color.White,
secondaryContainer = Color(0xFFE0E7FF),
tertiary = Color(0xFF06B6D4),
background = Color(0xFFF4F5FB),
onBackground = Color(0xFF171725),
secondaryContainer = Color(0xFFF0F2F8),
onSecondaryContainer = Color(0xFF1C2132),
tertiary = Color(0xFFAC8BFF),
background = Color(0xFFF5F6FB),
onBackground = Color(0xFF171923),
surface = Color(0xFFFFFFFF),
onSurface = Color(0xFF171725),
surfaceVariant = Color(0xFFF0F1F7),
onSurfaceVariant = Color(0xFF69697A),
outline = Color(0xFFD9DAE5),
onSurface = Color(0xFF171923),
surfaceVariant = Color(0xFFF1F2F8),
onSurfaceVariant = Color(0xFF666B7B),
outline = Color(0xFFDEE2EE),
outlineVariant = Color(0xFFE9ECF6),
error = Color(0xFFC54646),
onError = Color.White,
)
private val Dark = darkColorScheme(
primary = Color(0xFFA78BFA),
onPrimary = Color(0xFF24005B),
primaryContainer = Color(0xFF4C1D95),
onPrimaryContainer = Color(0xFFEDE9FE),
secondary = Color(0xFF818CF8),
onSecondary = Color(0xFF10144A),
secondaryContainer = Color(0xFF312E81),
tertiary = Color(0xFF22D3EE),
background = Color(0xFF0B0D17),
onBackground = Color(0xFFF5F5FA),
surface = Color(0xFF151824),
onSurface = Color(0xFFF5F5FA),
surfaceVariant = Color(0xFF202432),
onSurfaceVariant = Color(0xFFB8BBC8),
outline = Color(0xFF343849),
primary = Color(0xFFA66BFF),
onPrimary = Color(0xFF1D0C45),
primaryContainer = Color(0xFF2A175B),
onPrimaryContainer = Color(0xFFEFE7FF),
secondary = Color(0xFFAAB1C8),
onSecondary = Color(0xFF1B2236),
secondaryContainer = Color(0xFF1C2235),
onSecondaryContainer = Color(0xFFDEE3F7),
tertiary = Color(0xFFCC9DFF),
background = Color(0xFF0B1020),
onBackground = Color(0xFFF3F5FB),
surface = Color(0xFF161C2D),
onSurface = Color(0xFFF3F5FB),
surfaceVariant = Color(0xFF21283C),
onSurfaceVariant = Color(0xFFB1B8CD),
outline = Color(0xFF363D55),
outlineVariant = Color(0xFF272E45),
error = Color(0xFFFF8A8A),
onError = Color(0xFF320F16),
)
private val AppShapes = Shapes(
extraSmall = RoundedCornerShape(12.dp),
small = RoundedCornerShape(18.dp),
medium = RoundedCornerShape(24.dp),
large = RoundedCornerShape(28.dp),
extraLarge = RoundedCornerShape(32.dp),
)
@Composable
@@ -50,5 +70,10 @@ fun TrainingTheme(theme: String, content: @Composable () -> Unit) {
"light" -> false
else -> isSystemInDarkTheme()
}
MaterialTheme(colorScheme = if (dark) Dark else Light, content = content)
MaterialTheme(
colorScheme = if (dark) Dark else Light,
typography = Typography(),
shapes = AppShapes,
content = content,
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,91 @@
package de.drazz.boehmitools.training.domain
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Test
class ResultDataEditingTest {
@Test
fun addingSetPreservesAllExistingValuesAndWeight() {
val original = buildJsonObject {
put("version", 2)
put("mode", "reps")
put("laterality", "bilateral")
put("sides_mode", "same")
put("sets", 3)
put("weight_kg", 80.0)
put("values", buildJsonArray {
add(8.0)
add(7.0)
add(6.0)
})
put("left_values", buildJsonArray { repeat(3) { add(JsonNull) } })
put("right_values", buildJsonArray { repeat(3) { add(JsonNull) } })
}
val expanded = resultDataWithSets(original, 4)
assertEquals(4, expanded.int("sets"))
assertEquals(80.0, expanded["weight_kg"]!!.jsonPrimitive.doubleOrNull!!, 0.0)
assertEquals(listOf(8.0, 7.0, 6.0), expanded.array("values").take(3).map { it.jsonPrimitive.doubleOrNull })
assertEquals(JsonNull, expanded.array("values")[3])
}
@Test
fun removingMiddleSetKeepsRemainingValuesInOrder() {
val original = buildJsonObject {
put("version", 2)
put("mode", "reps")
put("laterality", "bilateral")
put("sides_mode", "same")
put("sets", 3)
put("weight_kg", JsonPrimitive(85.0))
put("values", buildJsonArray {
add(10.0)
add(8.0)
add(6.0)
})
put("left_values", buildJsonArray { repeat(3) { add(JsonNull) } })
put("right_values", buildJsonArray { repeat(3) { add(JsonNull) } })
}
val reduced = resultDataWithoutSet(original, 1)
assertEquals(2, reduced.int("sets"))
assertEquals(listOf(10.0, 6.0), reduced.array("values").map { it.jsonPrimitive.doubleOrNull })
assertEquals(85.0, reduced["weight_kg"]!!.jsonPrimitive.doubleOrNull!!, 0.0)
}
@Test
fun removingUnilateralSetPreservesBothSides() {
val original = buildJsonObject {
put("version", 2)
put("mode", "seconds")
put("laterality", "unilateral")
put("sides_mode", "separate")
put("sets", 3)
put("weight_kg", JsonNull)
put("values", buildJsonArray { repeat(3) { add(JsonNull) } })
put("left_values", buildJsonArray {
add(20.0)
add(18.0)
add(16.0)
})
put("right_values", buildJsonArray {
add(19.0)
add(17.0)
add(15.0)
})
}
val reduced = resultDataWithoutSet(original, 0)
assertEquals(listOf(18.0, 16.0), reduced.array("left_values").map { it.jsonPrimitive.doubleOrNull })
assertEquals(listOf(17.0, 15.0), reduced.array("right_values").map { it.jsonPrimitive.doubleOrNull })
}
}