diff --git a/.gitignore b/.gitignore index 79c113f..aa724b7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,45 +1,15 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.build/ -.buildlog/ -.history -.svn/ -.swiftpm/ -migrate_working_dir/ - -# IntelliJ related *.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -**/ios/Flutter/.last_build_id -.dart_tool/ -.flutter-plugins -.flutter-plugins-dependencies -.pub-cache/ -.pub/ -/build/ - -# Symbolication related -app.*.symbols - -# Obfuscation related -app.*.map.json - -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release +.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/.metadata b/.metadata deleted file mode 100644 index d77a4e0..0000000 --- a/.metadata +++ /dev/null @@ -1,45 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: "c23637390482d4cf9598c3ce3f2be31aa7332daf" - channel: "stable" - -project_type: app - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf - base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf - - platform: android - create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf - base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf - - platform: ios - create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf - base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf - - platform: linux - create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf - base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf - - platform: macos - create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf - base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf - - platform: web - create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf - base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf - - platform: windows - create_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf - base_revision: c23637390482d4cf9598c3ce3f2be31aa7332daf - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/Icon.jpeg b/Icon.jpeg new file mode 100644 index 0000000..eb3f70d Binary files /dev/null and b/Icon.jpeg differ diff --git a/README.md b/README.md deleted file mode 100644 index 0f553a3..0000000 --- a/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# kettlebell_tracker - -A new Flutter project. - -## Getting Started - -This project is a starting point for a Flutter application. - -A few resources to get you started if this is your first Flutter project: - -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) - -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. diff --git a/analysis_options.yaml b/analysis_options.yaml deleted file mode 100644 index 0d29021..0000000 --- a/analysis_options.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. - -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. -include: package:flutter_lints/flutter.yaml - -linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at https://dart.dev/lints. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. - rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore deleted file mode 100644 index be3943c..0000000 --- a/android/.gitignore +++ /dev/null @@ -1,14 +0,0 @@ -gradle-wrapper.jar -/.gradle -/captures/ -/gradlew -/gradlew.bat -/local.properties -GeneratedPluginRegistrant.java -.cxx/ - -# Remember to never publicly share your keystore. -# See https://flutter.dev/to/reference-keystore -key.properties -**/*.keystore -**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts deleted file mode 100644 index 2bd1da9..0000000 --- a/android/app/build.gradle.kts +++ /dev/null @@ -1,45 +0,0 @@ -plugins { - id("com.android.application") - id("kotlin-android") - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. - id("dev.flutter.flutter-gradle-plugin") -} - -android { - namespace = "com.example.kettlebell_tracker" - compileSdk = flutter.compileSdkVersion - // ndkVersion = flutter.ndkVersion - ndkVersion = "27.0.12077973" - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() - } - - defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.example.kettlebell_tracker" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion - targetSdk = flutter.targetSdkVersion - versionCode = flutter.versionCode - versionName = flutter.versionName - } - - buildTypes { - release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") - } - } -} - -flutter { - source = "../.." -} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 399f698..0000000 --- a/android/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index f577157..0000000 --- a/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/android/app/src/main/kotlin/com/example/kettlebell_tracker/MainActivity.kt b/android/app/src/main/kotlin/com/example/kettlebell_tracker/MainActivity.kt deleted file mode 100644 index a5c0748..0000000 --- a/android/app/src/main/kotlin/com/example/kettlebell_tracker/MainActivity.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.example.kettlebell_tracker - -import io.flutter.embedding.android.FlutterActivity - -class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml deleted file mode 100644 index f74085f..0000000 --- a/android/app/src/main/res/drawable-v21/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml deleted file mode 100644 index 304732f..0000000 --- a/android/app/src/main/res/drawable/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index db77bb4..0000000 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 17987b7..0000000 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 09d4391..0000000 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index d5f1c8d..0000000 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 4d6372e..0000000 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml deleted file mode 100644 index 06952be..0000000 --- a/android/app/src/main/res/values-night/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml deleted file mode 100644 index cb1ef88..0000000 --- a/android/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml deleted file mode 100644 index 399f698..0000000 --- a/android/app/src/profile/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/android/build.gradle.kts b/android/build.gradle.kts deleted file mode 100644 index 89176ef..0000000 --- a/android/build.gradle.kts +++ /dev/null @@ -1,21 +0,0 @@ -allprojects { - repositories { - google() - mavenCentral() - } -} - -val newBuildDir: Directory = rootProject.layout.buildDirectory.dir("../../build").get() -rootProject.layout.buildDirectory.value(newBuildDir) - -subprojects { - val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) - project.layout.buildDirectory.value(newSubprojectBuildDir) -} -subprojects { - project.evaluationDependsOn(":app") -} - -tasks.register("clean") { - delete(rootProject.layout.buildDirectory) -} diff --git a/android/gradle.properties b/android/gradle.properties deleted file mode 100644 index f018a61..0000000 --- a/android/gradle.properties +++ /dev/null @@ -1,3 +0,0 @@ -org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError -android.useAndroidX=true -android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index afa1e8e..0000000 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,5 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts deleted file mode 100644 index a439442..0000000 --- a/android/settings.gradle.kts +++ /dev/null @@ -1,25 +0,0 @@ -pluginManagement { - val flutterSdkPath = run { - val properties = java.util.Properties() - file("local.properties").inputStream().use { properties.load(it) } - val flutterSdkPath = properties.getProperty("flutter.sdk") - require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } - flutterSdkPath - } - - includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") - - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -plugins { - id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.7.0" apply false - id("org.jetbrains.kotlin.android") version "1.8.22" apply false -} - -include(":app") 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/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/de/patani/kettlebelltracker/ExampleInstrumentedTest.kt b/app/src/androidTest/java/de/patani/kettlebelltracker/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..4f6a8dd --- /dev/null +++ b/app/src/androidTest/java/de/patani/kettlebelltracker/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package de.patani.kettlebelltracker + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("de.patani.kettlebelltracker", appContext.packageName) + } +} \ 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..f314948 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + 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..a6a7080 --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/MainActivity.kt @@ -0,0 +1,158 @@ +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 +import androidx.core.content.edit + +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("https://kb.patanix.de/") + .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) } + } + 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) -> + TrainingViewModel(db.trainingSessionDao(), settingsDataStore, apiRepository, + appUUID + ) 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..ff141cc --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/data/remote/ApiService.kt @@ -0,0 +1,38 @@ +package de.patani.kettlebelltracker.data.remote + +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.POST + +interface ApiService { + @POST("trainings") + suspend fun sendTrainingData(@Body payload: TrainingPayload): Response + + @POST("trainings/recommend-rest") + suspend fun getRecommendedRest(@Body request: RestRecommendationRequest): Response> + +} + +data class RestRecommendationRequest( + val uuid: String, + val reps_per_set: Int, + val current_sets: Int +) + +data class RestRecommendationResponse( + val recommended_rest_seconds: Int +) + +data class ApiResponse( + val status: String, + val message: String?, + val data: T? +) + +data class RestRecommendationData( + val recommended_rest: Int, + val expected_sets: Int, + val last_training_rest: Int?, + val last_training_sets: Int?, + val reasoning: String? +) diff --git a/app/src/main/java/de/patani/kettlebelltracker/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..d6f4768 --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/repositories/ApiRepository.kt @@ -0,0 +1,54 @@ +package de.patani.kettlebelltracker.repositories + +import android.util.Log +import de.patani.kettlebelltracker.data.remote.TrainingPayload +import de.patani.kettlebelltracker.data.remote.ApiService +import de.patani.kettlebelltracker.data.remote.RestRecommendationData +import de.patani.kettlebelltracker.data.remote.RestRecommendationRequest +import de.patani.kettlebelltracker.data.remote.RestRecommendationResponse + +class ApiRepository(private val apiService: ApiService) { + + suspend fun sendTrainingData(session: de.patani.kettlebelltracker.data.local.TrainingSession, uuid: String) { + try { + val rest = if (session.sets > 0) { + 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.i("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) + } + } + + suspend fun getRecommendedRest(uuid: String, repsPerSet: Int, currentSets: Int): RestRecommendationData? { + return try { + val request = RestRecommendationRequest(uuid, repsPerSet, currentSets) + val response = apiService.getRecommendedRest(request) + if (response.isSuccessful) { + Log.i("ApiRepository", "Got Rest Recommendation:") + val body = response.body()?.data + Log.i("ApiRepository", body.toString()) + response.body()?.data + } else { + null + } + } catch (e: Exception) { + null + } + } + +} \ 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..ece7ac8 --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/ui/screens/HistoryScreen.kt @@ -0,0 +1,229 @@ +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..0a76673 --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/ui/screens/TrainingScreen.kt @@ -0,0 +1,173 @@ +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.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 trainingState by viewModel.trainingState.collectAsState() + val showFinishButton = trainingState.remainingSeconds <= 0 && trainingState.isTrainingRunning + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceEvenly + ) { + + if (trainingState.isRoundActive) { + RoundTimerCard( + timeRemaining = trainingState.currentRoundTime, + totalTime = trainingState.totalRoundTime, + ) + } + + if (trainingState.isRoundActive) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Verbleibende Zeit", style = MaterialTheme.typography.titleLarge) + Text( + text = formatDuration(trainingState.remainingSeconds.toLong()), + fontSize = 72.sp, + fontWeight = FontWeight.Bold, + color = if (trainingState.remainingSeconds <= 10) + MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurface + ) + } + } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Sätze", style = MaterialTheme.typography.titleLarge) + Text( + text = "${trainingState.setsDone} / ${trainingState.goalSets}", + fontSize = 60.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.tertiary + ) + Text( + text = "${trainingState.repsPerSet} Wiederholungen", + fontSize = 24.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + if (trainingState.isRoundActive) { + Button( + onClick = viewModel::completeSet, + enabled = trainingState.isTrainingRunning, + modifier = Modifier + .fillMaxWidth() + .height(60.dp) + ) { + Text("Satz abschließen", fontSize = 18.sp) + } + } + + if (showFinishButton) { + Button( + onClick = viewModel::finishTraining, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ), + modifier = Modifier + .fillMaxWidth() + .height(50.dp) + ) { + Text("Training beenden & Speichern", fontSize = 16.sp) + } + } + } + } +} + +@Composable +fun RoundTimerCard( + timeRemaining: Int, + totalTime: Int +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + colors = CardDefaults.cardColors( + containerColor = when { + timeRemaining <= 0 -> MaterialTheme.colorScheme.errorContainer + timeRemaining <= 10 -> MaterialTheme.colorScheme.tertiary.copy(alpha = 0.3f) + else -> MaterialTheme.colorScheme.primaryContainer + } + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = if (timeRemaining <= 0) "Zeit abgelaufen!" else "Rundenzeit", + style = MaterialTheme.typography.titleLarge, + color = when { + timeRemaining <= 0 -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.primary + } + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Box( + modifier = Modifier.size(120.dp), + contentAlignment = Alignment.Center + ) { + val progress = if (totalTime > 0 && timeRemaining >= 0) { + (totalTime - timeRemaining).toFloat() / totalTime.toFloat() + } else if (timeRemaining < 0) 1f else 0f + + CircularProgressIndicator( + progress = progress, + modifier = Modifier.fillMaxSize(), + strokeWidth = 8.dp, + color = when { + timeRemaining <= 0 -> MaterialTheme.colorScheme.error + timeRemaining <= 10 -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.primary + } + ) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = if (timeRemaining >= 0) { + formatDuration(timeRemaining.toLong()) + } else { + "+${formatDuration((-timeRemaining).toLong())}" + }, + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = when { + timeRemaining <= 0 -> MaterialTheme.colorScheme.error + timeRemaining <= 10 -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.primary + } + ) + Text( + text = if (timeRemaining <= 0) "überzogen" else "verbleibend", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/de/patani/kettlebelltracker/ui/theme/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..ad317fc --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/ui/theme/Theme.kt @@ -0,0 +1,30 @@ +package de.patani.kettlebelltracker.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable + +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..f6ed694 --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/ui/theme/Type.kt @@ -0,0 +1,17 @@ +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 + +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.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..5bd9038 --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/util/formatDuration.kt @@ -0,0 +1,19 @@ +package de.patani.kettlebelltracker.util + +import android.annotation.SuppressLint +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) +} + +@SuppressLint("DefaultLocale") +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..51aaf2e --- /dev/null +++ b/app/src/main/java/de/patani/kettlebelltracker/viewmodels/TrainingViewModel.kt @@ -0,0 +1,256 @@ +package de.patani.kettlebelltracker.viewmodels + +import android.media.AudioManager +import android.media.ToneGenerator +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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, + val isRoundActive: Boolean = false, + val currentRoundTime: Int = 0, + val totalRoundTime: 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 + private var roundTimerJob: Job? = null + + init { + 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 + ) + } + } + } + + private var recommendedRestSeconds: Int = 90 + + 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 + ) + } + val recommendation = apiRepository.getRecommendedRest( + uuid = appUUID, + repsPerSet = _trainingState.value.repsPerSet, + currentSets = 0 // ggf. 0 oder deinen Startwert + ) + recommendedRestSeconds = recommendation?.recommended_rest ?: 90 + Log.d("Training", "using rest time ${recommendedRestSeconds.toString()}") + startTimer() + startFirstRound() + } + } + + 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() + } + } + } + + private fun startFirstRound() { + startRoundTimer(recommendedRestSeconds) + } + + fun completeSet() { + if (!_trainingState.value.isTrainingRunning) return + + val currentState = _trainingState.value + val newSetsDone = currentState.setsDone + 1 + val newProgress = if (currentState.goalSets > 0) { + min(newSetsDone.toFloat() / currentState.goalSets.toFloat(), 1.0f) + } else 0.0f + + _trainingState.update { + it.copy( + setsDone = newSetsDone, + progress = newProgress + ) + } + + stopRoundTimer() + + startNextRound() + } + + private fun startNextRound() { + startRoundTimer(recommendedRestSeconds) + } + + private fun startRoundTimer(seconds: Int) { + roundTimerJob?.cancel() + + _trainingState.update { + it.copy( + isRoundActive = true, + currentRoundTime = seconds, + totalRoundTime = seconds + ) + } + + roundTimerJob = viewModelScope.launch { + while (_trainingState.value.currentRoundTime > 0 && _trainingState.value.isRoundActive) { + delay(1000) + _trainingState.update { + it.copy(currentRoundTime = it.currentRoundTime - 1) + } + } + + if (_trainingState.value.isRoundActive) { + playRoundCompleteSound() + } + } + } + + private fun stopRoundTimer() { + roundTimerJob?.cancel() + _trainingState.update { + it.copy( + isRoundActive = false, + currentRoundTime = 0, + totalRoundTime = 0 + ) + } + } + + private fun playRoundCompleteSound() { + viewModelScope.launch { + try { + val toneGenerator = ToneGenerator(AudioManager.STREAM_NOTIFICATION, 100) + toneGenerator.startTone(ToneGenerator.TONE_CDMA_ALERT_CALL_GUARD, 1000) + } catch (e: Exception) { + Log.e("TrainingViewModel", "Could not play sound", e) + } + } + } + + fun finishTraining() { + timerJob?.cancel() + roundTimerJob?.cancel() + if (!_trainingState.value.isTrainingRunning) return + + viewModelScope.launch { + 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) + Log.d("Training", "Sending Trainingsession to backend: $appUUID") + 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) + } + + override fun onCleared() { + super.onCleared() + timerJob?.cancel() + roundTimerJob?.cancel() + } + + data class ProgramState(val program: String, val blockDay: Int, val reps: Int) +} \ No newline at end of file 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 @@ + + + +