change from golang fyne to kotlin and Jetpack Compose

This commit is contained in:
Patryk Hegenberg 2025-07-30 22:29:10 +02:00
parent e3eb2c9aa4
commit e6dc77116b
48 changed files with 1845 additions and 0 deletions

15
.gitignore vendored Normal file
View file

@ -0,0 +1,15 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties

1
app/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

205
app/build.gradle.kts Normal file
View file

@ -0,0 +1,205 @@
//plugins {
// id("com.android.application")
// id("org.jetbrains.kotlin.android")
// id("org.jetbrains.kotlin.plugin.compose")
// id("kotlin-kapt")
//}
//
//android {
// namespace = "de.patani.kettlebelltracker"
// compileSdk = 34
//
// defaultConfig {
// applicationId = "de.patani.kettlebelltracker"
// minSdk = 26
// targetSdk = 34
// versionCode = 1
// versionName = "1.0"
//
// testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// vectorDrawables {
// useSupportLibrary = true
// }
// }
//
// buildTypes {
// release {
// isMinifyEnabled = false
// proguardFiles(
// getDefaultProguardFile("proguard-android-optimize.txt"),
// "proguard-rules.pro"
// )
// }
// }
// compileOptions {
// sourceCompatibility = JavaVersion.VERSION_1_8
// targetCompatibility = JavaVersion.VERSION_1_8
// }
// kotlinOptions {
// jvmTarget = "1.8"
// }
// buildFeatures {
// compose = true
// }
// composeOptions {
// kotlinCompilerExtensionVersion = "1.5.1"
// }
// packaging {
// resources {
// excludes += "/META-INF/{AL2.0,LGPL2.1}"
// excludes += "org/intellij/lang/annotations/**"
// }
// }
//}
//
//configurations {
// all {
// exclude(group = "com.intellij", module = "annotations")
// }
//}
//
//dependencies {
// // Core
// implementation("androidx.core:core-ktx:1.12.0")
// implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
// implementation("androidx.activity:activity-compose:1.8.2")
//
// // Compose
// implementation(platform("androidx.compose:compose-bom:2023.08.00"))
// implementation("androidx.compose.ui:ui")
// implementation("androidx.compose.ui:ui-graphics")
// implementation("androidx.compose.ui:ui-tooling-preview")
// implementation("androidx.compose.material3:material3")
// implementation("androidx.compose.material:material-icons-extended")
//
//
// // Navigation
// implementation("androidx.navigation:navigation-compose:2.7.6")
//
// // ViewModel
// implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2")
//
// // Room (Database)
// implementation("androidx.room:room-runtime:2.6.1")
// implementation("androidx.room:room-ktx:2.6.1")
// implementation(libs.androidx.room.common.jvm)
// implementation(libs.androidx.room.compiler)
// kapt("androidx.room:room-compiler:2.6.1")
//
// // DataStore (Settings)
// implementation("androidx.datastore:datastore-preferences:1.0.0")
//
// // Retrofit (API)
// implementation("com.squareup.retrofit2:retrofit:2.9.0")
// implementation("com.squareup.retrofit2:converter-gson:2.9.0")
//
// // Hilt (Dependency Injection - Optional, but recommended)
// // implementation("com.google.dagger:hilt-android:2.48")
// // kapt("com.google.dagger:hilt-compiler:2.48")
// // implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
//}
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.kapt") // Wichtig: Hier auf 'org.jetbrains.kotlin.kapt' geändert
}
android {
namespace = "de.patani.kettlebelltracker"
compileSdk = 34
defaultConfig {
applicationId = "de.patani.kettlebelltracker"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.1"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
excludes += "org/intellij/lang/annotations/**"
}
}
}
// Den 'configurations' Block entfernen, es sei denn, du benötigst ihn explizit für einen spezifischen Ausschluss.
// Wenn du ihn absichtlich hinzugefügt hast, um ein bekanntes Problem zu lösen, kannst du ihn behalten.
// Ansonsten kommentiere ihn aus oder entferne ihn:
/*
configurations {
all {
exclude(group = "com.intellij", module = "annotations")
}
}
*/
dependencies {
// Core
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
implementation("androidx.activity:activity-compose:1.8.2")
// Compose
implementation(platform("androidx.compose:compose-bom:2023.08.00"))
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
// Navigation
implementation("androidx.navigation:navigation-compose:2.7.6")
// ViewModel
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2")
// Room (Database)
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
// Wichtig: Die folgenden Zeilen für den Room Compiler wurden entfernt/korrigiert!
// KEIN implementation("libs.androidx.room.common.jvm")
// KEIN implementation("libs.androidx.room.compiler")
kapt("androidx.room:room-compiler:2.6.1") // NUR diese Zeile für den Compiler!
// DataStore (Settings)
implementation("androidx.datastore:datastore-preferences:1.0.0")
// Retrofit (API)
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
// Hilt (Dependency Injection - Optional, but recommended)
// implementation("com.google.dagger:hilt-android:2.48")
// kapt("com.google.dagger:hilt-compiler:2.48")
// implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
}

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Kettlebelltracker">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Kettlebelltracker">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,159 @@
package de.patani.kettlebelltracker
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.*
import androidx.room.Room
import de.patani.kettlebelltracker.data.datastore.SettingsDataStore
import de.patani.kettlebelltracker.data.local.AppDatabase
import de.patani.kettlebelltracker.repositories.ApiRepository
import de.patani.kettlebelltracker.ui.navigation.Screen
import de.patani.kettlebelltracker.ui.screens.*
import de.patani.kettlebelltracker.ui.theme.KettlebellTrackerTheme
import de.patani.kettlebelltracker.viewmodels.*
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.UUID
class MainActivity : ComponentActivity() {
private val db by lazy {
Room.databaseBuilder(applicationContext, AppDatabase::class.java, "kettlebell_tracker.db").build()
}
private val settingsDataStore by lazy { SettingsDataStore(applicationContext) }
private val apiService by lazy {
Retrofit.Builder()
.baseUrl("http://192.168.178.43:8080/") // WICHTIG: Deine lokale IP
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(de.patani.kettlebelltracker.data.remote.ApiService::class.java)
}
private val apiRepository by lazy { ApiRepository(apiService) }
private val appUUID by lazy {
val prefs = getSharedPreferences("app_prefs", MODE_PRIVATE)
var uuid = prefs.getString("app_uuid", null)
if (uuid == null) {
uuid = UUID.randomUUID().toString()
prefs.edit().putString("app_uuid", uuid).apply()
}
uuid
}
private val trainingViewModel by lazy {
ViewModelProvider(this, createViewModelFactory(TrainingViewModel::class.java)).get(TrainingViewModel::class.java)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
KettlebellTrackerTheme {
App(createViewModelFactory = { modelClass -> createViewModelFactory(modelClass) })
}
}
}
@Suppress("UNCHECKED_CAST")
private fun <T : ViewModel> createViewModelFactory(modelClass: Class<T>): ViewModelProvider.Factory {
return object : ViewModelProvider.Factory {
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
modelClass.isAssignableFrom(HomeViewModel::class.java) ->
HomeViewModel(db.trainingSessionDao(), trainingViewModel) as T
modelClass.isAssignableFrom(HistoryViewModel::class.java) ->
HistoryViewModel(db.trainingSessionDao()) as T
modelClass.isAssignableFrom(SettingsViewModel::class.java) ->
SettingsViewModel(settingsDataStore) as T
else -> throw IllegalArgumentException("Unknown ViewModel class")
}
}
}
}
}
@Composable
fun App(createViewModelFactory: (Class<out ViewModel>) -> ViewModelProvider.Factory) {
val navController = rememberNavController()
val screens = listOf(
Screen.Home,
Screen.Training,
Screen.History,
Screen.Settings
)
val sharedTrainingViewModel: TrainingViewModel =
androidx.lifecycle.viewmodel.compose.viewModel(factory = createViewModelFactory(TrainingViewModel::class.java))
Scaffold(
bottomBar = {
NavigationBar {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
screens.forEach { screen ->
NavigationBarItem(
icon = { Icon(screen.icon, contentDescription = null) },
label = { Text(screen.title) },
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
onClick = {
navController.navigate(screen.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
}
) { innerPadding ->
NavHost(
navController,
startDestination = Screen.Home.route,
Modifier.padding(innerPadding)
) {
composable(Screen.Home.route) {
val homeViewModel: HomeViewModel = androidx.lifecycle.viewmodel.compose.viewModel(factory = createViewModelFactory(HomeViewModel::class.java))
HomeScreen(
viewModel = homeViewModel,
onStartTrainingClicked = {
sharedTrainingViewModel.startTraining()
navController.navigate(Screen.Training.route)
}
)
}
composable(Screen.Training.route) {
TrainingScreen(viewModel = sharedTrainingViewModel)
}
composable(Screen.History.route) {
val historyViewModel: HistoryViewModel = androidx.lifecycle.viewmodel.compose.viewModel(factory = createViewModelFactory(HistoryViewModel::class.java))
HistoryScreen(viewModel = historyViewModel)
}
composable(Screen.Settings.route) {
val settingsViewModel: SettingsViewModel = androidx.lifecycle.viewmodel.compose.viewModel(factory = createViewModelFactory(SettingsViewModel::class.java))
SettingsScreen(viewModel = settingsViewModel)
}
}
}
}

View file

@ -0,0 +1,54 @@
package de.patani.kettlebelltracker.data.datastore
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
class SettingsDataStore(context: Context) {
private val dataStore = context.dataStore
companion object {
val TRAINING_TIME_MINUTES = intPreferencesKey("trainingTimeMinutes")
val WEIGHT_LEFT = doublePreferencesKey("weightLeft")
val WEIGHT_RIGHT = doublePreferencesKey("weightRight")
val GOAL_SETS = intPreferencesKey("goalSets")
val INITIAL_PROGRAM = stringPreferencesKey("initialProgram")
}
val settingsFlow: Flow<Settings> = dataStore.data.map { preferences ->
Settings(
trainingTimeMinutes = preferences[TRAINING_TIME_MINUTES] ?: 20,
weightLeft = preferences[WEIGHT_LEFT] ?: 16.0,
weightRight = preferences[WEIGHT_RIGHT] ?: 16.0,
goalSets = preferences[GOAL_SETS] ?: 5,
initialProgram = preferences[INITIAL_PROGRAM] ?: "giant_1.0"
)
}
suspend fun saveSettings(settings: Settings) {
dataStore.edit { preferences ->
preferences[TRAINING_TIME_MINUTES] = settings.trainingTimeMinutes
preferences[WEIGHT_LEFT] = settings.weightLeft
preferences[WEIGHT_RIGHT] = settings.weightRight
preferences[GOAL_SETS] = settings.goalSets
preferences[INITIAL_PROGRAM] = settings.initialProgram
}
}
}
data class Settings(
val trainingTimeMinutes: Int,
val weightLeft: Double,
val weightRight: Double,
val goalSets: Int,
val initialProgram: String
)

View file

@ -0,0 +1,11 @@
package de.patani.kettlebelltracker.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@Database(entities = [TrainingSession::class], version = 1, exportSchema = false)
@TypeConverters(Converters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun trainingSessionDao(): TrainingSessionDao
}

View file

@ -0,0 +1,16 @@
package de.patani.kettlebelltracker.data.local
import androidx.room.TypeConverter
import java.util.Date
class Converters {
@TypeConverter
fun fromTimestamp(value: Long?): Date? {
return value?.let { Date(it) }
}
@TypeConverter
fun dateToTimestamp(date: Date?): Long? {
return date?.time
}
}

View file

@ -0,0 +1,19 @@
package de.patani.kettlebelltracker.data.local
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.util.Date
@Entity(tableName = "training_session")
data class TrainingSession(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val date: Date,
val sets: Int,
val weightLeft: Double,
val weightRight: Double,
val repsPerSet: Int,
val duration: Long, // in seconds
val program: String,
val blockDay: Int
)

View file

@ -0,0 +1,36 @@
package de.patani.kettlebelltracker.data.local
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface TrainingSessionDao {
@Query("SELECT * FROM training_session ORDER BY date DESC")
fun getAllSessions(): Flow<List<TrainingSession>>
@Query("SELECT * FROM training_session ORDER BY date DESC LIMIT 20")
fun getHistory(): Flow<List<TrainingSession>>
@Query("SELECT * FROM training_session ORDER BY date DESC LIMIT 1")
fun getLastSession(): Flow<TrainingSession?>
@Query("SELECT COUNT(*) FROM training_session")
fun getTrainingCount(): Flow<Int>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(session: TrainingSession)
@Update
suspend fun update(session: TrainingSession)
@Delete
suspend fun delete(session: TrainingSession)
@Query("DELETE FROM training_session WHERE id = :sessionId")
suspend fun deleteById(sessionId: Long)
}

View file

@ -0,0 +1,10 @@
package de.patani.kettlebelltracker.data.remote
import retrofit2.http.Body
import retrofit2.http.POST
import retrofit2.Response
interface ApiService {
@POST("trainings/")
suspend fun sendTrainingData(@Body payload: TrainingPayload): Response<Unit>
}

View file

@ -0,0 +1,8 @@
package de.patani.kettlebelltracker.data.remote
data class TrainingPayload(
val reps: Int,
val rest: Double,
val sets: Int,
val uuid: String
)

View file

@ -0,0 +1,32 @@
package de.patani.kettlebelltracker.repositories
import android.util.Log
import de.patani.kettlebelltracker.data.remote.TrainingPayload
import de.patani.kettlebelltracker.data.remote.ApiService
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) {
session.duration.toDouble() / session.sets.toDouble()
} else {
0.0
}
val payload = TrainingPayload(
reps = session.repsPerSet,
rest = rest,
sets = session.sets,
uuid = uuid
)
val response = apiService.sendTrainingData(payload)
if (response.isSuccessful) {
Log.d("ApiRepository", "Training successfully sent to backend.")
} else {
Log.e("ApiRepository", "API Error: Unexpected status code: ${response.code()}")
}
} catch (e: Exception) {
Log.e("ApiRepository", "API Error: Failed to send training data", e)
}
}
}

View file

@ -0,0 +1,15 @@
package de.patani.kettlebelltracker.ui.navigation
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Settings
import androidx.compose.ui.graphics.vector.ImageVector
sealed class Screen(val route: String, val title: String, val icon: ImageVector) {
object Home : Screen("home", "Home", Icons.Default.Home)
object Training : Screen("training", "Training", Icons.Default.PlayArrow)
object History : Screen("history", "Historie", Icons.Default.DateRange)
object Settings : Screen("settings", "Einstellungen", Icons.Default.Settings)
}

View file

@ -0,0 +1,322 @@
//
//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.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
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
import androidx.compose.ui.Alignment
@Composable
fun HistoryScreen(viewModel: HistoryViewModel) {
val history by viewModel.history.collectAsState()
if (history.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = 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) {
var editedSets by remember { mutableStateOf(session.sets.toString()) }
var editedRepsPerSet by remember { mutableStateOf(session.repsPerSet.toString()) }
var editedWeightLeft by remember { mutableStateOf(session.weightLeft.toString()) }
var editedWeightRight by remember { mutableStateOf(session.weightRight.toString()) }
var editedDurationMinutes by remember { mutableStateOf((session.duration / 60).toString()) }
var editedDurationSeconds by remember { mutableStateOf((session.duration % 60).toString()) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Eintrag bearbeiten") },
text = {
Column {
Text("Datum: ${session.date.formatDate()}", style = MaterialTheme.typography.bodyMedium)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = editedSets,
onValueChange = { newValue ->
if (newValue.all { it.isDigit() } || newValue.isEmpty()) {
editedSets = newValue
}
},
label = { Text("Sätze") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
singleLine = true
)
OutlinedTextField(
value = editedRepsPerSet,
onValueChange = { newValue ->
if (newValue.all { it.isDigit() } || newValue.isEmpty()) {
editedRepsPerSet = newValue
}
},
label = { Text("Wiederholungen pro Satz") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
singleLine = true
)
OutlinedTextField(
value = editedWeightLeft,
onValueChange = { newValue ->
if (newValue.matches(Regex("^\\d*\\.?\\d*\$"))) {
editedWeightLeft = newValue
}
},
label = { Text("Gewicht Links (kg)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
singleLine = true
)
OutlinedTextField(
value = editedWeightRight,
onValueChange = { newValue ->
if (newValue.matches(Regex("^\\d*\\.?\\d*\$"))) {
editedWeightRight = newValue
}
},
label = { Text("Gewicht Rechts (kg)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
singleLine = true
)
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
OutlinedTextField(
value = editedDurationMinutes,
onValueChange = { newValue ->
if (newValue.all { it.isDigit() } || newValue.isEmpty()) {
editedDurationMinutes = newValue
}
},
label = { Text("Dauer (Min)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f).padding(end = 4.dp),
singleLine = true
)
OutlinedTextField(
value = editedDurationSeconds,
onValueChange = { newValue ->
if (newValue.all { it.isDigit() } || newValue.isEmpty()) {
editedDurationSeconds = newValue
}
},
label = { Text("Dauer (Sek)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f).padding(start = 4.dp),
singleLine = true
)
}
Text("Programm: ${session.program}", style = MaterialTheme.typography.bodySmall)
Text("Block Tag: ${session.blockDay}", style = MaterialTheme.typography.bodySmall)
}
},
confirmButton = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Button(
onClick = onDelete,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
modifier = Modifier.weight(1f).padding(end = 4.dp)
) {
Icon(Icons.Default.Delete, contentDescription = "Löschen")
Spacer(Modifier.width(4.dp))
Text("Löschen")
}
Button(
onClick = {
val newSets = editedSets.toIntOrNull() ?: session.sets
val newRepsPerSet = editedRepsPerSet.toIntOrNull() ?: session.repsPerSet
val newWeightLeft = editedWeightLeft.toDoubleOrNull() ?: session.weightLeft
val newWeightRight = editedWeightRight.toDoubleOrNull() ?: session.weightRight
val newDurationMinutes = editedDurationMinutes.toLongOrNull() ?: 0L
val newDurationSeconds = editedDurationSeconds.toLongOrNull() ?: 0L
val newDuration = newDurationMinutes * 60 + newDurationSeconds
val updatedSession = session.copy(
sets = newSets,
repsPerSet = newRepsPerSet,
weightLeft = newWeightLeft,
weightRight = newWeightRight,
duration = newDuration
)
onSave(updatedSession)
},
modifier = Modifier.weight(1f).padding(start = 4.dp)
) {
Text("Speichern")
}
}
},
dismissButton = {
Button(onClick = onDismiss) {
Text("Abbrechen")
}
}
)
}

View file

@ -0,0 +1,85 @@
package de.patani.kettlebelltracker.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.patani.kettlebelltracker.viewmodels.HomeViewModel
import de.patani.kettlebelltracker.util.formatDate
import de.patani.kettlebelltracker.util.formatDuration
import java.util.Calendar
@Composable
fun HomeScreen(
viewModel: HomeViewModel,
onStartTrainingClicked: () -> Unit
) {
val state by viewModel.homeScreenState.collectAsState()
val isTrainedToday = remember(state.lastTrainingSession) {
val lastDate = state.lastTrainingSession?.date
if (lastDate == null) false
else {
val cal1 = Calendar.getInstance().apply { time = lastDate }
val cal2 = Calendar.getInstance()
cal1.get(Calendar.YEAR) == cal2.get(Calendar.YEAR) &&
cal1.get(Calendar.DAY_OF_YEAR) == cal2.get(Calendar.DAY_OF_YEAR)
}
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceAround
) {
Text(
text = "Kettlebell Workout Tracker",
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center
)
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text("Nächstes Training", style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(8.dp))
Text("${state.nextTrainingProgram} - Tag ${state.nextTrainingBlockDay}")
Spacer(modifier = Modifier.height(8.dp))
Text("Ziel: ${state.nextTrainingReps} Wiederholungen pro Satz")
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onStartTrainingClicked, enabled = !isTrainedToday) {
Text(if (isTrainedToday) "Heute bereits trainiert" else "Training starten")
}
}
}
Card(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Text("Letzte Leistung", style = MaterialTheme.typography.titleLarge, modifier = Modifier.align(Alignment.CenterHorizontally))
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround
) {
StatItem("Datum", state.lastTrainingSession?.date?.formatDate() ?: "")
StatItem("Sätze", state.lastTrainingSession?.sets?.toString() ?: "")
StatItem("Dauer", formatDuration(state.lastTrainingSession?.duration ?: 0))
StatItem("Gewicht", "${state.lastTrainingSession?.weightLeft ?: ""}kg")
}
}
}
}
}
@Composable
fun StatItem(label: String, value: String) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = label, style = MaterialTheme.typography.labelMedium)
Text(text = value, style = MaterialTheme.typography.bodyLarge, fontSize = 18.sp)
}
}

