Compare commits
17 commits
explore-ot
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6241efca58 | |||
| cfbd2a313b | |||
| 6d9151c8ec | |||
|
|
5377dfce27 | ||
| 217623efe2 | |||
| b94c6e1ec1 | |||
| e6dc77116b | |||
| e3eb2c9aa4 | |||
| 519daeec40 | |||
| 4f8e353d48 | |||
| 084ea252a2 | |||
| 059db8f2fb | |||
| 16b2409ae8 | |||
| a7e427ca14 | |||
| c15cdea57d | |||
| 24430d0fae | |||
| 9cae00d2a5 |
15
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
/local.properties
|
||||||
|
/.idea/caches
|
||||||
|
/.idea/libraries
|
||||||
|
/.idea/modules.xml
|
||||||
|
/.idea/workspace.xml
|
||||||
|
/.idea/navEditor.xml
|
||||||
|
/.idea/assetWizardSettings.xml
|
||||||
|
.DS_Store
|
||||||
|
/build
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
local.properties
|
||||||
1
app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/build
|
||||||
205
app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
//plugins {
|
||||||
|
// id("com.android.application")
|
||||||
|
// id("org.jetbrains.kotlin.android")
|
||||||
|
// id("org.jetbrains.kotlin.plugin.compose")
|
||||||
|
// id("kotlin-kapt")
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//android {
|
||||||
|
// namespace = "de.patani.kettlebelltracker"
|
||||||
|
// compileSdk = 34
|
||||||
|
//
|
||||||
|
// defaultConfig {
|
||||||
|
// applicationId = "de.patani.kettlebelltracker"
|
||||||
|
// minSdk = 26
|
||||||
|
// targetSdk = 34
|
||||||
|
// versionCode = 1
|
||||||
|
// versionName = "1.0"
|
||||||
|
//
|
||||||
|
// testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
// vectorDrawables {
|
||||||
|
// useSupportLibrary = true
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// buildTypes {
|
||||||
|
// release {
|
||||||
|
// isMinifyEnabled = false
|
||||||
|
// proguardFiles(
|
||||||
|
// getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
// "proguard-rules.pro"
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// compileOptions {
|
||||||
|
// sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
// targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
// }
|
||||||
|
// kotlinOptions {
|
||||||
|
// jvmTarget = "1.8"
|
||||||
|
// }
|
||||||
|
// buildFeatures {
|
||||||
|
// compose = true
|
||||||
|
// }
|
||||||
|
// composeOptions {
|
||||||
|
// kotlinCompilerExtensionVersion = "1.5.1"
|
||||||
|
// }
|
||||||
|
// packaging {
|
||||||
|
// resources {
|
||||||
|
// excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
// excludes += "org/intellij/lang/annotations/**"
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//configurations {
|
||||||
|
// all {
|
||||||
|
// exclude(group = "com.intellij", module = "annotations")
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//dependencies {
|
||||||
|
// // Core
|
||||||
|
// implementation("androidx.core:core-ktx:1.12.0")
|
||||||
|
// implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
|
||||||
|
// implementation("androidx.activity:activity-compose:1.8.2")
|
||||||
|
//
|
||||||
|
// // Compose
|
||||||
|
// implementation(platform("androidx.compose:compose-bom:2023.08.00"))
|
||||||
|
// implementation("androidx.compose.ui:ui")
|
||||||
|
// implementation("androidx.compose.ui:ui-graphics")
|
||||||
|
// implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
|
// implementation("androidx.compose.material3:material3")
|
||||||
|
// implementation("androidx.compose.material:material-icons-extended")
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// // Navigation
|
||||||
|
// implementation("androidx.navigation:navigation-compose:2.7.6")
|
||||||
|
//
|
||||||
|
// // ViewModel
|
||||||
|
// implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2")
|
||||||
|
//
|
||||||
|
// // Room (Database)
|
||||||
|
// implementation("androidx.room:room-runtime:2.6.1")
|
||||||
|
// implementation("androidx.room:room-ktx:2.6.1")
|
||||||
|
// implementation(libs.androidx.room.common.jvm)
|
||||||
|
// implementation(libs.androidx.room.compiler)
|
||||||
|
// kapt("androidx.room:room-compiler:2.6.1")
|
||||||
|
//
|
||||||
|
// // DataStore (Settings)
|
||||||
|
// implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||||
|
//
|
||||||
|
// // Retrofit (API)
|
||||||
|
// implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||||
|
// implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
||||||
|
//
|
||||||
|
// // Hilt (Dependency Injection - Optional, but recommended)
|
||||||
|
// // implementation("com.google.dagger:hilt-android:2.48")
|
||||||
|
// // kapt("com.google.dagger:hilt-compiler:2.48")
|
||||||
|
// // implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
|
||||||
|
//}
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
|
id("org.jetbrains.kotlin.kapt") // Wichtig: Hier auf 'org.jetbrains.kotlin.kapt' geändert
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "de.patani.kettlebelltracker"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "de.patani.kettlebelltracker"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 34
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0"
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
vectorDrawables {
|
||||||
|
useSupportLibrary = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "1.8"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion = "1.5.1"
|
||||||
|
}
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
excludes += "org/intellij/lang/annotations/**"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Den 'configurations' Block entfernen, es sei denn, du benötigst ihn explizit für einen spezifischen Ausschluss.
|
||||||
|
// Wenn du ihn absichtlich hinzugefügt hast, um ein bekanntes Problem zu lösen, kannst du ihn behalten.
|
||||||
|
// Ansonsten kommentiere ihn aus oder entferne ihn:
|
||||||
|
/*
|
||||||
|
configurations {
|
||||||
|
all {
|
||||||
|
exclude(group = "com.intellij", module = "annotations")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// Core
|
||||||
|
implementation("androidx.core:core-ktx:1.12.0")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
|
||||||
|
implementation("androidx.activity:activity-compose:1.8.2")
|
||||||
|
|
||||||
|
// Compose
|
||||||
|
implementation(platform("androidx.compose:compose-bom:2023.08.00"))
|
||||||
|
implementation("androidx.compose.ui:ui")
|
||||||
|
implementation("androidx.compose.ui:ui-graphics")
|
||||||
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
|
implementation("androidx.compose.material3:material3")
|
||||||
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
implementation("androidx.navigation:navigation-compose:2.7.6")
|
||||||
|
|
||||||
|
// ViewModel
|
||||||
|
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.2")
|
||||||
|
|
||||||
|
// Room (Database)
|
||||||
|
implementation("androidx.room:room-runtime:2.6.1")
|
||||||
|
implementation("androidx.room:room-ktx:2.6.1")
|
||||||
|
// Wichtig: Die folgenden Zeilen für den Room Compiler wurden entfernt/korrigiert!
|
||||||
|
// KEIN implementation("libs.androidx.room.common.jvm")
|
||||||
|
// KEIN implementation("libs.androidx.room.compiler")
|
||||||
|
kapt("androidx.room:room-compiler:2.6.1") // NUR diese Zeile für den Compiler!
|
||||||
|
|
||||||
|
// DataStore (Settings)
|
||||||
|
implementation("androidx.datastore:datastore-preferences:1.0.0")
|
||||||
|
|
||||||
|
// Retrofit (API)
|
||||||
|
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||||
|
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
||||||
|
|
||||||
|
// Hilt (Dependency Injection - Optional, but recommended)
|
||||||
|
// implementation("com.google.dagger:hilt-android:2.48")
|
||||||
|
// kapt("com.google.dagger:hilt-compiler:2.48")
|
||||||
|
// implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
|
||||||
|
}
|
||||||
21
app/proguard-rules.pro
vendored
Normal file
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.Kettlebelltracker">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/Theme.Kettlebelltracker">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
158
app/src/main/java/de/patani/kettlebelltracker/MainActivity.kt
Normal file
|
|
@ -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 <T : ViewModel> createViewModelFactory(modelClass: Class<T>): ViewModelProvider.Factory {
|
||||||
|
return object : ViewModelProvider.Factory {
|
||||||
|
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||||
|
return when {
|
||||||
|
modelClass.isAssignableFrom(TrainingViewModel::class.java) ->
|
||||||
|
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<out ViewModel>) -> ViewModelProvider.Factory) {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
val screens = listOf(
|
||||||
|
Screen.Home,
|
||||||
|
Screen.Training,
|
||||||
|
Screen.History,
|
||||||
|
Screen.Settings
|
||||||
|
)
|
||||||
|
|
||||||
|
val sharedTrainingViewModel: TrainingViewModel =
|
||||||
|
androidx.lifecycle.viewmodel.compose.viewModel(factory = createViewModelFactory(TrainingViewModel::class.java))
|
||||||
|
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
bottomBar = {
|
||||||
|
NavigationBar {
|
||||||
|
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val currentDestination = navBackStackEntry?.destination
|
||||||
|
screens.forEach { screen ->
|
||||||
|
NavigationBarItem(
|
||||||
|
icon = { Icon(screen.icon, contentDescription = null) },
|
||||||
|
label = { Text(screen.title) },
|
||||||
|
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
|
||||||
|
onClick = {
|
||||||
|
navController.navigate(screen.route) {
|
||||||
|
popUpTo(navController.graph.findStartDestination().id) {
|
||||||
|
saveState = true
|
||||||
|
}
|
||||||
|
launchSingleTop = true
|
||||||
|
restoreState = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
NavHost(
|
||||||
|
navController,
|
||||||
|
startDestination = Screen.Home.route,
|
||||||
|
Modifier.padding(innerPadding)
|
||||||
|
) {
|
||||||
|
composable(Screen.Home.route) {
|
||||||
|
val homeViewModel: HomeViewModel = androidx.lifecycle.viewmodel.compose.viewModel(factory = createViewModelFactory(HomeViewModel::class.java))
|
||||||
|
HomeScreen(
|
||||||
|
viewModel = homeViewModel,
|
||||||
|
onStartTrainingClicked = {
|
||||||
|
sharedTrainingViewModel.startTraining()
|
||||||
|
navController.navigate(Screen.Training.route)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(Screen.Training.route) {
|
||||||
|
TrainingScreen(viewModel = sharedTrainingViewModel)
|
||||||
|
}
|
||||||
|
composable(Screen.History.route) {
|
||||||
|
val historyViewModel: HistoryViewModel = androidx.lifecycle.viewmodel.compose.viewModel(factory = createViewModelFactory(HistoryViewModel::class.java))
|
||||||
|
HistoryScreen(viewModel = historyViewModel)
|
||||||
|
}
|
||||||
|
composable(Screen.Settings.route) {
|
||||||
|
val settingsViewModel: SettingsViewModel = androidx.lifecycle.viewmodel.compose.viewModel(factory = createViewModelFactory(SettingsViewModel::class.java))
|
||||||
|
SettingsScreen(viewModel = settingsViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
package de.patani.kettlebelltracker.data.datastore
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.doublePreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||||
|
|
||||||
|
class SettingsDataStore(context: Context) {
|
||||||
|
private val dataStore = context.dataStore
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val TRAINING_TIME_MINUTES = intPreferencesKey("trainingTimeMinutes")
|
||||||
|
val WEIGHT_LEFT = doublePreferencesKey("weightLeft")
|
||||||
|
val WEIGHT_RIGHT = doublePreferencesKey("weightRight")
|
||||||
|
val GOAL_SETS = intPreferencesKey("goalSets")
|
||||||
|
val INITIAL_PROGRAM = stringPreferencesKey("initialProgram")
|
||||||
|
}
|
||||||
|
|
||||||
|
val settingsFlow: Flow<Settings> = dataStore.data.map { preferences ->
|
||||||
|
Settings(
|
||||||
|
trainingTimeMinutes = preferences[TRAINING_TIME_MINUTES] ?: 20,
|
||||||
|
weightLeft = preferences[WEIGHT_LEFT] ?: 16.0,
|
||||||
|
weightRight = preferences[WEIGHT_RIGHT] ?: 16.0,
|
||||||
|
goalSets = preferences[GOAL_SETS] ?: 5,
|
||||||
|
initialProgram = preferences[INITIAL_PROGRAM] ?: "giant_1.0"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun saveSettings(settings: Settings) {
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
preferences[TRAINING_TIME_MINUTES] = settings.trainingTimeMinutes
|
||||||
|
preferences[WEIGHT_LEFT] = settings.weightLeft
|
||||||
|
preferences[WEIGHT_RIGHT] = settings.weightRight
|
||||||
|
preferences[GOAL_SETS] = settings.goalSets
|
||||||
|
preferences[INITIAL_PROGRAM] = settings.initialProgram
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Settings(
|
||||||
|
val trainingTimeMinutes: Int,
|
||||||
|
val weightLeft: Double,
|
||||||
|
val weightRight: Double,
|
||||||
|
val goalSets: Int,
|
||||||
|
val initialProgram: String
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
package de.patani.kettlebelltracker.data.local
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Delete
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Update
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface TrainingSessionDao {
|
||||||
|
@Query("SELECT * FROM training_session ORDER BY date DESC")
|
||||||
|
fun getAllSessions(): Flow<List<TrainingSession>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM training_session ORDER BY date DESC LIMIT 20")
|
||||||
|
fun getHistory(): Flow<List<TrainingSession>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM training_session ORDER BY date DESC LIMIT 1")
|
||||||
|
fun getLastSession(): Flow<TrainingSession?>
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM training_session")
|
||||||
|
fun getTrainingCount(): Flow<Int>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insert(session: TrainingSession)
|
||||||
|
|
||||||
|
@Update
|
||||||
|
suspend fun update(session: TrainingSession)
|
||||||
|
|
||||||
|
@Delete
|
||||||
|
suspend fun delete(session: TrainingSession)
|
||||||
|
|
||||||
|
@Query("DELETE FROM training_session WHERE id = :sessionId")
|
||||||
|
suspend fun deleteById(sessionId: Long)
|
||||||
|
}
|
||||||
|
|
@ -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<Unit>
|
||||||
|
|
||||||
|
@POST("trainings/recommend-rest")
|
||||||
|
suspend fun getRecommendedRest(@Body request: RestRecommendationRequest): Response<ApiResponse<RestRecommendationData>>
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
data class RestRecommendationRequest(
|
||||||
|
val uuid: String,
|
||||||
|
val reps_per_set: Int,
|
||||||
|
val current_sets: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RestRecommendationResponse(
|
||||||
|
val recommended_rest_seconds: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ApiResponse<T>(
|
||||||
|
val status: String,
|
||||||
|
val message: String?,
|
||||||
|
val data: T?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RestRecommendationData(
|
||||||
|
val recommended_rest: Int,
|
||||||
|
val expected_sets: Int,
|
||||||
|
val last_training_rest: Int?,
|
||||||
|
val last_training_sets: Int?,
|
||||||
|
val reasoning: String?
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
170
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#3DDC84"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M9,0L9,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,0L19,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,0L29,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,0L39,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,0L49,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,0L59,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,0L69,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,0L79,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M89,0L89,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M99,0L99,108"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,9L108,9"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,19L108,19"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,29L108,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,39L108,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,49L108,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,59L108,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,69L108,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,79L108,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,89L108,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M0,99L108,99"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,29L89,29"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,39L89,39"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,49L89,49"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,59L89,59"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,69L89,69"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M19,79L89,79"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M29,19L29,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M39,19L39,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M49,19L49,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M59,19L59,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M69,19L69,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:pathData="M79,19L79,89"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:strokeColor="#33FFFFFF" />
|
||||||
|
</vector>
|
||||||
30
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:endX="85.84757"
|
||||||
|
android:endY="92.4963"
|
||||||
|
android:startX="42.9492"
|
||||||
|
android:startY="49.59793"
|
||||||
|
android:type="linear">
|
||||||
|
<item
|
||||||
|
android:color="#44000000"
|
||||||
|
android:offset="0.0" />
|
||||||
|
<item
|
||||||
|
android:color="#00000000"
|
||||||
|
android:offset="1.0" />
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="nonZero"
|
||||||
|
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||||
|
android:strokeWidth="1"
|
||||||
|
android:strokeColor="#00000000" />
|
||||||
|
</vector>
|
||||||
6
app/src/main/res/mipmap-anydpi/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
6
app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 982 B |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
10
app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="purple_200">#FFBB86FC</color>
|
||||||
|
<color name="purple_500">#FF6200EE</color>
|
||||||
|
<color name="purple_700">#FF3700B3</color>
|
||||||
|
<color name="teal_200">#FF03DAC5</color>
|
||||||
|
<color name="teal_700">#FF018786</color>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
</resources>
|
||||||
3
app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">kettlebelltracker</string>
|
||||||
|
</resources>
|
||||||
5
app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<style name="Theme.Kettlebelltracker" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
|
</resources>
|
||||||
13
app/src/main/res/xml/backup_rules.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Sample backup rules file; uncomment and customize as necessary.
|
||||||
|
See https://developer.android.com/guide/topics/data/autobackup
|
||||||
|
for details.
|
||||||
|
Note: This file is ignored for devices older than API 31
|
||||||
|
See https://developer.android.com/about/versions/12/backup-restore
|
||||||
|
-->
|
||||||
|
<full-backup-content>
|
||||||
|
<!--
|
||||||
|
<include domain="sharedpref" path="."/>
|
||||||
|
<exclude domain="sharedpref" path="device.xml"/>
|
||||||
|
-->
|
||||||
|
</full-backup-content>
|
||||||
19
app/src/main/res/xml/data_extraction_rules.xml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Sample data extraction rules file; uncomment and customize as necessary.
|
||||||
|
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||||
|
for details.
|
||||||
|
-->
|
||||||
|
<data-extraction-rules>
|
||||||
|
<cloud-backup>
|
||||||
|
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||||
|
<include .../>
|
||||||
|
<exclude .../>
|
||||||
|
-->
|
||||||
|
</cloud-backup>
|
||||||
|
<!--
|
||||||
|
<device-transfer>
|
||||||
|
<include .../>
|
||||||
|
<exclude .../>
|
||||||
|
</device-transfer>
|
||||||
|
-->
|
||||||
|
</data-extraction-rules>
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
package de.patani.kettlebelltracker
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example local unit test, which will execute on the development machine (host).
|
||||||
|
*
|
||||||
|
* See [testing documentation](http://d.android.com/tools/testing).
|
||||||
|
*/
|
||||||
|
class ExampleUnitTest {
|
||||||
|
@Test
|
||||||
|
fun addition_isCorrect() {
|
||||||
|
assertEquals(4, 2 + 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
6
build.gradle.kts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
}
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
Website = "https://patanix.de"
|
|
||||||
|
|
||||||
[Details]
|
|
||||||
Icon = "Icon.png"
|
|
||||||
Name = "kettlebell_tracker"
|
|
||||||
ID = "de.patanix.kettlebell_tracker"
|
|
||||||
Version = "1.0.0"
|
|
||||||
Build = 5
|
|
||||||
BIN
cmd/Icon.png
|
Before Width: | Height: | Size: 100 KiB |
|
|
@ -1 +0,0 @@
|
||||||
fyne package -os android -release --tags -ldflags="-s -w"
|
|
||||||
54
cmd/main.go
|
|
@ -1,54 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
|
||||||
"fyne.io/fyne/v2/app"
|
|
||||||
"fyne.io/fyne/v2/container"
|
|
||||||
"fyne.io/fyne/v2/theme"
|
|
||||||
|
|
||||||
"git.patanix.de/git/kettlebell-app/internal/data"
|
|
||||||
"git.patanix.de/git/kettlebell-app/internal/services"
|
|
||||||
"git.patanix.de/git/kettlebell-app/internal/ui"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
myApp := app.NewWithID("com.example.kettlebell-tracker")
|
|
||||||
// myApp.Settings().SetTheme(theme.DarkTheme())
|
|
||||||
mainWIndow := myApp.NewWindow("Kettlebell Programm Tracker")
|
|
||||||
|
|
||||||
dbDir := myApp.Storage().RootURI().Path()
|
|
||||||
dbPath := filepath.Join(dbDir, "kb_training.db")
|
|
||||||
log.Println("Datenbankpfad:", dbPath)
|
|
||||||
|
|
||||||
dbService, err := data.NewDatabaseService(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Fehler bei der Initialisierung der Datenbank: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
settingsService := services.NewSettingsService(myApp)
|
|
||||||
apiService := services.NewApiService(myApp.UniqueID())
|
|
||||||
|
|
||||||
trainingService := services.NewTrainingService(dbService, settingsService, apiService)
|
|
||||||
|
|
||||||
homeScreen := ui.MakeHomeScreen()
|
|
||||||
settingsScreen := ui.MakeSettingsScreen(settingsService, mainWIndow)
|
|
||||||
historyScreen := ui.MakeHistoryScreen(dbService, mainWIndow)
|
|
||||||
trainingScreen := ui.MakeTrainingScreen(trainingService, settingsService, mainWIndow)
|
|
||||||
|
|
||||||
tabs := container.NewAppTabs(
|
|
||||||
container.NewTabItemWithIcon("Home", theme.HomeIcon(), homeScreen),
|
|
||||||
container.NewTabItemWithIcon("Training", theme.MediaPlayIcon(), trainingScreen),
|
|
||||||
container.NewTabItemWithIcon("Historie", theme.HistoryIcon(), historyScreen),
|
|
||||||
container.NewTabItemWithIcon("Einstellungen", theme.SettingsIcon(), settingsScreen),
|
|
||||||
)
|
|
||||||
|
|
||||||
tabs.SetTabLocation(container.TabLocationBottom)
|
|
||||||
|
|
||||||
mainWIndow.Resize(fyne.NewSize(400, 600))
|
|
||||||
mainWIndow.SetContent(tabs)
|
|
||||||
mainWIndow.SetMaster()
|
|
||||||
mainWIndow.ShowAndRun()
|
|
||||||
}
|
|
||||||
52
go.mod
|
|
@ -1,52 +0,0 @@
|
||||||
module git.patanix.de/git/kettlebell-app
|
|
||||||
|
|
||||||
go 1.24.4
|
|
||||||
|
|
||||||
require (
|
|
||||||
fyne.io/fyne/v2 v2.6.1
|
|
||||||
modernc.org/sqlite v1.38.0
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
fyne.io/systray v1.11.0 // indirect
|
|
||||||
github.com/BurntSushi/toml v1.4.0 // indirect
|
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
|
||||||
github.com/fredbi/uri v1.1.0 // indirect
|
|
||||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
|
||||||
github.com/fyne-io/gl-js v0.1.0 // indirect
|
|
||||||
github.com/fyne-io/glfw-js v0.2.0 // indirect
|
|
||||||
github.com/fyne-io/image v0.1.1 // indirect
|
|
||||||
github.com/fyne-io/oksvg v0.1.0 // indirect
|
|
||||||
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
|
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
|
|
||||||
github.com/go-text/render v0.2.0 // indirect
|
|
||||||
github.com/go-text/typesetting v0.2.1 // indirect
|
|
||||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
|
||||||
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
|
|
||||||
github.com/hack-pad/safejs v0.1.0 // indirect
|
|
||||||
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 // indirect
|
|
||||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
|
|
||||||
github.com/kr/text v0.2.0 // indirect
|
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
|
||||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
|
||||||
github.com/rymdport/portal v0.4.1 // indirect
|
|
||||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
|
|
||||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
|
||||||
github.com/stretchr/testify v1.10.0 // indirect
|
|
||||||
github.com/yuin/goldmark v1.7.8 // indirect
|
|
||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
|
||||||
golang.org/x/image v0.24.0 // indirect
|
|
||||||
golang.org/x/net v0.35.0 // indirect
|
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
|
||||||
golang.org/x/text v0.22.0 // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
|
||||||
modernc.org/libc v1.65.10 // indirect
|
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
|
||||||
modernc.org/memory v1.11.0 // indirect
|
|
||||||
)
|
|
||||||
123
go.sum
|
|
@ -1,123 +0,0 @@
|
||||||
fyne.io/fyne/v2 v2.6.1 h1:kjPJD4/rBS9m2nHJp+npPSuaK79yj6ObMTuzR6VQ1Is=
|
|
||||||
fyne.io/fyne/v2 v2.6.1/go.mod h1:YZt7SksjvrSNJCwbWFV32WON3mE1Sr7L41D29qMZ/lU=
|
|
||||||
fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg=
|
|
||||||
fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
|
||||||
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
|
|
||||||
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
|
||||||
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
|
|
||||||
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
|
|
||||||
github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8=
|
|
||||||
github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4=
|
|
||||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
|
||||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
|
||||||
github.com/fyne-io/gl-js v0.1.0 h1:8luJzNs0ntEAJo+8x8kfUOXujUlP8gB3QMOxO2mUdpM=
|
|
||||||
github.com/fyne-io/gl-js v0.1.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
|
|
||||||
github.com/fyne-io/glfw-js v0.2.0 h1:8GUZtN2aCoTPNqgRDxK5+kn9OURINhBEBc7M4O1KrmM=
|
|
||||||
github.com/fyne-io/glfw-js v0.2.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
|
|
||||||
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
|
|
||||||
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
|
|
||||||
github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw=
|
|
||||||
github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
|
|
||||||
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
|
|
||||||
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
|
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
|
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
|
||||||
github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
|
|
||||||
github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
|
|
||||||
github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8=
|
|
||||||
github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M=
|
|
||||||
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
|
|
||||||
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
|
|
||||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
|
||||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
|
|
||||||
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
|
|
||||||
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
|
|
||||||
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
|
|
||||||
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08 h1:wMeVzrPO3mfHIWLZtDcSaGAe2I4PW9B/P5nMkRSwCAc=
|
|
||||||
github.com/jeandeaual/go-locale v0.0.0-20241217141322-fcc2cadd6f08/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
|
|
||||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
|
|
||||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
|
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
|
||||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
|
||||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
|
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
|
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
|
||||||
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
|
|
||||||
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
|
||||||
github.com/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA=
|
|
||||||
github.com/rymdport/portal v0.4.1/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
|
|
||||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
|
|
||||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
|
|
||||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
|
|
||||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
|
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
|
||||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
|
||||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
|
||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
|
||||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
|
||||||
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
|
||||||
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
|
||||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
|
||||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
|
||||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
|
||||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
|
||||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
|
||||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
|
||||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
|
||||||
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
|
||||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
|
|
||||||
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
|
||||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
|
||||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
|
||||||
modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA=
|
|
||||||
modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
|
||||||
modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc=
|
|
||||||
modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po=
|
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
|
||||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
|
||||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
|
||||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
|
||||||
modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI=
|
|
||||||
modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE=
|
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
|
||||||
23
gradle.properties
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Project-wide Gradle settings.
|
||||||
|
# IDE (e.g. Android Studio) users:
|
||||||
|
# Gradle settings configured through the IDE *will override*
|
||||||
|
# any settings specified in this file.
|
||||||
|
# For more details on how to configure your build environment visit
|
||||||
|
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
|
# Specifies the JVM arguments used for the daemon process.
|
||||||
|
# The setting is particularly useful for tweaking memory settings.
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
# When configured, Gradle will run in incubating parallel mode.
|
||||||
|
# This option should only be used with decoupled projects. For more details, visit
|
||||||
|
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
||||||
|
# org.gradle.parallel=true
|
||||||
|
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||||
|
# Android operating system, and which are packaged with your app's APK
|
||||||
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
|
android.useAndroidX=true
|
||||||
|
# Kotlin code style for this project: "official" or "obsolete":
|
||||||
|
kotlin.code.style=official
|
||||||
|
# Enables namespacing of each library's R class so that its R class includes only the
|
||||||
|
# resources declared in the library itself and none from the library's dependencies,
|
||||||
|
# thereby reducing the size of the R class for that library
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
36
gradle/libs.versions.toml
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
[versions]
|
||||||
|
agp = "8.11.1"
|
||||||
|
kotlin = "2.0.21"
|
||||||
|
coreKtx = "1.10.1"
|
||||||
|
junit = "4.13.2"
|
||||||
|
junitVersion = "1.1.5"
|
||||||
|
espressoCore = "3.5.1"
|
||||||
|
lifecycleRuntimeKtx = "2.6.1"
|
||||||
|
activityCompose = "1.8.0"
|
||||||
|
composeBom = "2024.09.00"
|
||||||
|
roomCommonJvm = "2.7.2"
|
||||||
|
roomCompiler = "2.7.2"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||||
|
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
|
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
androidx-room-common-jvm = { group = "androidx.room", name = "room-common-jvm", version.ref = "roomCommonJvm" }
|
||||||
|
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "roomCompiler" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
package data
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
_ "modernc.org/sqlite" // Importiert den SQLite-Treiber
|
|
||||||
)
|
|
||||||
|
|
||||||
type DatabaseService struct {
|
|
||||||
DB *sql.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewDatabaseService(dbPath string) (*DatabaseService, error) {
|
|
||||||
db, err := sql.Open("sqlite", dbPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = db.Ping(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
createTableSQL := `
|
|
||||||
CREATE TABLE IF NOT EXISTS training (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
date TEXT NOT NULL,
|
|
||||||
sets INTEGER,
|
|
||||||
weightLeft REAL,
|
|
||||||
weightRight REAL,
|
|
||||||
repsPerSet INTEGER,
|
|
||||||
duration INTEGER,
|
|
||||||
program TEXT,
|
|
||||||
blockDay INTEGER
|
|
||||||
);`
|
|
||||||
|
|
||||||
_, err = db.Exec(createTableSQL)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Fehler beim Erstellen der Tabelle: %v", err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hier könnten wir auch komplexere Migrationen wie dein _onUpgrade handle,
|
|
||||||
// aber für den Anfang reicht das Erstellen der Tabelle.
|
|
||||||
|
|
||||||
log.Println("Datenbank erfolgreich initialisiert.")
|
|
||||||
return &DatabaseService{DB: db}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *DatabaseService) SaveTraining(session *TrainingSession) error {
|
|
||||||
dateStr := session.Date.Format(time.RFC3339)
|
|
||||||
|
|
||||||
query := `
|
|
||||||
INSERT INTO training (id, date, sets, weightLeft, weightRight, repsPerSet, duration, program, blockDay)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
|
||||||
date = excluded.date,
|
|
||||||
sets = excluded.sets,
|
|
||||||
weightLeft = excluded.weightLeft,
|
|
||||||
weightRight = excluded.weightRight,
|
|
||||||
repsPerSet = excluded.repsPerSet,
|
|
||||||
duration = excluded.duration,
|
|
||||||
program = excluded.program,
|
|
||||||
blockDay = excluded.blockDay;
|
|
||||||
`
|
|
||||||
var id any
|
|
||||||
if session.ID != 0 {
|
|
||||||
id = session.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := s.DB.Exec(query, id, dateStr, session.Sets, session.WeightLeft, session.WeightRight, session.RepsPerSet, session.Duration, session.Program, session.BlockDay)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *DatabaseService) GetTrainingCount() (int, error) {
|
|
||||||
var count int
|
|
||||||
query := "SELECT COUNT(*) FROM training;"
|
|
||||||
err := s.DB.QueryRow(query).Scan(&count)
|
|
||||||
if err != nil {
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return count, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *DatabaseService) GetHistory() ([]TrainingSession, error) {
|
|
||||||
query := `SELECT id, date, sets, weightLeft, weightRight, repsPerSet, duration, program, blockDay FROM training ORDER BY date DESC LIMIT 20;`
|
|
||||||
|
|
||||||
rows, err := s.DB.Query(query)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var sessions []TrainingSession
|
|
||||||
for rows.Next() {
|
|
||||||
var s TrainingSession
|
|
||||||
var dateStr string
|
|
||||||
|
|
||||||
err := rows.Scan(&s.ID, &dateStr, &s.Sets, &s.WeightLeft, &s.WeightRight, &s.RepsPerSet, &s.Duration, &s.Program, &s.BlockDay)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
s.Date, err = time.Parse(time.RFC3339, dateStr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
sessions = append(sessions, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
return sessions, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
package data
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// TrainingSession repräsentiert eine einzelne Trainingseinheit.
|
|
||||||
// Die `db`-Tags werden verwendet, um die Struct-Felder den Datenbankspalten zuzuordnen.
|
|
||||||
type TrainingSession struct {
|
|
||||||
ID int64 `db:"id"`
|
|
||||||
Date time.Time `db:"date"`
|
|
||||||
Sets int64 `db:"sets"`
|
|
||||||
WeightLeft float64 `db:"weightLeft"`
|
|
||||||
WeightRight float64 `db:"weightRight"`
|
|
||||||
RepsPerSet int64 `db:"repsPerSet"`
|
|
||||||
Duration int64 `db:"duration"` // in Sekunden
|
|
||||||
Program string `db:"program"`
|
|
||||||
BlockDay int64 `db:"blockDay"`
|
|
||||||
}
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.patanix.de/git/kettlebell-app/internal/data"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TrainingPayload ist die JSON-Struktur, die an das Backend gesendet wird.
|
|
||||||
// Die `json:"..."`-Tags stellen sicher, dass die Feldnamen im JSON korrekt sind.
|
|
||||||
type TrainingPayload struct {
|
|
||||||
Reps int `json:"reps"`
|
|
||||||
Rest float64 `json:"rest"`
|
|
||||||
Sets int `json:"sets"`
|
|
||||||
UUID string `json:"uuid"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApiService kümmert sich um die Kommunikation mit dem Backend.
|
|
||||||
type ApiService struct {
|
|
||||||
client *http.Client
|
|
||||||
endpoint string
|
|
||||||
uuid string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewApiService erstellt einen neuen Service für die API-Kommunikation.
|
|
||||||
func NewApiService(appUUID string) *ApiService {
|
|
||||||
return &ApiService{
|
|
||||||
// Erstellt einen HTTP-Client mit einem 5-Sekunden-Timeout, genau wie in deiner Flutter-App.
|
|
||||||
client: &http.Client{
|
|
||||||
Timeout: 5 * time.Second,
|
|
||||||
},
|
|
||||||
endpoint: "http://192.168.178.43:8080/trainings/",
|
|
||||||
uuid: appUUID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendTrainingData sendet eine abgeschlossene Trainingseinheit an das Backend.
|
|
||||||
func (s *ApiService) SendTrainingData(session *data.TrainingSession) {
|
|
||||||
// Berechnung für 'rest' durchführen.
|
|
||||||
var rest float64
|
|
||||||
if session.Sets > 0 {
|
|
||||||
rest = float64(session.Duration) / float64(session.Sets)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Die zu sendenden Daten vorbereiten.
|
|
||||||
payload := TrainingPayload{
|
|
||||||
Reps: int(session.RepsPerSet),
|
|
||||||
Rest: rest,
|
|
||||||
Sets: int(session.Sets),
|
|
||||||
UUID: s.uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Daten in JSON umwandeln.
|
|
||||||
jsonData, err := json.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("API Fehler: Konnte Payload nicht in JSON umwandeln: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Den HTTP-Request erstellen.
|
|
||||||
req, err := http.NewRequest("POST", s.endpoint, bytes.NewBuffer(jsonData))
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("API Fehler: Konnte Request nicht erstellen: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
// Request senden.
|
|
||||||
log.Printf("Sende Training an Backend: %s", string(jsonData))
|
|
||||||
resp, err := s.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("API Fehler: Fehler beim Senden des Trainings: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
// Antwort des Servers prüfen.
|
|
||||||
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {
|
|
||||||
log.Println("Training erfolgreich an Backend gesendet.")
|
|
||||||
} else {
|
|
||||||
log.Printf("API Fehler: Unerwarteter Statuscode: %s", resp.Status)
|
|
||||||
// Optional: Den Body der Antwort lesen, um mehr Details zu erhalten.
|
|
||||||
// body, _ := io.ReadAll(resp.Body)
|
|
||||||
// log.Printf("Antwort-Body: %s", string(body))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fyne.io/fyne/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Settings struct {
|
|
||||||
TrainingTimeMinutes int
|
|
||||||
WeightLeft float64
|
|
||||||
WeightRight float64
|
|
||||||
GoalSets int
|
|
||||||
InitialProgram string
|
|
||||||
}
|
|
||||||
|
|
||||||
type SettingsService struct {
|
|
||||||
prefs fyne.Preferences
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewSettingsService(app fyne.App) *SettingsService {
|
|
||||||
return &SettingsService{
|
|
||||||
prefs: app.Preferences(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SettingsService) LoadSettings() *Settings {
|
|
||||||
return &Settings{
|
|
||||||
TrainingTimeMinutes: s.prefs.IntWithFallback("trainingTimeMinutes", 20),
|
|
||||||
WeightLeft: s.prefs.FloatWithFallback("weightLeft", 16.0),
|
|
||||||
WeightRight: s.prefs.FloatWithFallback("weightRight", 16.0),
|
|
||||||
GoalSets: s.prefs.IntWithFallback("goalSets", 5),
|
|
||||||
InitialProgram: s.prefs.StringWithFallback("initialProgram", "giant_1.0"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SettingsService) SaveSettings(settings *Settings) {
|
|
||||||
s.prefs.SetInt("trainingTimeMinutes", settings.TrainingTimeMinutes)
|
|
||||||
s.prefs.SetFloat("weightLeft", settings.WeightLeft)
|
|
||||||
s.prefs.SetFloat("weightRight", settings.WeightRight)
|
|
||||||
s.prefs.SetInt("goalSets", settings.GoalSets)
|
|
||||||
s.prefs.SetString("initialProgram", settings.InitialProgram)
|
|
||||||
}
|
|
||||||
|
|
@ -1,356 +0,0 @@
|
||||||
// package services
|
|
||||||
//
|
|
||||||
// import (
|
|
||||||
//
|
|
||||||
// "log"
|
|
||||||
// "time"
|
|
||||||
//
|
|
||||||
// "git.patanix.de/git/kettlebell-app/internal/data"
|
|
||||||
//
|
|
||||||
// )
|
|
||||||
//
|
|
||||||
// // TrainingState hält den aktuellen Zustand einer laufenden Trainingseinheit.
|
|
||||||
//
|
|
||||||
// type TrainingState struct {
|
|
||||||
// IsTrainingRunning bool
|
|
||||||
// RemainingSeconds int
|
|
||||||
// InitialDurationSeconds int
|
|
||||||
// SetsDone int
|
|
||||||
// GoalSets int
|
|
||||||
// RepsPerSet int
|
|
||||||
// SetTimes []time.Time
|
|
||||||
// Progress float64
|
|
||||||
// SecondsSinceLastSet int
|
|
||||||
// LastSetTimestamp *time.Time
|
|
||||||
// CurrentProgram string
|
|
||||||
// CurrentBlockDay int
|
|
||||||
// CurrentReps int
|
|
||||||
// TotalTrainingDays int
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// func NewTrainingState() *TrainingState {
|
|
||||||
// return &TrainingState{
|
|
||||||
// IsTrainingRunning: false,
|
|
||||||
// RemainingSeconds: 0,
|
|
||||||
// InitialDurationSeconds: 0,
|
|
||||||
// SetsDone: 0,
|
|
||||||
// GoalSets: 5,
|
|
||||||
// RepsPerSet: 5,
|
|
||||||
// Progress: 0.0,
|
|
||||||
// SecondsSinceLastSet: 0,
|
|
||||||
// LastSetTimestamp: nil,
|
|
||||||
// CurrentProgram: "giant_1.0",
|
|
||||||
// CurrentBlockDay: 1,
|
|
||||||
// CurrentReps: 5,
|
|
||||||
// TotalTrainingDays: 0,
|
|
||||||
// SetTimes: []time.Time{},
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// type TrainingService struct {
|
|
||||||
// State *TrainingState
|
|
||||||
// dbService *data.DatabaseService
|
|
||||||
// settingsService *SettingsService
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// func NewTrainingService(db *data.DatabaseService, settings *SettingsService) *TrainingService {
|
|
||||||
// initialState := NewTrainingState()
|
|
||||||
// trainingCount, err := db.GetTrainingCount()
|
|
||||||
// if err != nil {
|
|
||||||
// log.Printf("Fehler beim Abrufen der Trainingsanzahl, setze auf 0: %v", err)
|
|
||||||
// initialState.TotalTrainingDays = 0
|
|
||||||
// } else {
|
|
||||||
// initialState.TotalTrainingDays = trainingCount
|
|
||||||
// }
|
|
||||||
// return &TrainingService{
|
|
||||||
// State: initialState,
|
|
||||||
// dbService: db,
|
|
||||||
// settingsService: settings,
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// func (s *TrainingService) updateProgram() {
|
|
||||||
// st := s.State
|
|
||||||
// newTotalDays := st.TotalTrainingDays + 1
|
|
||||||
// newProgram := st.CurrentProgram
|
|
||||||
// newDay := (st.CurrentBlockDay % 3) + 1
|
|
||||||
// newReps := st.CurrentReps
|
|
||||||
//
|
|
||||||
// if newTotalDays > 0 && newTotalDays%12 == 0 {
|
|
||||||
// switch st.CurrentProgram {
|
|
||||||
// case "giant_1.0":
|
|
||||||
// newProgram = "ksk_1.0"
|
|
||||||
// case "giant_1.1":
|
|
||||||
// newProgram = "ksk_1.1"
|
|
||||||
// case "giant_1.2":
|
|
||||||
// newProgram = "ksk_1.2"
|
|
||||||
// case "ksk_1.0":
|
|
||||||
// newProgram = "giant_1.1"
|
|
||||||
// case "ksk_1.1":
|
|
||||||
// newProgram = "giant_1.2"
|
|
||||||
// case "ksk_1.2":
|
|
||||||
// newProgram = "giant_1.0"
|
|
||||||
// default:
|
|
||||||
// newProgram = "giant_1.0"
|
|
||||||
// }
|
|
||||||
// newDay = 1
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// repsMap := map[string][]int{
|
|
||||||
// "giant_1.0": {5, 6, 4},
|
|
||||||
// "giant_1.1": {6, 8, 7},
|
|
||||||
// "giant_1.2": {7, 9, 8},
|
|
||||||
// "ksk_1.0": {5, 6, 4},
|
|
||||||
// "ksk_1.1": {6, 8, 7},
|
|
||||||
// "ksk_1.2": {7, 9, 8},
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// if reps, ok := repsMap[newProgram]; ok && len(reps) >= newDay {
|
|
||||||
// newReps = reps[newDay-1]
|
|
||||||
// } else {
|
|
||||||
// newReps = 5
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// st.CurrentProgram = newProgram
|
|
||||||
// st.CurrentBlockDay = newDay
|
|
||||||
// st.CurrentReps = newReps
|
|
||||||
// st.TotalTrainingDays = newTotalDays
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// func (s *TrainingService) StartTraining(minutes, goal int) {
|
|
||||||
// s.updateProgram()
|
|
||||||
// duration := minutes * 60
|
|
||||||
// s.State = &TrainingState{
|
|
||||||
// IsTrainingRunning: true,
|
|
||||||
// InitialDurationSeconds: duration,
|
|
||||||
// RemainingSeconds: duration,
|
|
||||||
// GoalSets: goal,
|
|
||||||
// RepsPerSet: s.State.CurrentReps,
|
|
||||||
// CurrentProgram: s.State.CurrentProgram,
|
|
||||||
// CurrentBlockDay: s.State.CurrentBlockDay,
|
|
||||||
// TotalTrainingDays: s.State.TotalTrainingDays,
|
|
||||||
// SetTimes: []time.Time{},
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// func (s *TrainingService) Tick() {
|
|
||||||
// if s.State.RemainingSeconds > 0 {
|
|
||||||
// s.State.RemainingSeconds--
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// func (s *TrainingService) TickLastSetTimer() {
|
|
||||||
// if s.State.IsTrainingRunning && s.State.LastSetTimestamp != nil {
|
|
||||||
// s.State.SecondsSinceLastSet = int(time.Since(*s.State.LastSetTimestamp).Seconds())
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// func (s *TrainingService) CompleteSet() {
|
|
||||||
// st := s.State
|
|
||||||
// st.SetsDone++
|
|
||||||
// now := time.Now()
|
|
||||||
// st.SetTimes = append(st.SetTimes, now)
|
|
||||||
// if st.GoalSets > 0 {
|
|
||||||
// st.Progress = min(float64(st.SetsDone)/float64(st.GoalSets), 1.0)
|
|
||||||
// }
|
|
||||||
// st.LastSetTimestamp = &now
|
|
||||||
// st.SecondsSinceLastSet = 0
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// func (s *TrainingService) FinishTraining(session *data.TrainingSession) error {
|
|
||||||
// session.Program = s.State.CurrentProgram
|
|
||||||
// session.BlockDay = int64(s.State.CurrentBlockDay)
|
|
||||||
//
|
|
||||||
// err := s.dbService.SaveTraining(session)
|
|
||||||
// if err != nil {
|
|
||||||
// return err
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // Platzhalter für den API-Aufruf (aus api_service.dart)
|
|
||||||
// s.sendToBackend(session)
|
|
||||||
//
|
|
||||||
// s.ResetTraining()
|
|
||||||
// return nil
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// func (s *TrainingService) ResetTraining() {
|
|
||||||
// // Diesen Teil nochmals pruefen
|
|
||||||
// s.State = NewTrainingState()
|
|
||||||
// trainingCount, err := s.dbService.GetTrainingCount()
|
|
||||||
// if err != nil {
|
|
||||||
// log.Print("Unable to get training count")
|
|
||||||
// }
|
|
||||||
// s.State.CurrentBlockDay = trainingCount
|
|
||||||
// // Hier müsste man TotalTrainingDays wieder korrekt laden.
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// // sendToBackend ist ein Platzhalter für deinen API-Aufruf.
|
|
||||||
//
|
|
||||||
// func (s *TrainingService) sendToBackend(session *data.TrainingSession) {
|
|
||||||
// // Hier würde die Logik aus deinem `api_service.dart` hinkommen.
|
|
||||||
// // z.B. ein HTTP POST Request mit den Trainingsdaten.
|
|
||||||
// // Da der Service nicht existiert, loggen wir es nur.
|
|
||||||
// log.Println("Sende Trainingsdaten an das Backend (Platzhalter)...")
|
|
||||||
// // rest := float64(session.Duration) / float64(session.Sets)
|
|
||||||
// // log.Printf("Reps: %d, Rest: %.2f, Sets: %d", session.RepsPerSet, rest, session.Sets)
|
|
||||||
// }
|
|
||||||
package services
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.patanix.de/git/kettlebell-app/internal/data"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TrainingState struct {
|
|
||||||
IsTrainingRunning bool
|
|
||||||
RemainingSeconds int
|
|
||||||
InitialDurationSeconds int
|
|
||||||
SetsDone int
|
|
||||||
GoalSets int
|
|
||||||
RepsPerSet int
|
|
||||||
SetTimes []time.Time
|
|
||||||
Progress float64
|
|
||||||
SecondsSinceLastSet int
|
|
||||||
LastSetTimestamp *time.Time
|
|
||||||
CurrentProgram string
|
|
||||||
CurrentBlockDay int
|
|
||||||
CurrentReps int
|
|
||||||
TotalTrainingDays int
|
|
||||||
}
|
|
||||||
|
|
||||||
func calculateStateByDayCount(totalDays int) (program string, blockDay, reps int) {
|
|
||||||
program = "giant_1.0"
|
|
||||||
blockDay = 1
|
|
||||||
reps = 5
|
|
||||||
|
|
||||||
if totalDays > 0 {
|
|
||||||
cycleIndex := (totalDays / 12) % 6
|
|
||||||
|
|
||||||
programs := []string{"giant_1.0", "ksk_1.0", "giant_1.1", "ksk_1.1", "giant_1.2", "ksk_1.2"}
|
|
||||||
program = programs[cycleIndex]
|
|
||||||
|
|
||||||
blockDay = (totalDays % 3) + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
repsMap := map[string][]int{
|
|
||||||
"giant_1.0": {5, 6, 4},
|
|
||||||
"giant_1.1": {6, 8, 7},
|
|
||||||
"giant_1.2": {7, 9, 8},
|
|
||||||
"ksk_1.0": {5, 6, 4},
|
|
||||||
"ksk_1.1": {6, 8, 7},
|
|
||||||
"ksk_1.2": {7, 9, 8},
|
|
||||||
}
|
|
||||||
|
|
||||||
if r, ok := repsMap[program]; ok && len(r) >= blockDay {
|
|
||||||
reps = r[blockDay-1]
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTrainingState(db *data.DatabaseService) *TrainingState {
|
|
||||||
trainingCount, err := db.GetTrainingCount()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Fehler beim Abrufen der Trainingsanzahl, setze auf 0: %v", err)
|
|
||||||
trainingCount = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
program, blockDay, reps := calculateStateByDayCount(trainingCount)
|
|
||||||
|
|
||||||
return &TrainingState{
|
|
||||||
IsTrainingRunning: false,
|
|
||||||
TotalTrainingDays: trainingCount,
|
|
||||||
CurrentProgram: program,
|
|
||||||
CurrentBlockDay: blockDay,
|
|
||||||
CurrentReps: reps,
|
|
||||||
SetTimes: []time.Time{},
|
|
||||||
GoalSets: 5,
|
|
||||||
RepsPerSet: reps,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type TrainingService struct {
|
|
||||||
State *TrainingState
|
|
||||||
dbService *data.DatabaseService
|
|
||||||
settingsService *SettingsService
|
|
||||||
apiService *ApiService
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewTrainingService(db *data.DatabaseService, settings *SettingsService, api *ApiService) *TrainingService {
|
|
||||||
return &TrainingService{
|
|
||||||
State: NewTrainingState(db),
|
|
||||||
dbService: db,
|
|
||||||
settingsService: settings,
|
|
||||||
apiService: api,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *TrainingService) StartTraining(minutes, goal int) {
|
|
||||||
program, blockDay, reps := calculateStateByDayCount(s.State.TotalTrainingDays)
|
|
||||||
|
|
||||||
st := s.State
|
|
||||||
st.IsTrainingRunning = true
|
|
||||||
st.InitialDurationSeconds = minutes * 60
|
|
||||||
st.RemainingSeconds = st.InitialDurationSeconds
|
|
||||||
st.GoalSets = goal
|
|
||||||
st.CurrentProgram = program
|
|
||||||
st.CurrentBlockDay = blockDay
|
|
||||||
st.CurrentReps = reps
|
|
||||||
st.RepsPerSet = reps
|
|
||||||
|
|
||||||
st.SetsDone = 0
|
|
||||||
st.Progress = 0.0
|
|
||||||
st.SetTimes = []time.Time{}
|
|
||||||
st.LastSetTimestamp = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *TrainingService) Tick() {
|
|
||||||
if s.State.RemainingSeconds > 0 {
|
|
||||||
s.State.RemainingSeconds--
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *TrainingService) TickLastSetTimer() {
|
|
||||||
if s.State.IsTrainingRunning && s.State.LastSetTimestamp != nil {
|
|
||||||
s.State.SecondsSinceLastSet = int(time.Since(*s.State.LastSetTimestamp).Seconds())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *TrainingService) CompleteSet() {
|
|
||||||
st := s.State
|
|
||||||
st.SetsDone++
|
|
||||||
now := time.Now()
|
|
||||||
st.SetTimes = append(st.SetTimes, now)
|
|
||||||
if st.GoalSets > 0 {
|
|
||||||
st.Progress = min(float64(st.SetsDone)/float64(st.GoalSets), 1.0)
|
|
||||||
}
|
|
||||||
st.LastSetTimestamp = &now
|
|
||||||
st.SecondsSinceLastSet = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *TrainingService) FinishTraining(session *data.TrainingSession) error {
|
|
||||||
session.Program = s.State.CurrentProgram
|
|
||||||
session.BlockDay = int64(s.State.CurrentBlockDay)
|
|
||||||
|
|
||||||
err := s.dbService.SaveTraining(session)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// s.sendToBackend(session)
|
|
||||||
go s.apiService.SendTrainingData(session)
|
|
||||||
|
|
||||||
s.ResetTraining()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *TrainingService) ResetTraining() {
|
|
||||||
s.State = NewTrainingState(s.dbService)
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendToBackend ist ein Platzhalter für deinen API-Aufruf.
|
|
||||||
func (s *TrainingService) sendToBackend(session *data.TrainingSession) {
|
|
||||||
log.Println("Sende Trainingsdaten an das Backend (Platzhalter)...")
|
|
||||||
}
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
package ui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
|
|
||||||
"git.patanix.de/git/kettlebell-app/internal/data"
|
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
|
||||||
"fyne.io/fyne/v2/container"
|
|
||||||
"fyne.io/fyne/v2/dialog"
|
|
||||||
"fyne.io/fyne/v2/theme"
|
|
||||||
"fyne.io/fyne/v2/widget"
|
|
||||||
)
|
|
||||||
|
|
||||||
func formatDuration(totalSeconds int64) string {
|
|
||||||
mins := totalSeconds / 60
|
|
||||||
secs := totalSeconds % 60
|
|
||||||
return fmt.Sprintf("%02d:%02d", mins, secs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MakeHistoryScreen erstellt den Bildschirm für die Trainingshistorie.
|
|
||||||
func MakeHistoryScreen(db *data.DatabaseService, parent fyne.Window) fyne.CanvasObject {
|
|
||||||
var history []data.TrainingSession
|
|
||||||
|
|
||||||
// Platzhalter, wenn die Liste leer ist
|
|
||||||
placeholder := widget.NewLabel("Noch keine Trainingsdaten vorhanden.")
|
|
||||||
placeholder.Alignment = fyne.TextAlignCenter
|
|
||||||
|
|
||||||
list := widget.NewList(
|
|
||||||
func() int {
|
|
||||||
return len(history)
|
|
||||||
},
|
|
||||||
func() fyne.CanvasObject {
|
|
||||||
// Template für einen Listeneintrag
|
|
||||||
return widget.NewCard("", "", container.NewVBox(
|
|
||||||
widget.NewLabel(""), // Datum
|
|
||||||
widget.NewSeparator(),
|
|
||||||
widget.NewLabel(""), // Sätze
|
|
||||||
widget.NewLabel(""), // Gewicht
|
|
||||||
widget.NewLabel(""), // Reps
|
|
||||||
widget.NewLabel(""), // Dauer
|
|
||||||
))
|
|
||||||
},
|
|
||||||
func(i widget.ListItemID, o fyne.CanvasObject) {
|
|
||||||
// Template mit Daten füllen
|
|
||||||
session := history[i]
|
|
||||||
card := o.(*widget.Card)
|
|
||||||
|
|
||||||
// Datum als Titel der Karte
|
|
||||||
card.SetTitle(session.Date.Format("02.01.2006 15:04"))
|
|
||||||
|
|
||||||
// Details im Inhalt der Karte
|
|
||||||
box := card.Content.(*fyne.Container)
|
|
||||||
labels := box.Objects
|
|
||||||
labels[0].(*widget.Label).SetText(fmt.Sprintf("Programm: %s - Tag %d", session.Program, session.BlockDay))
|
|
||||||
labels[2].(*widget.Label).SetText(fmt.Sprintf("Sätze: %d", session.Sets))
|
|
||||||
labels[3].(*widget.Label).SetText(fmt.Sprintf("Kettlebells: %.1fkg / %.1fkg", session.WeightLeft, session.WeightRight))
|
|
||||||
labels[4].(*widget.Label).SetText(fmt.Sprintf("Reps pro Satz: %d", session.RepsPerSet))
|
|
||||||
labels[5].(*widget.Label).SetText(fmt.Sprintf("Dauer: %s", formatDuration(session.Duration)))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
// Funktion zum Neuladen der Daten
|
|
||||||
refreshData := func() {
|
|
||||||
var err error
|
|
||||||
history, err = db.GetHistory()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Fehler beim Laden der Historie: %v", err)
|
|
||||||
dialog.ShowError(err, parent)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(history) == 0 {
|
|
||||||
placeholder.Show()
|
|
||||||
list.Hide()
|
|
||||||
} else {
|
|
||||||
placeholder.Hide()
|
|
||||||
list.Show()
|
|
||||||
}
|
|
||||||
list.Refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initiales Laden
|
|
||||||
refreshData()
|
|
||||||
|
|
||||||
// Toolbar mit Refresh-Button
|
|
||||||
toolbar := widget.NewToolbar(
|
|
||||||
widget.NewToolbarAction(theme.ViewRefreshIcon(), refreshData),
|
|
||||||
)
|
|
||||||
|
|
||||||
return container.NewBorder(toolbar, nil, nil, nil, container.NewStack(list, placeholder))
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
package ui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fyne.io/fyne/v2"
|
|
||||||
"fyne.io/fyne/v2/canvas"
|
|
||||||
"fyne.io/fyne/v2/container"
|
|
||||||
"fyne.io/fyne/v2/theme"
|
|
||||||
"fyne.io/fyne/v2/widget"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MakeHomeScreen erstellt den statischen Willkommensbildschirm.
|
|
||||||
func MakeHomeScreen() fyne.CanvasObject {
|
|
||||||
primaryColor := theme.PrimaryColor()
|
|
||||||
|
|
||||||
title := canvas.NewText("Willkommen beim Giant Programm Tracker!", primaryColor)
|
|
||||||
title.TextStyle.Bold = true
|
|
||||||
title.Alignment = fyne.TextAlignCenter
|
|
||||||
title.TextSize = 24
|
|
||||||
|
|
||||||
subtitle := widget.NewLabel("Verwalte deine Kettlebell-Trainings effizient.")
|
|
||||||
subtitle.Alignment = fyne.TextAlignCenter
|
|
||||||
|
|
||||||
icon := widget.NewIcon(theme.MediaPlayIcon())
|
|
||||||
icon.Resize(fyne.NewSize(150, 150))
|
|
||||||
|
|
||||||
// Layout erstellen, das dem Flutter-Layout entspricht
|
|
||||||
content := container.NewVBox(
|
|
||||||
title,
|
|
||||||
widget.NewSeparator(),
|
|
||||||
subtitle,
|
|
||||||
container.NewPadded(icon), // Icon mit etwas Abstand
|
|
||||||
)
|
|
||||||
|
|
||||||
// Zentriert den Inhalt
|
|
||||||
return container.NewCenter(content)
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
package ui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"git.patanix.de/git/kettlebell-app/internal/services"
|
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
|
||||||
"fyne.io/fyne/v2/container"
|
|
||||||
"fyne.io/fyne/v2/dialog"
|
|
||||||
"fyne.io/fyne/v2/widget"
|
|
||||||
)
|
|
||||||
|
|
||||||
func MakeSettingsScreen(settingsService *services.SettingsService, parent fyne.Window) fyne.CanvasObject {
|
|
||||||
currentSettings := settingsService.LoadSettings()
|
|
||||||
|
|
||||||
trainingTimeEntry := widget.NewEntry()
|
|
||||||
trainingTimeEntry.SetText(fmt.Sprintf("%d", currentSettings.TrainingTimeMinutes))
|
|
||||||
|
|
||||||
weightLeftEntry := widget.NewEntry()
|
|
||||||
weightLeftEntry.SetText(fmt.Sprintf("%.1f", currentSettings.WeightLeft))
|
|
||||||
|
|
||||||
weightRightEntry := widget.NewEntry()
|
|
||||||
weightRightEntry.SetText(fmt.Sprintf("%.1f", currentSettings.WeightRight))
|
|
||||||
|
|
||||||
goalSetsEntry := widget.NewEntry()
|
|
||||||
goalSetsEntry.SetText(fmt.Sprintf("%d", currentSettings.GoalSets))
|
|
||||||
|
|
||||||
form := &widget.Form{
|
|
||||||
Items: []*widget.FormItem{
|
|
||||||
{Text: "Trainingszeit (Minuten)", Widget: trainingTimeEntry},
|
|
||||||
{Text: "Linke Kettlebell (kg)", Widget: weightLeftEntry},
|
|
||||||
{Text: "Rechte Kettlebell (kg)", Widget: weightRightEntry},
|
|
||||||
{Text: "Ziel-Sätze", Widget: goalSetsEntry},
|
|
||||||
},
|
|
||||||
OnSubmit: func() {
|
|
||||||
timeMin, err1 := strconv.Atoi(trainingTimeEntry.Text)
|
|
||||||
weightL, err2 := strconv.ParseFloat(weightLeftEntry.Text, 64)
|
|
||||||
weightR, err3 := strconv.ParseFloat(weightRightEntry.Text, 64)
|
|
||||||
goal, err4 := strconv.Atoi(goalSetsEntry.Text)
|
|
||||||
|
|
||||||
if err1 != nil || err2 != nil || err3 != nil || err4 != nil {
|
|
||||||
dialog.ShowError(fmt.Errorf("Bitte gib gültige Zahlen ein"), parent)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
newSettings := &services.Settings{
|
|
||||||
TrainingTimeMinutes: timeMin,
|
|
||||||
WeightLeft: weightL,
|
|
||||||
WeightRight: weightR,
|
|
||||||
GoalSets: goal,
|
|
||||||
}
|
|
||||||
settingsService.SaveSettings(newSettings)
|
|
||||||
|
|
||||||
fyne.CurrentApp().SendNotification(&fyne.Notification{
|
|
||||||
Title: "Gespeichert",
|
|
||||||
Content: "Die Einstellungen wurden erfolgreich aktualisiert.",
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return container.NewPadded(form)
|
|
||||||
}
|
|
||||||
|
|
@ -1,161 +0,0 @@
|
||||||
package ui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.patanix.de/git/kettlebell-app/internal/data"
|
|
||||||
"git.patanix.de/git/kettlebell-app/internal/services"
|
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
|
||||||
"fyne.io/fyne/v2/container"
|
|
||||||
"fyne.io/fyne/v2/dialog"
|
|
||||||
"fyne.io/fyne/v2/theme"
|
|
||||||
"fyne.io/fyne/v2/widget"
|
|
||||||
)
|
|
||||||
|
|
||||||
func MakeTrainingScreen(ts *services.TrainingService, ss *services.SettingsService, parent fyne.Window) fyne.CanvasObject {
|
|
||||||
programLabel := widget.NewLabelWithStyle("", fyne.TextAlignCenter, fyne.TextStyle{Bold: true})
|
|
||||||
blockDayLabel := widget.NewLabelWithStyle("", fyne.TextAlignCenter, fyne.TextStyle{})
|
|
||||||
repsLabel := widget.NewLabelWithStyle("", fyne.TextAlignCenter, fyne.TextStyle{Bold: true})
|
|
||||||
|
|
||||||
timerLabel := widget.NewLabelWithStyle("00:00", fyne.TextAlignCenter, fyne.TextStyle{Bold: true})
|
|
||||||
|
|
||||||
progressBar := widget.NewProgressBar()
|
|
||||||
progressLabel := widget.NewLabelWithStyle("Fortschritt: 0%", fyne.TextAlignCenter, fyne.TextStyle{})
|
|
||||||
|
|
||||||
var startButton, setButton, finishButton *widget.Button
|
|
||||||
|
|
||||||
setHistoryList := widget.NewList(
|
|
||||||
func() int {
|
|
||||||
return len(ts.State.SetTimes)
|
|
||||||
},
|
|
||||||
func() fyne.CanvasObject {
|
|
||||||
return widget.NewLabel("")
|
|
||||||
},
|
|
||||||
func(id widget.ListItemID, obj fyne.CanvasObject) {
|
|
||||||
t := ts.State.SetTimes[id]
|
|
||||||
obj.(*widget.Label).SetText(fmt.Sprintf("#%d um %s (%d Reps)", id+1, t.Format("15:04:05"), ts.State.CurrentReps))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
var mainTimer, lastSetTimer *time.Ticker
|
|
||||||
|
|
||||||
updateUI := func() {
|
|
||||||
state := ts.State
|
|
||||||
programLabel.SetText(state.CurrentProgram)
|
|
||||||
blockDayLabel.SetText(fmt.Sprintf("Block Tag: %d", state.CurrentBlockDay))
|
|
||||||
repsLabel.SetText(fmt.Sprintf("Reps pro Satz: %d", state.CurrentReps))
|
|
||||||
|
|
||||||
timerLabel.SetText(formatDuration(int64(state.RemainingSeconds)))
|
|
||||||
progressBar.SetValue(state.Progress)
|
|
||||||
progressLabel.SetText(fmt.Sprintf("Fortschritt: %.0f%%", state.Progress*100))
|
|
||||||
|
|
||||||
if state.IsTrainingRunning {
|
|
||||||
startButton.Disable()
|
|
||||||
setButton.Enable()
|
|
||||||
finishButton.Enable()
|
|
||||||
} else {
|
|
||||||
startButton.Enable()
|
|
||||||
setButton.Disable()
|
|
||||||
finishButton.Disable()
|
|
||||||
}
|
|
||||||
setHistoryList.Refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
stopTimers := func() {
|
|
||||||
if mainTimer != nil {
|
|
||||||
mainTimer.Stop()
|
|
||||||
mainTimer = nil
|
|
||||||
}
|
|
||||||
if lastSetTimer != nil {
|
|
||||||
lastSetTimer.Stop()
|
|
||||||
lastSetTimer = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
startAction := func() {
|
|
||||||
settings := ss.LoadSettings()
|
|
||||||
ts.StartTraining(settings.TrainingTimeMinutes, settings.GoalSets)
|
|
||||||
updateUI()
|
|
||||||
|
|
||||||
mainTimer = time.NewTicker(time.Second)
|
|
||||||
go func() {
|
|
||||||
for range mainTimer.C {
|
|
||||||
if ts.State.RemainingSeconds <= 0 {
|
|
||||||
stopTimers()
|
|
||||||
fyne.CurrentApp().SendNotification(&fyne.Notification{
|
|
||||||
Title: "Zeit abgelaufen!",
|
|
||||||
Content: "Training wird automatisch gespeichert.",
|
|
||||||
})
|
|
||||||
finishButton.OnTapped()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ts.Tick()
|
|
||||||
updateUI()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
lastSetTimer = time.NewTicker(time.Second)
|
|
||||||
go func() {
|
|
||||||
for range lastSetTimer.C {
|
|
||||||
ts.TickLastSetTimer()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
setAction := func() {
|
|
||||||
ts.CompleteSet()
|
|
||||||
fyne.CurrentApp().SendNotification(&fyne.Notification{Title: "Satz gespeichert!", Content: ""})
|
|
||||||
updateUI()
|
|
||||||
}
|
|
||||||
|
|
||||||
finishAction := func() {
|
|
||||||
stopTimers()
|
|
||||||
settings := ss.LoadSettings()
|
|
||||||
state := ts.State
|
|
||||||
|
|
||||||
session := &data.TrainingSession{
|
|
||||||
Date: time.Now(),
|
|
||||||
Sets: int64(state.SetsDone),
|
|
||||||
WeightLeft: settings.WeightLeft,
|
|
||||||
WeightRight: settings.WeightRight,
|
|
||||||
RepsPerSet: int64(state.RepsPerSet),
|
|
||||||
Duration: int64(state.InitialDurationSeconds - state.RemainingSeconds),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := ts.FinishTraining(session); err != nil {
|
|
||||||
dialog.ShowError(err, parent)
|
|
||||||
log.Printf("Fehler beim Speichern des Trainings: %v", err)
|
|
||||||
} else {
|
|
||||||
fyne.CurrentApp().SendNotification(&fyne.Notification{Title: "Training gespeichert!", Content: "Gut gemacht!"})
|
|
||||||
}
|
|
||||||
|
|
||||||
updateUI()
|
|
||||||
}
|
|
||||||
|
|
||||||
startButton = widget.NewButtonWithIcon("Start", theme.MediaPlayIcon(), startAction)
|
|
||||||
setButton = widget.NewButtonWithIcon("Satz", theme.ConfirmIcon(), setAction)
|
|
||||||
finishButton = widget.NewButtonWithIcon("Beenden", theme.MediaStopIcon(), finishAction)
|
|
||||||
|
|
||||||
updateUI()
|
|
||||||
|
|
||||||
headerCard := widget.NewCard("", "", container.NewVBox(programLabel, blockDayLabel, repsLabel))
|
|
||||||
timerCard := widget.NewCard("", "", container.NewVBox(
|
|
||||||
widget.NewLabelWithStyle("Verbleibende Zeit", fyne.TextAlignCenter, fyne.TextStyle{}),
|
|
||||||
timerLabel,
|
|
||||||
progressBar,
|
|
||||||
progressLabel,
|
|
||||||
))
|
|
||||||
|
|
||||||
actionButtons := container.NewGridWithColumns(3, startButton, setButton, finishButton)
|
|
||||||
historyCard := widget.NewCard("Satz-Historie", "", setHistoryList)
|
|
||||||
|
|
||||||
return container.NewVBox(
|
|
||||||
headerCard,
|
|
||||||
timerCard,
|
|
||||||
actionButtons,
|
|
||||||
historyCard,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
3
renovate.json
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
|
||||||
|
}
|
||||||
24
settings.gradle.kts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google {
|
||||||
|
content {
|
||||||
|
includeGroupByRegex("com\\.android.*")
|
||||||
|
includeGroupByRegex("com\\.google.*")
|
||||||
|
includeGroupByRegex("androidx.*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "kettlebelltracker"
|
||||||
|
include(":app")
|
||||||
|
|
||||||