feat: add Training Timer and calculation of optimal Round Times
This commit is contained in:
parent
6d9151c8ec
commit
cfbd2a313b
10 changed files with 271 additions and 149 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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?
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
@ -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.*
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
) {
|
) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Text("Verbleibende Zeit", style = MaterialTheme.typography.titleLarge)
|
if (trainingState.isRoundActive) {
|
||||||
Text(
|
RoundTimerCard(
|
||||||
text = formatDuration(state.remainingSeconds.toLong()),
|
timeRemaining = trainingState.currentRoundTime,
|
||||||
fontSize = 72.sp,
|
totalTime = trainingState.totalRoundTime,
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
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(
|
||||||
Button(
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
onClick = viewModel::completeSet,
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
enabled = state.isTrainingRunning,
|
) {
|
||||||
modifier = Modifier
|
if (trainingState.isRoundActive) {
|
||||||
.fillMaxWidth()
|
Button(
|
||||||
.height(60.dp)
|
onClick = viewModel::completeSet,
|
||||||
) {
|
enabled = trainingState.isTrainingRunning,
|
||||||
Text("Satz abschließen", fontSize = 18.sp)
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(60.dp)
|
||||||
|
) {
|
||||||
|
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)
|
||||||
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
)
|
|
||||||
*/
|
|
||||||
)
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
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 {
|
_trainingState.update {
|
||||||
val newSetsDone = it.setsDone + 1
|
it.copy(
|
||||||
val newProgress = if (it.goalSets > 0) {
|
setsDone = newSetsDone,
|
||||||
min(newSetsDone.toFloat() / it.goalSets.toFloat(), 1.0f)
|
progress = newProgress
|
||||||
} else 0.0f
|
)
|
||||||
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)
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue