From cfbd2a313ba5115974b877da8d0936e518526440 Mon Sep 17 00:00:00 2001 From: Patryk Hegenberg Date: Mon, 22 Sep 2025 19:29:48 +0200 Subject: [PATCH] feat: add Training Timer and calculation of optimal Round Times --- app/src/main/AndroidManifest.xml | 3 + .../patani/kettlebelltracker/MainActivity.kt | 2 +- .../data/remote/ApiService.kt | 32 +++- .../repositories/ApiRepository.kt | 19 +++ .../ui/screens/HistoryScreen.kt | 93 ------------ .../ui/screens/TrainingScreen.kt | 141 +++++++++++++++--- .../kettlebelltracker/ui/theme/Theme.kt | 8 - .../patani/kettlebelltracker/ui/theme/Type.kt | 17 --- .../kettlebelltracker/util/formatDuration.kt | 2 + .../viewmodels/TrainingViewModel.kt | 103 ++++++++++++- 10 files changed, 271 insertions(+), 149 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 88e6e44..f314948 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,9 @@ + + + -} \ No newline at end of file + + @POST("trainings/recommend-rest") + suspend fun getRecommendedRest(@Body request: RestRecommendationRequest): Response> + +} + +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( + 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? +) diff --git a/app/src/main/java/de/patani/kettlebelltracker/repositories/ApiRepository.kt b/app/src/main/java/de/patani/kettlebelltracker/repositories/ApiRepository.kt index 53f3020..949a2d8 100644 --- a/app/src/main/java/de/patani/kettlebelltracker/repositories/ApiRepository.kt +++ b/app/src/main/java/de/patani/kettlebelltracker/repositories/ApiRepository.kt @@ -3,8 +3,12 @@ package de.patani.kettlebelltracker.repositories import android.util.Log import de.patani.kettlebelltracker.data.remote.TrainingPayload 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) { + suspend fun sendTrainingData(session: de.patani.kettlebelltracker.data.local.TrainingSession, uuid: String) { try { 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) } } + + 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 + } + } + } \ No newline at end of file diff --git a/app/src/main/java/de/patani/kettlebelltracker/ui/screens/HistoryScreen.kt b/app/src/main/java/de/patani/kettlebelltracker/ui/screens/HistoryScreen.kt index 106b281..ece7ac8 100644 --- a/app/src/main/java/de/patani/kettlebelltracker/ui/screens/HistoryScreen.kt +++ b/app/src/main/java/de/patani/kettlebelltracker/ui/screens/HistoryScreen.kt @@ -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 import androidx.compose.foundation.layout.* diff --git a/app/src/main/java/de/patani/kettlebelltracker/ui/screens/TrainingScreen.kt b/app/src/main/java/de/patani/kettlebelltracker/ui/screens/TrainingScreen.kt index 518c6bb..0a76673 100644 --- a/app/src/main/java/de/patani/kettlebelltracker/ui/screens/TrainingScreen.kt +++ b/app/src/main/java/de/patani/kettlebelltracker/ui/screens/TrainingScreen.kt @@ -6,7 +6,6 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.sp import de.patani.kettlebelltracker.viewmodels.TrainingViewModel @@ -14,8 +13,8 @@ import de.patani.kettlebelltracker.util.formatDuration @Composable fun TrainingScreen(viewModel: TrainingViewModel) { - val state by viewModel.trainingState.collectAsState() - val showFinishButton = state.remainingSeconds <= 0 && state.isTrainingRunning + val trainingState by viewModel.trainingState.collectAsState() + val showFinishButton = trainingState.remainingSeconds <= 0 && trainingState.isTrainingRunning Column( modifier = Modifier @@ -24,44 +23,65 @@ fun TrainingScreen(viewModel: TrainingViewModel) { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceEvenly ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("Verbleibende Zeit", style = MaterialTheme.typography.titleLarge) - Text( - text = formatDuration(state.remainingSeconds.toLong()), - fontSize = 72.sp, - fontWeight = FontWeight.Bold + + if (trainingState.isRoundActive) { + RoundTimerCard( + timeRemaining = trainingState.currentRoundTime, + totalTime = trainingState.totalRoundTime, ) } + if (trainingState.isRoundActive) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Verbleibende Zeit", style = MaterialTheme.typography.titleLarge) + Text( + text = formatDuration(trainingState.remainingSeconds.toLong()), + fontSize = 72.sp, + fontWeight = FontWeight.Bold, + color = if (trainingState.remainingSeconds <= 10) + MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurface + ) + } + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { Text("Sätze", style = MaterialTheme.typography.titleLarge) Text( - text = "${state.setsDone} / ${state.goalSets}", + text = "${trainingState.setsDone} / ${trainingState.goalSets}", fontSize = 60.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.tertiary ) Text( - text = "${state.repsPerSet} Wiederholungen", + text = "${trainingState.repsPerSet} Wiederholungen", fontSize = 24.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) ) } - Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) { - Button( - onClick = viewModel::completeSet, - enabled = state.isTrainingRunning, - modifier = Modifier - .fillMaxWidth() - .height(60.dp) - ) { - Text("Satz abschließen", fontSize = 18.sp) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (trainingState.isRoundActive) { + Button( + onClick = viewModel::completeSet, + enabled = trainingState.isTrainingRunning, + modifier = Modifier + .fillMaxWidth() + .height(60.dp) + ) { + Text("Satz abschließen", fontSize = 18.sp) + } } + if (showFinishButton) { Button( onClick = viewModel::finishTraining, - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ), modifier = Modifier .fillMaxWidth() .height(50.dp) @@ -71,4 +91,83 @@ 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) + ) + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/de/patani/kettlebelltracker/ui/theme/Theme.kt b/app/src/main/java/de/patani/kettlebelltracker/ui/theme/Theme.kt index f105e9d..ad317fc 100644 --- a/app/src/main/java/de/patani/kettlebelltracker/ui/theme/Theme.kt +++ b/app/src/main/java/de/patani/kettlebelltracker/ui/theme/Theme.kt @@ -1,17 +1,9 @@ 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.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme -import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext -import androidx.compose.material3.darkColorScheme import androidx.compose.ui.graphics.Color private val DarkColorScheme = darkColorScheme( diff --git a/app/src/main/java/de/patani/kettlebelltracker/ui/theme/Type.kt b/app/src/main/java/de/patani/kettlebelltracker/ui/theme/Type.kt index 06a5fb5..f6ed694 100644 --- a/app/src/main/java/de/patani/kettlebelltracker/ui/theme/Type.kt +++ b/app/src/main/java/de/patani/kettlebelltracker/ui/theme/Type.kt @@ -6,7 +6,6 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -// Set of Material typography styles to start with val Typography = Typography( bodyLarge = TextStyle( fontFamily = FontFamily.Default, @@ -15,20 +14,4 @@ val Typography = Typography( lineHeight = 24.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 - ) - */ ) \ No newline at end of file diff --git a/app/src/main/java/de/patani/kettlebelltracker/util/formatDuration.kt b/app/src/main/java/de/patani/kettlebelltracker/util/formatDuration.kt index 77c4782..5bd9038 100644 --- a/app/src/main/java/de/patani/kettlebelltracker/util/formatDuration.kt +++ b/app/src/main/java/de/patani/kettlebelltracker/util/formatDuration.kt @@ -1,5 +1,6 @@ package de.patani.kettlebelltracker.util +import android.annotation.SuppressLint import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -9,6 +10,7 @@ fun Date.formatDate(): String { return formatter.format(this) } +@SuppressLint("DefaultLocale") fun formatDuration(totalSeconds: Long): String { if (totalSeconds < 0) return "00:00" val minutes = totalSeconds / 60 diff --git a/app/src/main/java/de/patani/kettlebelltracker/viewmodels/TrainingViewModel.kt b/app/src/main/java/de/patani/kettlebelltracker/viewmodels/TrainingViewModel.kt index 83c8f1f..543e42e 100644 --- a/app/src/main/java/de/patani/kettlebelltracker/viewmodels/TrainingViewModel.kt +++ b/app/src/main/java/de/patani/kettlebelltracker/viewmodels/TrainingViewModel.kt @@ -1,5 +1,7 @@ package de.patani.kettlebelltracker.viewmodels +import android.media.AudioManager +import android.media.ToneGenerator import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -27,7 +29,11 @@ data class TrainingState( val currentProgram: String = "clean_1.0", val currentBlockDay: Int = 1, 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( @@ -41,9 +47,9 @@ class TrainingViewModel( val trainingState = _trainingState.asStateFlow() private var timerJob: Job? = null + private var roundTimerJob: Job? = null init { - // Load initial state based on past trainings viewModelScope.launch { val trainingCount = dao.getTrainingCount().first() val initialState = calculateStateByDayCount(trainingCount) @@ -59,6 +65,8 @@ class TrainingViewModel( } } + private var recommendedRestSeconds: Int = 90 + fun startTraining() { if (_trainingState.value.isTrainingRunning) return @@ -76,7 +84,15 @@ class TrainingViewModel( 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() + startFirstRound() } } @@ -93,20 +109,87 @@ class TrainingViewModel( } } + private fun startFirstRound() { + startRoundTimer(recommendedRestSeconds) + } + fun completeSet() { if (!_trainingState.value.isTrainingRunning) return + val currentState = _trainingState.value + val newSetsDone = currentState.setsDone + 1 + val newProgress = if (currentState.goalSets > 0) { + min(newSetsDone.toFloat() / currentState.goalSets.toFloat(), 1.0f) + } else 0.0f + _trainingState.update { - val newSetsDone = it.setsDone + 1 - val newProgress = if (it.goalSets > 0) { - min(newSetsDone.toFloat() / it.goalSets.toFloat(), 1.0f) - } else 0.0f - it.copy(setsDone = newSetsDone, progress = newProgress) + 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() { timerJob?.cancel() + roundTimerJob?.cancel() if (!_trainingState.value.isTrainingRunning) return viewModelScope.launch { @@ -165,5 +248,11 @@ class TrainingViewModel( 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) } \ No newline at end of file