View file

@ -0,0 +1,84 @@
package de.patani.kettlebelltracker.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import de.patani.kettlebelltracker.viewmodels.SettingsViewModel
import kotlinx.coroutines.launch
@Composable
fun SettingsScreen(viewModel: SettingsViewModel) {
val settings by viewModel.settings.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
var time by remember(settings.trainingTimeMinutes) { mutableStateOf(settings.trainingTimeMinutes.toString()) }
var sets by remember(settings.goalSets) { mutableStateOf(settings.goalSets.toString()) }
var weightLeft by remember(settings.weightLeft) { mutableStateOf(settings.weightLeft.toString()) }
var weightRight by remember(settings.weightRight) { mutableStateOf(settings.weightRight.toString()) }
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text("Einstellungen", style = MaterialTheme.typography.headlineSmall)
OutlinedTextField(
value = time,
onValueChange = { time = it },
label = { Text("Trainingszeit (Minuten)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = sets,
onValueChange = { sets = it },
label = { Text("Ziel-Sätze") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = weightLeft,
onValueChange = { weightLeft = it },
label = { Text("Gewicht Links (kg)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = weightRight,
onValueChange = { weightRight = it },
label = { Text("Gewicht Rechts (kg)") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
modifier = Modifier.fillMaxWidth()
)
Button(
onClick = {
val timeInt = time.toIntOrNull() ?: settings.trainingTimeMinutes
val setsInt = sets.toIntOrNull() ?: settings.goalSets
val weightL = weightLeft.toDoubleOrNull() ?: settings.weightLeft
val weightR = weightRight.toDoubleOrNull() ?: settings.weightRight
viewModel.saveSettings(timeInt, setsInt, weightL, weightR)
scope.launch {
snackbarHostState.showSnackbar("Einstellungen gespeichert!")
}
},
modifier = Modifier.fillMaxWidth()
) {
Text("Speichern")
}
}
}
}

View file

@ -0,0 +1,74 @@
package de.patani.kettlebelltracker.ui.screens
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
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
import de.patani.kettlebelltracker.util.formatDuration
@Composable
fun TrainingScreen(viewModel: TrainingViewModel) {
val state by viewModel.trainingState.collectAsState()
val showFinishButton = state.remainingSeconds <= 0 && state.isTrainingRunning
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
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
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Sätze", style = MaterialTheme.typography.titleLarge)
Text(
text = "${state.setsDone} / ${state.goalSets}",
fontSize = 60.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.tertiary
)
Text(
text = "${state.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)
}
if (showFinishButton) {
Button(
onClick = viewModel::finishTraining,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
) {
Text("Training beenden & Speichern", fontSize = 16.sp)
}
}
}
}
}

View file

@ -0,0 +1,11 @@
package de.patani.kettlebelltracker.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

View file

@ -0,0 +1,38 @@
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(
primary = Color(0xFF61AFEF), // Blue
secondary = Color(0xFFC678DD), // Purple
tertiary = Color(0xFF98C379), // Green
background = Color(0xFF282C34),
surface = Color(0xFF2C313A),
onPrimary = Color.Black,
onSecondary = Color.Black,
onTertiary = Color.Black,
onBackground = Color(0xFFABB2BF),
onSurface = Color(0xFFABB2BF),
error = Color(0xFFE06C75), // Red
)
@Composable
fun KettlebellTrackerTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = DarkColorScheme,
typography = Typography,
content = content
)
}

