diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..ac4f03b --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..88e6e44 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/java/de/patani/kettlebelltracker/MainActivity.kt b/app/src/main/java/de/patani/kettlebelltracker/MainActivity.kt new file mode 100644 index 0000000..f25377e --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/MainActivity.kt @@ -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 createViewModelFactory(modelClass: Class): ViewModelProvider.Factory { + return object : ViewModelProvider.Factory { + override fun create(modelClass: Class): 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) -> 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) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/patani/kettlebelltracker/data/datastore/SettingsDataStore.kt b/app/src/main/java/de/patani/kettlebelltracker/data/datastore/SettingsDataStore.kt new file mode 100644 index 0000000..b6222ed --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/data/datastore/SettingsDataStore.kt @@ -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 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 = 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 +) \ No newline at end of file diff --git a/app/src/main/java/de/patani/kettlebelltracker/data/local/AppDatabase.kt b/app/src/main/java/de/patani/kettlebelltracker/data/local/AppDatabase.kt new file mode 100644 index 0000000..087f901 --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/data/local/AppDatabase.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/de/patani/kettlebelltracker/data/local/Converters.kt b/app/src/main/java/de/patani/kettlebelltracker/data/local/Converters.kt new file mode 100644 index 0000000..213a881 --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/data/local/Converters.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/de/patani/kettlebelltracker/data/local/TrainingSession.kt b/app/src/main/java/de/patani/kettlebelltracker/data/local/TrainingSession.kt new file mode 100644 index 0000000..ec91096 --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/data/local/TrainingSession.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/de/patani/kettlebelltracker/data/local/TrainingSessionDao.kt b/app/src/main/java/de/patani/kettlebelltracker/data/local/TrainingSessionDao.kt new file mode 100644 index 0000000..1772939 --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/data/local/TrainingSessionDao.kt @@ -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> + + @Query("SELECT * FROM training_session ORDER BY date DESC LIMIT 20") + fun getHistory(): Flow> + + @Query("SELECT * FROM training_session ORDER BY date DESC LIMIT 1") + fun getLastSession(): Flow + + @Query("SELECT COUNT(*) FROM training_session") + fun getTrainingCount(): Flow + + @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) +} \ No newline at end of file diff --git a/app/src/main/java/de/patani/kettlebelltracker/data/remote/ApiService.kt b/app/src/main/java/de/patani/kettlebelltracker/data/remote/ApiService.kt new file mode 100644 index 0000000..482dd4a --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/data/remote/ApiService.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/de/patani/kettlebelltracker/data/remote/TrainingPayload.kt b/app/src/main/java/de/patani/kettlebelltracker/data/remote/TrainingPayload.kt new file mode 100644 index 0000000..01ad4a1 --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/data/remote/TrainingPayload.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/de/patani/kettlebelltracker/repositories/ApiRepository.kt b/app/src/main/java/de/patani/kettlebelltracker/repositories/ApiRepository.kt new file mode 100644 index 0000000..53f3020 --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/repositories/ApiRepository.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/patani/kettlebelltracker/ui/navigation/Screen.kt b/app/src/main/java/de/patani/kettlebelltracker/ui/navigation/Screen.kt new file mode 100644 index 0000000..b79bd02 --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/ui/navigation/Screen.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/java/de/patani/kettlebelltracker/ui/screens/HistoryScreen.kt b/app/src/main/java/de/patani/kettlebelltracker/ui/screens/HistoryScreen.kt new file mode 100644 index 0000000..106b281 --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/ui/screens/HistoryScreen.kt @@ -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") + } + } + ) +} diff --git a/app/src/main/java/de/patani/kettlebelltracker/ui/screens/HomeScreen.kt b/app/src/main/java/de/patani/kettlebelltracker/ui/screens/HomeScreen.kt new file mode 100644 index 0000000..56f6c3a --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/ui/screens/HomeScreen.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/patani/kettlebelltracker/ui/screens/SettingsScreen.kt b/app/src/main/java/de/patani/kettlebelltracker/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..d85f717 --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/ui/screens/SettingsScreen.kt @@ -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") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/patani/kettlebelltracker/ui/screens/TrainingScreen.kt b/app/src/main/java/de/patani/kettlebelltracker/ui/screens/TrainingScreen.kt new file mode 100644 index 0000000..518c6bb --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/ui/screens/TrainingScreen.kt @@ -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) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/patani/kettlebelltracker/ui/theme/Color.kt b/app/src/main/java/de/patani/kettlebelltracker/ui/theme/Color.kt new file mode 100644 index 0000000..dba3d63 --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/ui/theme/Color.kt @@ -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) \ No newline at end of file diff --git a/app/src/main/java/de/patani/kettlebelltracker/ui/theme/Theme.kt b/app/src/main/java/de/patani/kettlebelltracker/ui/theme/Theme.kt new file mode 100644 index 0000000..f105e9d --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/ui/theme/Theme.kt @@ -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 + ) +} \ No newline at end of file diff --git a/app/src/main/java/de/patani/kettlebelltracker/ui/theme/Type.kt b/app/src/main/java/de/patani/kettlebelltracker/ui/theme/Type.kt new file mode 100644 index 0000000..06a5fb5 --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/ui/theme/Type.kt @@ -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 + ) + */ +) \ No newline at end of file diff --git a/app/src/main/java/de/patani/kettlebelltracker/util/formatDuration.kt b/app/src/main/java/de/patani/kettlebelltracker/util/formatDuration.kt new file mode 100644 index 0000000..77c4782 --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/util/formatDuration.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/java/de/patani/kettlebelltracker/viewmodels/HistoryViewModel.kt b/app/src/main/java/de/patani/kettlebelltracker/viewmodels/HistoryViewModel.kt new file mode 100644 index 0000000..1827055 --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/viewmodels/HistoryViewModel.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/patani/kettlebelltracker/viewmodels/HomeViewModel.kt b/app/src/main/java/de/patani/kettlebelltracker/viewmodels/HomeViewModel.kt new file mode 100644 index 0000000..c662d01 --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/viewmodels/HomeViewModel.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/de/patani/kettlebelltracker/viewmodels/SettingsViewModel.kt b/app/src/main/java/de/patani/kettlebelltracker/viewmodels/SettingsViewModel.kt new file mode 100644 index 0000000..0c61f5c --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/viewmodels/SettingsViewModel.kt @@ -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 + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/de/patani/kettlebelltracker/viewmodels/TrainingViewModel.kt b/app/src/main/java/de/patani/kettlebelltracker/viewmodels/TrainingViewModel.kt new file mode 100644 index 0000000..83c8f1f --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/viewmodels/TrainingViewModel.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..d1aa070 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + kettlebelltracker + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..9a13f97 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +