Compare commits

...

2 commits
v0.0.6 ... main

10 changed files with 279 additions and 157 deletions

View file

@ -2,6 +2,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
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
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"

View file

@ -24,6 +24,7 @@ import de.patani.kettlebelltracker.viewmodels.*
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.UUID
import androidx.core.content.edit
class MainActivity : ComponentActivity() {
@ -34,7 +35,7 @@ class MainActivity : ComponentActivity() {
private val apiService by lazy {
Retrofit.Builder()
.baseUrl("http://192.168.178.43:8080/") // WICHTIG: Deine lokale IP
.baseUrl("https://kb.patanix.de/")
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(de.patani.kettlebelltracker.data.remote.ApiService::class.java)
@ -47,7 +48,7 @@ class MainActivity : ComponentActivity() {
var uuid = prefs.getString("app_uuid", null)
if (uuid == null) {
uuid = UUID.randomUUID().toString()
prefs.edit().putString("app_uuid", uuid).apply()
prefs.edit { putString("app_uuid", uuid) }
}
uuid
}
@ -72,11 +73,9 @@ class MainActivity : ComponentActivity() {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return when {
modelClass.isAssignableFrom(TrainingViewModel::class.java) ->
appUUID?.let {
TrainingViewModel(db.trainingSessionDao(), settingsDataStore, apiRepository,
it
)
} as T
TrainingViewModel(db.trainingSessionDao(), settingsDataStore, apiRepository,
appUUID
) as T
modelClass.isAssignableFrom(HomeViewModel::class.java) ->
HomeViewModel(db.trainingSessionDao(), trainingViewModel) as T
modelClass.isAssignableFrom(HistoryViewModel::class.java) ->

View file

@ -1,10 +1,38 @@
package de.patani.kettlebelltracker.data.remote
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.POST
import retrofit2.Response
interface ApiService {
@POST("trainings/")
@POST("trainings")
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 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) {
@ -21,7 +25,7 @@ class ApiRepository(private val apiService: ApiService) {
)
val response = apiService.sendTrainingData(payload)
if (response.isSuccessful) {
Log.d("ApiRepository", "Training successfully sent to backend.")
Log.i("ApiRepository", "Training successfully sent to backend.")
} else {
Log.e("ApiRepository", "API Error: Unexpected status code: ${response.code()}")
}
@ -29,4 +33,22 @@ 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) {
Log.i("ApiRepository", "Got Rest Recommendation:")
val body = response.body()?.data
Log.i("ApiRepository", body.toString())
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
import androidx.compose.foundation.layout.*

View file

@ -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)
)
}
}
}
}
}

View file

@ -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(

View file

@ -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
)
*/
)

View file

@ -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

View file

@ -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,10 @@ 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,
val isRoundActive: Boolean = false,
val currentRoundTime: Int = 0,
val totalRoundTime: Int = 0
)
class TrainingViewModel(
@ -41,9 +46,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 +64,8 @@ class TrainingViewModel(
}
}
private var recommendedRestSeconds: Int = 90
fun startTraining() {
if (_trainingState.value.isTrainingRunning) return
@ -76,7 +83,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 +108,85 @@ 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()
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 {
@ -125,6 +205,7 @@ class TrainingViewModel(
)
dao.insert(session)
Log.d("Training", "Sending Trainingsession to backend: $appUUID")
apiRepository.sendTrainingData(session, appUUID)
resetTraining()
}
@ -165,5 +246,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)
}