View file

@ -0,0 +1,34 @@
package de.patani.kettlebelltracker.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
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,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
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

@ -0,0 +1,17 @@
package de.patani.kettlebelltracker.util
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
fun Date.formatDate(): String {
val formatter = SimpleDateFormat("dd.MM.yyyy", Locale.getDefault())
return formatter.format(this)
}
fun formatDuration(totalSeconds: Long): String {
if (totalSeconds < 0) return "00:00"
val minutes = totalSeconds / 60
val seconds = totalSeconds % 60
return String.format("%02d:%02d", minutes, seconds)
}

View file

@ -0,0 +1,24 @@
package de.patani.kettlebelltracker.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.patani.kettlebelltracker.data.datastore.SettingsDataStore
import de.patani.kettlebelltracker.data.local.TrainingSessionDao
import de.patani.kettlebelltracker.data.local.TrainingSession
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.util.Date
class HistoryViewModel(private val dao: TrainingSessionDao) : ViewModel() {
val history = dao.getHistory()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun updateSession(session: TrainingSession) = viewModelScope.launch {
dao.update(session)
}
fun deleteSession(session: TrainingSession) = viewModelScope.launch {
dao.delete(session)
}
}

View file

@ -0,0 +1,33 @@
package de.patani.kettlebelltracker.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.patani.kettlebelltracker.data.local.TrainingSessionDao
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
class HomeViewModel(
dao: TrainingSessionDao,
trainingViewModel: TrainingViewModel
) : ViewModel() {
val lastSession = dao.getLastSession()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
val homeScreenState = combine(lastSession, trainingViewModel.trainingState) { last, trainingState ->
HomeScreenState(
lastTrainingSession = last,
nextTrainingProgram = trainingState.currentProgram,
nextTrainingBlockDay = trainingState.currentBlockDay,
nextTrainingReps = trainingState.currentReps
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), HomeScreenState())
}
data class HomeScreenState(
val lastTrainingSession: de.patani.kettlebelltracker.data.local.TrainingSession? = null,
val nextTrainingProgram: String = "",
val nextTrainingBlockDay: Int = 0,
val nextTrainingReps: Int = 0
)

