feat: add Training Timer and calculation of optimal Round Times

This commit is contained in:
Patryk Hegenberg 2025-09-22 19:29:48 +02:00
parent 6d9151c8ec
commit cfbd2a313b
10 changed files with 271 additions and 149 deletions

View file

@ -2,6 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"

View file

@ -34,7 +34,7 @@ class MainActivity : ComponentActivity() {
private val apiService by lazy { private val apiService by lazy {
Retrofit.Builder() Retrofit.Builder()
.baseUrl("http://192.168.178.43:8080/") // WICHTIG: Deine lokale IP .baseUrl("https://kb.patanix.de/")
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.build() .build()
.create(de.patani.kettlebelltracker.data.remote.ApiService::class.java) .create(de.patani.kettlebelltracker.data.remote.ApiService::class.java)

View file

@ -1,10 +1,38 @@
package de.patani.kettlebelltracker.data.remote package de.patani.kettlebelltracker.data.remote
import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.Response
interface ApiService { interface ApiService {
@POST("trainings/") @POST("trainings/")
suspend fun sendTrainingData(@Body payload: TrainingPayload): Response<Unit> suspend fun sendTrainingData(@Body payload: TrainingPayload): Response<Unit>
@POST("trainings/recommend-rest")
suspend fun getRecommendedRest(@Body request: RestRecommendationRequest): Response<ApiResponse<RestRecommendationData>>
} }
data class RestRecommendationRequest(
val uuid: String,
val reps_per_set: Int,
val current_sets: Int
)
data class RestRecommendationResponse(
val recommended_rest_seconds: Int
)
data class ApiResponse<T>(
val status: String,
val message: String?,
val data: T?
)
data class RestRecommendationData(
val recommended_rest: Int,
val expected_sets: Int,
val last_training_rest: Int?,
val last_training_sets: Int?,
val reasoning: String?
)

View file

@ -3,8 +3,12 @@ package de.patani.kettlebelltracker.repositories
import android.util.Log import android.util.Log
import de.patani.kettlebelltracker.data.remote.TrainingPayload import de.patani.kettlebelltracker.data.remote.TrainingPayload
import de.patani.kettlebelltracker.data.remote.ApiService import de.patani.kettlebelltracker.data.remote.ApiService
import de.patani.kettlebelltracker.data.remote.RestRecommendationData
import de.patani.kettlebelltracker.data.remote.RestRecommendationRequest
import de.patani.kettlebelltracker.data.remote.RestRecommendationResponse
class ApiRepository(private val apiService: ApiService) { class ApiRepository(private val apiService: ApiService) {
suspend fun sendTrainingData(session: de.patani.kettlebelltracker.data.local.TrainingSession, uuid: String) { suspend fun sendTrainingData(session: de.patani.kettlebelltracker.data.local.TrainingSession, uuid: String) {
try { try {
val rest = if (session.sets > 0) { val rest = if (session.sets > 0) {
@ -29,4 +33,19 @@ class ApiRepository(private val apiService: ApiService) {
Log.e("ApiRepository", "API Error: Failed to send training data", e) Log.e("ApiRepository", "API Error: Failed to send training data", e)
} }
} }
suspend fun getRecommendedRest(uuid: String, repsPerSet: Int, currentSets: Int): RestRecommendationData? {
return try {
val request = RestRecommendationRequest(uuid, repsPerSet, currentSets)
val response = apiService.getRecommendedRest(request)
if (response.isSuccessful) {
response.body()?.data
} else {
null
}
} catch (e: Exception) {
null
}
}
} }

View file

@ -1,96 +1,3 @@
//
//import androidx.compose.foundation.layout.*
//import androidx.compose.foundation.lazy.LazyColumn
//import androidx.compose.foundation.lazy.items
//import androidx.compose.material3.*
//import androidx.compose.runtime.*
//import androidx.compose.ui.Modifier
//import androidx.compose.ui.unit.dp
//import de.patani.kettlebelltracker.viewmodels.HistoryViewModel
//import de.patani.kettlebelltracker.util.formatDate
//import de.patani.kettlebelltracker.util.formatDuration
//import de.patani.kettlebelltracker.data.local.TrainingSession
//
//@Composable
//fun HistoryScreen(viewModel: HistoryViewModel) {
// val history by viewModel.history.collectAsState()
//
// if (history.isEmpty()) {
// Box(modifier = Modifier.fillMaxSize(), contentAlignment = androidx.compose.ui.Alignment.Center) {
// Text("Noch keine Trainingsdaten vorhanden.")
// }
// } else {
// LazyColumn(
// modifier = Modifier.fillMaxSize(),
// contentPadding = PaddingValues(16.dp),
// verticalArrangement = Arrangement.spacedBy(8.dp)
// ) {
// item {
// Row(Modifier.fillMaxWidth()) {
// Text("Datum", modifier = Modifier.weight(1f))
// Text("Sätze", modifier = Modifier.weight(0.5f))
// Text("Dauer", modifier = Modifier.weight(0.7f))
// Text("Reps", modifier = Modifier.weight(0.5f))
// }
// Divider(modifier = Modifier.padding(vertical = 8.dp))
// }
// items(history) { session ->
// HistoryItem(session = session, onUpdate = viewModel::updateSession, onDelete = viewModel::deleteSession)
// }
// }
// }
//}
//
//@OptIn(ExperimentalMaterial3Api::class)
//@Composable
//fun HistoryItem(session: TrainingSession, onUpdate: (TrainingSession) -> Unit, onDelete: (TrainingSession) -> Unit) {
// var showDialog by remember { mutableStateOf(false) }
//
// Card(onClick = { showDialog = true }, modifier = Modifier.fillMaxWidth()) {
// Row(modifier = Modifier.padding(16.dp)) {
// Text(text = session.date.formatDate(), modifier = Modifier.weight(1f))
// Text(text = session.sets.toString(), modifier = Modifier.weight(0.5f))
// Text(text = formatDuration(session.duration), modifier = Modifier.weight(0.7f))
// Text(text = session.repsPerSet.toString(), modifier = Modifier.weight(0.5f))
// }
// }
//
// if (showDialog) {
// EditHistoryDialog(
// session = session,
// onDismiss = { showDialog = false },
// onSave = { updatedSession ->
// onUpdate(updatedSession)
// showDialog = false
// },
// onDelete = {
// onDelete(session)
// showDialog = false
// }
// )
// }
//}
//
//@Composable
//fun EditHistoryDialog(session: TrainingSession, onDismiss: () -> Unit, onSave: (TrainingSession) -> Unit, onDelete: () -> Unit) {
// // Implement a detailed dialog for editing if needed, similar to the Go app's dialog.
// // For brevity, this example uses a simpler confirmation.
// AlertDialog(
// onDismissRequest = onDismiss,
// title = { Text("Eintrag verwalten") },
// text = { Text("Möchtest du diesen Eintrag löschen? Das Bearbeiten kann in einer zukünftigen Version implementiert werden.") },
// confirmButton = {
// Button(onClick = onDelete) {
// Text("Löschen")
// }
// },
// dismissButton = {
// Button(onClick = onDismiss) {
// Text("Abbrechen")
// }
// }
// )
//}
package de.patani.kettlebelltracker.ui.screens package de.patani.kettlebelltracker.ui.screens
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*

View file

@ -6,7 +6,6 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import de.patani.kettlebelltracker.viewmodels.TrainingViewModel import de.patani.kettlebelltracker.viewmodels.TrainingViewModel
@ -14,8 +13,8 @@ import de.patani.kettlebelltracker.util.formatDuration
@Composable @Composable
fun TrainingScreen(viewModel: TrainingViewModel) { fun TrainingScreen(viewModel: TrainingViewModel) {
val state by viewModel.trainingState.collectAsState() val trainingState by viewModel.trainingState.collectAsState()
val showFinishButton = state.remainingSeconds <= 0 && state.isTrainingRunning val showFinishButton = trainingState.remainingSeconds <= 0 && trainingState.isTrainingRunning
Column( Column(
modifier = Modifier modifier = Modifier
@ -24,44 +23,65 @@ fun TrainingScreen(viewModel: TrainingViewModel) {
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceEvenly verticalArrangement = Arrangement.SpaceEvenly
) { ) {
if (trainingState.isRoundActive) {
RoundTimerCard(
timeRemaining = trainingState.currentRoundTime,
totalTime = trainingState.totalRoundTime,
)
}
if (trainingState.isRoundActive) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Verbleibende Zeit", style = MaterialTheme.typography.titleLarge) Text("Verbleibende Zeit", style = MaterialTheme.typography.titleLarge)
Text( Text(
text = formatDuration(state.remainingSeconds.toLong()), text = formatDuration(trainingState.remainingSeconds.toLong()),
fontSize = 72.sp, fontSize = 72.sp,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold,
color = if (trainingState.remainingSeconds <= 10)
MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurface
) )
} }
}
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Sätze", style = MaterialTheme.typography.titleLarge) Text("Sätze", style = MaterialTheme.typography.titleLarge)
Text( Text(
text = "${state.setsDone} / ${state.goalSets}", text = "${trainingState.setsDone} / ${trainingState.goalSets}",
fontSize = 60.sp, fontSize = 60.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.tertiary color = MaterialTheme.colorScheme.tertiary
) )
Text( Text(
text = "${state.repsPerSet} Wiederholungen", text = "${trainingState.repsPerSet} Wiederholungen",
fontSize = 24.sp, fontSize = 24.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
) )
} }
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
if (trainingState.isRoundActive) {
Button( Button(
onClick = viewModel::completeSet, onClick = viewModel::completeSet,
enabled = state.isTrainingRunning, enabled = trainingState.isTrainingRunning,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(60.dp) .height(60.dp)
) { ) {
Text("Satz abschließen", fontSize = 18.sp) Text("Satz abschließen", fontSize = 18.sp)
} }
}
if (showFinishButton) { if (showFinishButton) {
Button( Button(
onClick = viewModel::finishTraining, onClick = viewModel::finishTraining,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(50.dp) .height(50.dp)
@ -72,3 +92,82 @@ fun TrainingScreen(viewModel: TrainingViewModel) {
} }
} }
} }
@Composable
fun RoundTimerCard(
timeRemaining: Int,
totalTime: Int
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
colors = CardDefaults.cardColors(
containerColor = when {
timeRemaining <= 0 -> MaterialTheme.colorScheme.errorContainer
timeRemaining <= 10 -> MaterialTheme.colorScheme.tertiary.copy(alpha = 0.3f)
else -> MaterialTheme.colorScheme.primaryContainer
}
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = if (timeRemaining <= 0) "Zeit abgelaufen!" else "Rundenzeit",
style = MaterialTheme.typography.titleLarge,
color = when {
timeRemaining <= 0 -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.primary
}
)
Spacer(modifier = Modifier.height(16.dp))
Box(
modifier = Modifier.size(120.dp),
contentAlignment = Alignment.Center
) {
val progress = if (totalTime > 0 && timeRemaining >= 0) {
(totalTime - timeRemaining).toFloat() / totalTime.toFloat()
} else if (timeRemaining < 0) 1f else 0f
CircularProgressIndicator(
progress = progress,
modifier = Modifier.fillMaxSize(),
strokeWidth = 8.dp,
color = when {
timeRemaining <= 0 -> MaterialTheme.colorScheme.error
timeRemaining <= 10 -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.primary
}
)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = if (timeRemaining >= 0) {
formatDuration(timeRemaining.toLong())
} else {
"+${formatDuration((-timeRemaining).toLong())}"
},
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = when {
timeRemaining <= 0 -> MaterialTheme.colorScheme.error
timeRemaining <= 10 -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.primary
}
)
Text(
text = if (timeRemaining <= 0) "überzogen" else "verbleibend",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
}
}
}
}

View file

@ -1,17 +1,9 @@
package de.patani.kettlebelltracker.ui.theme package de.patani.kettlebelltracker.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.material3.darkColorScheme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
private val DarkColorScheme = darkColorScheme( private val DarkColorScheme = darkColorScheme(

View file

@ -6,7 +6,6 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography( val Typography = Typography(
bodyLarge = TextStyle( bodyLarge = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
@ -15,20 +14,4 @@ val Typography = Typography(
lineHeight = 24.sp, lineHeight = 24.sp,
letterSpacing = 0.5.sp letterSpacing = 0.5.sp
) )
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
) )

View file

@ -1,5 +1,6 @@
package de.patani.kettlebelltracker.util package de.patani.kettlebelltracker.util
import android.annotation.SuppressLint
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@ -9,6 +10,7 @@ fun Date.formatDate(): String {
return formatter.format(this) return formatter.format(this)
} }
@SuppressLint("DefaultLocale")
fun formatDuration(totalSeconds: Long): String { fun formatDuration(totalSeconds: Long): String {
if (totalSeconds < 0) return "00:00" if (totalSeconds < 0) return "00:00"
val minutes = totalSeconds / 60 val minutes = totalSeconds / 60

View file

@ -1,5 +1,7 @@
package de.patani.kettlebelltracker.viewmodels package de.patani.kettlebelltracker.viewmodels
import android.media.AudioManager
import android.media.ToneGenerator
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
@ -27,7 +29,11 @@ data class TrainingState(
val currentProgram: String = "clean_1.0", val currentProgram: String = "clean_1.0",
val currentBlockDay: Int = 1, val currentBlockDay: Int = 1,
val currentReps: Int = 5, val currentReps: Int = 5,
val totalTrainingDays: Int = 0 val totalTrainingDays: Int = 0,
// Neue Rundentimer-Eigenschaften
val isRoundActive: Boolean = false,
val currentRoundTime: Int = 0,
val totalRoundTime: Int = 0
) )
class TrainingViewModel( class TrainingViewModel(
@ -41,9 +47,9 @@ class TrainingViewModel(
val trainingState = _trainingState.asStateFlow() val trainingState = _trainingState.asStateFlow()
private var timerJob: Job? = null private var timerJob: Job? = null
private var roundTimerJob: Job? = null
init { init {
// Load initial state based on past trainings
viewModelScope.launch { viewModelScope.launch {
val trainingCount = dao.getTrainingCount().first() val trainingCount = dao.getTrainingCount().first()
val initialState = calculateStateByDayCount(trainingCount) val initialState = calculateStateByDayCount(trainingCount)
@ -59,6 +65,8 @@ class TrainingViewModel(
} }
} }
private var recommendedRestSeconds: Int = 90
fun startTraining() { fun startTraining() {
if (_trainingState.value.isTrainingRunning) return if (_trainingState.value.isTrainingRunning) return
@ -76,7 +84,15 @@ class TrainingViewModel(
progress = 0.0f progress = 0.0f
) )
} }
val recommendation = apiRepository.getRecommendedRest(
uuid = appUUID,
repsPerSet = _trainingState.value.repsPerSet,
currentSets = 0 // ggf. 0 oder deinen Startwert
)
recommendedRestSeconds = recommendation?.recommended_rest ?: 90
Log.d("Training", "using rest time ${recommendedRestSeconds.toString()}")
startTimer() startTimer()
startFirstRound()
} }
} }
@ -93,20 +109,87 @@ class TrainingViewModel(
} }
} }
private fun startFirstRound() {
startRoundTimer(recommendedRestSeconds)
}
fun completeSet() { fun completeSet() {
if (!_trainingState.value.isTrainingRunning) return if (!_trainingState.value.isTrainingRunning) return
_trainingState.update { val currentState = _trainingState.value
val newSetsDone = it.setsDone + 1 val newSetsDone = currentState.setsDone + 1
val newProgress = if (it.goalSets > 0) { val newProgress = if (currentState.goalSets > 0) {
min(newSetsDone.toFloat() / it.goalSets.toFloat(), 1.0f) min(newSetsDone.toFloat() / currentState.goalSets.toFloat(), 1.0f)
} else 0.0f } else 0.0f
it.copy(setsDone = newSetsDone, progress = newProgress)
_trainingState.update {
it.copy(
setsDone = newSetsDone,
progress = newProgress
)
}
stopRoundTimer()
if (newSetsDone < currentState.goalSets) {
startNextRound()
}
}
private fun startNextRound() {
startRoundTimer(recommendedRestSeconds)
}
private fun startRoundTimer(seconds: Int) {
roundTimerJob?.cancel()
_trainingState.update {
it.copy(
isRoundActive = true,
currentRoundTime = seconds,
totalRoundTime = seconds
)
}
roundTimerJob = viewModelScope.launch {
while (_trainingState.value.currentRoundTime > 0 && _trainingState.value.isRoundActive) {
delay(1000)
_trainingState.update {
it.copy(currentRoundTime = it.currentRoundTime - 1)
}
}
if (_trainingState.value.isRoundActive) {
playRoundCompleteSound()
}
}
}
private fun stopRoundTimer() {
roundTimerJob?.cancel()
_trainingState.update {
it.copy(
isRoundActive = false,
currentRoundTime = 0,
totalRoundTime = 0
)
}
}
private fun playRoundCompleteSound() {
viewModelScope.launch {
try {
val toneGenerator = ToneGenerator(AudioManager.STREAM_NOTIFICATION, 100)
toneGenerator.startTone(ToneGenerator.TONE_CDMA_ALERT_CALL_GUARD, 1000)
} catch (e: Exception) {
Log.e("TrainingViewModel", "Could not play sound", e)
}
} }
} }
fun finishTraining() { fun finishTraining() {
timerJob?.cancel() timerJob?.cancel()
roundTimerJob?.cancel()
if (!_trainingState.value.isTrainingRunning) return if (!_trainingState.value.isTrainingRunning) return
viewModelScope.launch { viewModelScope.launch {
@ -165,5 +248,11 @@ class TrainingViewModel(
return ProgramState(program, blockDay, reps) return ProgramState(program, blockDay, reps)
} }
override fun onCleared() {
super.onCleared()
timerJob?.cancel()
roundTimerJob?.cancel()
}
data class ProgramState(val program: String, val blockDay: Int, val reps: Int) data class ProgramState(val program: String, val blockDay: Int, val reps: Int)
} }