View file

@ -0,0 +1,31 @@
package de.patani.kettlebelltracker.viewmodels
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.patani.kettlebelltracker.data.datastore.Settings
import de.patani.kettlebelltracker.data.datastore.SettingsDataStore
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class SettingsViewModel(private val settingsDataStore: SettingsDataStore) : ViewModel() {
val settings = settingsDataStore.settingsFlow
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), Settings(0,0.0,0.0,0,""))
fun saveSettings(
time: Int,
sets: Int,
weightLeft: Double,
weightRight: Double
) = viewModelScope.launch {
val currentSettings = settings.value
settingsDataStore.saveSettings(
currentSettings.copy(
trainingTimeMinutes = time,
goalSets = sets,
weightLeft = weightLeft,
weightRight = weightRight
)
)
}
}

View file

@ -0,0 +1,169 @@
package de.patani.kettlebelltracker.viewmodels
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.patani.kettlebelltracker.data.local.TrainingSessionDao
import de.patani.kettlebelltracker.data.datastore.SettingsDataStore
import de.patani.kettlebelltracker.repositories.ApiRepository
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.Date
import kotlin.math.min
data class TrainingState(
val isTrainingRunning: Boolean = false,
val remainingSeconds: Int = 0,
val initialDurationSeconds: Int = 0,
val setsDone: Int = 0,
val goalSets: Int = 5,
val repsPerSet: Int = 5,
val progress: Float = 0.0f,
val currentProgram: String = "clean_1.0",
val currentBlockDay: Int = 1,
val currentReps: Int = 5,
val totalTrainingDays: Int = 0
)
class TrainingViewModel(
private val dao: TrainingSessionDao,
private val settingsDataStore: SettingsDataStore,
private val apiRepository: ApiRepository,
private val appUUID: String
) : ViewModel() {
private val _trainingState = MutableStateFlow(TrainingState())
val trainingState = _trainingState.asStateFlow()
private var timerJob: Job? = null
init {
// Load initial state based on past trainings
viewModelScope.launch {
val trainingCount = dao.getTrainingCount().first()
val initialState = calculateStateByDayCount(trainingCount)
_trainingState.update {
it.copy(
totalTrainingDays = trainingCount,
currentProgram = initialState.program,
currentBlockDay = initialState.blockDay,
currentReps = initialState.reps,
repsPerSet = initialState.reps
)
}
}
}
fun startTraining() {
if (_trainingState.value.isTrainingRunning) return
viewModelScope.launch {
val settings = settingsDataStore.settingsFlow.first()
val durationSeconds = settings.trainingTimeMinutes * 60
_trainingState.update {
it.copy(
isTrainingRunning = true,
initialDurationSeconds = durationSeconds,
remainingSeconds = durationSeconds,
goalSets = settings.goalSets,
setsDone = 0,
progress = 0.0f
)
}
startTimer()
}
}
private fun startTimer() {
timerJob?.cancel()
timerJob = viewModelScope.launch {
while (_trainingState.value.remainingSeconds > 0 && _trainingState.value.isTrainingRunning) {
delay(1000)
_trainingState.update { it.copy(remainingSeconds = it.remainingSeconds - 1) }
}
if (_trainingState.value.isTrainingRunning) {
finishTraining()
}
}
}
fun completeSet() {
if (!_trainingState.value.isTrainingRunning) return
_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)
}
}
fun finishTraining() {
timerJob?.cancel()
if (!_trainingState.value.isTrainingRunning) return
viewModelScope.launch {
val state = _trainingState.value
val settings = settingsDataStore.settingsFlow.first()
val session = de.patani.kettlebelltracker.data.local.TrainingSession(
date = Date(),
sets = state.setsDone,
weightLeft = settings.weightLeft,
weightRight = settings.weightRight,
repsPerSet = state.repsPerSet,
duration = (state.initialDurationSeconds - state.remainingSeconds).toLong(),
program = state.currentProgram,
blockDay = state.currentBlockDay
)
dao.insert(session)
apiRepository.sendTrainingData(session, appUUID)
resetTraining()
}
}
private suspend fun resetTraining() {
val trainingCount = dao.getTrainingCount().first()
val nextState = calculateStateByDayCount(trainingCount)
_trainingState.value = TrainingState(
totalTrainingDays = trainingCount,
currentProgram = nextState.program,
currentBlockDay = nextState.blockDay,
currentReps = nextState.reps,
repsPerSet = nextState.reps
)
}
private fun calculateStateByDayCount(totalDays: Int): ProgramState {
if (totalDays <= 0) {
return ProgramState("clean_1.0", 1, 5)
}
val cycleIndex = (totalDays / 12) % 6
val programs = listOf("clean_1.0", "snatch_1.0", "clean_1.1", "snatch_1.1", "clean_1.2", "snatch_1.2")
val program = programs[cycleIndex]
val blockDay = (totalDays % 3) + 1
val repsMap = mapOf(
"clean_1.0" to listOf(5, 6, 4),
"clean_1.1" to listOf(6, 8, 7),
"clean_1.2" to listOf(7, 9, 8),
"snatch_1.0" to listOf(5, 6, 4),
"snatch_1.1" to listOf(6, 8, 7),
"snatch_1.2" to listOf(7, 9, 8)
)
val reps = repsMap[program]?.getOrNull(blockDay - 1) ?: 5
return ProgramState(program, blockDay, reps)
}
data class ProgramState(val program: String, val blockDay: Int, val reps: Int)
}

View file

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View file

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View file

@ -0,0 +1,3 @@
<resources>
<string name="app_name">kettlebelltracker</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Kettlebelltracker" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

6
build.gradle.kts Normal file
View file

@ -0,0 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}

23
gradle.properties Normal file
View file

@ -0,0 +1,23 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

24
settings.gradle.kts Normal file
View file

@ -0,0 +1,24 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "kettlebelltracker"
include(":app")