Compare commits
7 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6241efca58 | |||
| cfbd2a313b | |||
| 6d9151c8ec | |||
|
|
5377dfce27 | ||
| 217623efe2 | |||
| b94c6e1ec1 | |||
| e6dc77116b |
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 = 9
|
||||
BIN
cmd/Icon.png
|
Before Width: | Height: | Size: 100 KiB |
|
|
@ -1,2 +0,0 @@
|
|||
# fyne package -os android -release --tags -ldflags="-s -w"
|
||||
fyne package -os android/arm64 -release --tags -ldflags="-s -w" -certificate my-release-key.keystore
|
||||
67
cmd/main.go
|
|
@ -1,67 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"path/filepath"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/app"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"git.patanix.de/git/kettlebell-app/internal/data"
|
||||
"git.patanix.de/git/kettlebell-app/internal/services"
|
||||
"git.patanix.de/git/kettlebell-app/internal/ui"
|
||||
"git.patanix.de/git/kettlebell-app/internal/ui/theme"
|
||||
)
|
||||
|
||||
func main() {
|
||||
myApp := app.NewWithID("com.patani.kettlebell-tracker")
|
||||
myApp.Settings().SetTheme(&theme.KettlebellThemeOneDark{})
|
||||
|
||||
mainWindow := myApp.NewWindow("Kettlebell Tracker")
|
||||
|
||||
dbDir := myApp.Storage().RootURI().Path()
|
||||
dbPath := filepath.Join(dbDir, "giant_training.db")
|
||||
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)
|
||||
|
||||
contentContainer := container.NewStack()
|
||||
var navigateTo func(string)
|
||||
|
||||
trainingScreen, startTrainingAction := ui.MakeTrainingScreen(trainingService, settingsService, mainWindow)
|
||||
|
||||
homeScreen := ui.MakeHomeScreen(trainingService, dbService, func() {
|
||||
startTrainingAction()
|
||||
navigateTo("training")
|
||||
})
|
||||
|
||||
historyScreen := ui.MakeHistoryScreen(dbService, mainWindow)
|
||||
settingsScreen := ui.MakeSettingsScreen(settingsService, mainWindow)
|
||||
|
||||
screens := map[string]fyne.CanvasObject{
|
||||
"home": homeScreen,
|
||||
"training": trainingScreen,
|
||||
"history": historyScreen,
|
||||
"settings": settingsScreen,
|
||||
}
|
||||
|
||||
for _, s := range screens {
|
||||
contentContainer.Add(s)
|
||||
}
|
||||
|
||||
navBar, navigateFunc := ui.MakeNavBar(screens, contentContainer)
|
||||
navigateTo = navigateFunc
|
||||
|
||||
navigateTo("home")
|
||||
|
||||
mainLayout := container.NewBorder(nil, navBar, nil, nil, contentContainer)
|
||||
|
||||
mainWindow.SetContent(mainLayout)
|
||||
mainWindow.Resize(fyne.NewSize(360, 740))
|
||||
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,156 +0,0 @@
|
|||
package data
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
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) 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 sess TrainingSession
|
||||
var dateStr string
|
||||
err := rows.Scan(&sess.ID, &dateStr, &sess.Sets, &sess.WeightLeft, &sess.WeightRight, &sess.RepsPerSet, &sess.Duration, &sess.Program, &sess.BlockDay)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sess.Date, err = time.Parse(time.RFC3339, dateStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessions = append(sessions, sess)
|
||||
}
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
func (s *DatabaseService) GetLastTraining() (*TrainingSession, error) {
|
||||
query := `SELECT id, date, sets, weightLeft, weightRight, repsPerSet, duration, program, blockDay FROM training ORDER BY date DESC LIMIT 1;`
|
||||
row := s.DB.QueryRow(query)
|
||||
|
||||
var session TrainingSession
|
||||
var dateStr string
|
||||
err := row.Scan(&session.ID, &dateStr, &session.Sets, &session.WeightLeft, &session.WeightRight, &session.RepsPerSet, &session.Duration, &session.Program, &session.BlockDay)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
session.Date, err = time.Parse(time.RFC3339, dateStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
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) DeleteTraining(id int64) error {
|
||||
query := "DELETE FROM training WHERE id = ?;"
|
||||
_, err := s.DB.Exec(query, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *DatabaseService) UpdateTraining(session *TrainingSession) error {
|
||||
dateStr := session.Date.Format(time.RFC3339)
|
||||
query := `
|
||||
UPDATE training
|
||||
SET date = ?, sets = ?, weightLeft = ?, weightRight = ?, repsPerSet = ?, duration = ?, program = ?, blockDay = ?
|
||||
WHERE id = ?;
|
||||
`
|
||||
res, err := s.DB.Exec(query, dateStr, session.Sets, session.WeightLeft, session.WeightRight, session.RepsPerSet, session.Duration, session.Program, session.BlockDay, session.ID)
|
||||
if err != nil {
|
||||
log.Printf("UpdateTraining Fehler: %v", err)
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
log.Printf("UpdateTraining RowsAffected Fehler: %v", err)
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
log.Printf("UpdateTraining: Kein Datensatz mit ID %d gefunden", session.ID)
|
||||
return fmt.Errorf("kein Datensatz mit ID %d gefunden", session.ID)
|
||||
}
|
||||
log.Printf("UpdateTraining erfolgreich für ID %d", session.ID)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
package data
|
||||
|
||||
import "time"
|
||||
|
||||
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"`
|
||||
Program string `db:"program"`
|
||||
BlockDay int64 `db:"blockDay"`
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.patanix.de/git/kettlebell-app/internal/data"
|
||||
)
|
||||
|
||||
type TrainingPayload struct {
|
||||
Reps int `json:"reps"`
|
||||
Rest float64 `json:"rest"`
|
||||
Sets int `json:"sets"`
|
||||
UUID string `json:"uuid"`
|
||||
}
|
||||
|
||||
type ApiService struct {
|
||||
client *http.Client
|
||||
endpoint string
|
||||
uuid string
|
||||
}
|
||||
|
||||
func NewApiService(appUUID string) *ApiService {
|
||||
return &ApiService{
|
||||
client: &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
},
|
||||
endpoint: "http://192.168.178.43:8080/trainings/",
|
||||
uuid: appUUID,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ApiService) SendTrainingData(session *data.TrainingSession) {
|
||||
var rest float64
|
||||
if session.Sets > 0 {
|
||||
rest = float64(session.Duration) / float64(session.Sets)
|
||||
}
|
||||
|
||||
payload := TrainingPayload{
|
||||
Reps: int(session.RepsPerSet),
|
||||
Rest: rest,
|
||||
Sets: int(session.Sets),
|
||||
UUID: s.uuid,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
log.Printf("API Fehler: Konnte Payload nicht in JSON umwandeln: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
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()
|
||||
|
||||
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,355 +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 = "clean_1.0"
|
||||
blockDay = 1
|
||||
reps = 5
|
||||
|
||||
if totalDays > 0 {
|
||||
cycleIndex := (totalDays / 12) % 6
|
||||
|
||||
programs := []string{"clean_1.0", "snatch_1.0", "clean_1.1", "snatch_1.1", "clean_1.2", "snatch_1.2"}
|
||||
program = programs[cycleIndex]
|
||||
|
||||
blockDay = (totalDays % 3) + 1
|
||||
}
|
||||
|
||||
repsMap := map[string][]int{
|
||||
"clean_1.0": {5, 6, 4},
|
||||
"clean_1.1": {6, 8, 7},
|
||||
"clean_1.2": {7, 9, 8},
|
||||
"snatch_1.0": {5, 6, 4},
|
||||
"snatch_1.1": {6, 8, 7},
|
||||
"snatch_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
|
||||
}
|
||||
|
||||
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,59 +0,0 @@
|
|||
package components
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"git.patanix.de/git/kettlebell-app/internal/ui/theme"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/canvas"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
type NavButton struct {
|
||||
widget.BaseWidget
|
||||
icon *canvas.Image
|
||||
label *canvas.Text
|
||||
onTapped func()
|
||||
isActive bool
|
||||
container *fyne.Container
|
||||
}
|
||||
|
||||
func NewNavButton(label string, iconRes fyne.Resource, active bool, tapped func()) *NavButton {
|
||||
icon := canvas.NewImageFromResource(iconRes)
|
||||
icon.FillMode = canvas.ImageFillContain
|
||||
icon.SetMinSize(fyne.NewSize(28, 28))
|
||||
|
||||
text := canvas.NewText(label, color.White)
|
||||
text.TextSize = 12
|
||||
text.Alignment = fyne.TextAlignCenter
|
||||
|
||||
button := &NavButton{
|
||||
icon: icon,
|
||||
label: text,
|
||||
onTapped: tapped,
|
||||
}
|
||||
button.ExtendBaseWidget(button)
|
||||
button.container = container.NewVBox(icon, text)
|
||||
button.SetActive(active)
|
||||
return button
|
||||
}
|
||||
|
||||
func (b *NavButton) CreateRenderer() fyne.WidgetRenderer {
|
||||
return widget.NewSimpleRenderer(b.container)
|
||||
}
|
||||
|
||||
func (b *NavButton) Tapped(*fyne.PointEvent) {
|
||||
b.onTapped()
|
||||
}
|
||||
|
||||
func (b *NavButton) SetActive(active bool) {
|
||||
b.isActive = active
|
||||
if b.isActive {
|
||||
b.label.Color = theme.OneDarkGreen
|
||||
} else {
|
||||
b.label.Color = theme.OneDarkText
|
||||
}
|
||||
b.Refresh()
|
||||
}
|
||||
|
|
@ -1,204 +0,0 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
"git.patanix.de/git/kettlebell-app/internal/data"
|
||||
"git.patanix.de/git/kettlebell-app/internal/ui/utils"
|
||||
)
|
||||
|
||||
func MakeHistoryScreen(db *data.DatabaseService, parent fyne.Window) fyne.CanvasObject {
|
||||
var history []data.TrainingSession
|
||||
|
||||
placeholder := widget.NewLabel("Noch keine Trainingsdaten vorhanden.")
|
||||
|
||||
var showDetailDialog func(session *data.TrainingSession, id int)
|
||||
var refreshData func()
|
||||
|
||||
list := widget.NewList(
|
||||
func() int { return len(history) },
|
||||
func() fyne.CanvasObject {
|
||||
return container.NewGridWithColumns(4,
|
||||
widget.NewLabel(""),
|
||||
widget.NewLabel(""),
|
||||
widget.NewLabel(""),
|
||||
widget.NewLabel(""),
|
||||
)
|
||||
},
|
||||
func(i widget.ListItemID, o fyne.CanvasObject) {
|
||||
session := history[i]
|
||||
row := o.(*fyne.Container)
|
||||
row.Objects[0].(*widget.Label).SetText(session.Date.Format("02.01.2006"))
|
||||
row.Objects[1].(*widget.Label).SetText(fmt.Sprintf("%d", session.Sets))
|
||||
row.Objects[2].(*widget.Label).SetText(utils.FormatDuration(session.Duration))
|
||||
row.Objects[3].(*widget.Label).SetText(fmt.Sprintf("%d", session.RepsPerSet))
|
||||
},
|
||||
)
|
||||
|
||||
list.OnSelected = func(id widget.ListItemID) {
|
||||
session := &history[id]
|
||||
showDetailDialog(session, id)
|
||||
list.Unselect(id)
|
||||
}
|
||||
|
||||
showDetailDialog = func(session *data.TrainingSession, id int) {
|
||||
setsEntry := widget.NewEntry()
|
||||
setsEntry.SetText(fmt.Sprintf("%d", session.Sets))
|
||||
|
||||
durationEntry := widget.NewEntry()
|
||||
durationEntry.SetText(fmt.Sprintf("%d", session.Duration))
|
||||
|
||||
repsEntry := widget.NewEntry()
|
||||
repsEntry.SetText(fmt.Sprintf("%d", session.RepsPerSet))
|
||||
|
||||
weightLeftEntry := widget.NewEntry()
|
||||
weightLeftEntry.SetText(fmt.Sprintf("%.1f", session.WeightLeft))
|
||||
|
||||
weightRightEntry := widget.NewEntry()
|
||||
weightRightEntry.SetText(fmt.Sprintf("%.1f", session.WeightRight))
|
||||
|
||||
info := container.NewVBox(
|
||||
widget.NewLabel(fmt.Sprintf("Datum: %s", session.Date.Format("02.01.2006 15:04"))),
|
||||
widget.NewLabel(fmt.Sprintf("Programm: %s", session.Program)),
|
||||
widget.NewLabel(fmt.Sprintf("Block-Tag: %d", session.BlockDay)),
|
||||
)
|
||||
|
||||
form := widget.NewForm(
|
||||
&widget.FormItem{Text: "Sätze", Widget: setsEntry},
|
||||
&widget.FormItem{Text: "Dauer (Sekunden)", Widget: durationEntry},
|
||||
&widget.FormItem{Text: "Reps/Satz", Widget: repsEntry},
|
||||
&widget.FormItem{Text: "Gewicht links (kg)", Widget: weightLeftEntry},
|
||||
&widget.FormItem{Text: "Gewicht rechts (kg)", Widget: weightRightEntry},
|
||||
)
|
||||
|
||||
// Lokale Dialog-Referenz deklarieren
|
||||
var detailDialog dialog.Dialog
|
||||
|
||||
saveBtn := widget.NewButtonWithIcon("Speichern", theme.ConfirmIcon(), func() {
|
||||
sets, err := strconv.Atoi(setsEntry.Text)
|
||||
if err != nil {
|
||||
dialog.ShowError(fmt.Errorf("Ungültige Sätze-Zahl"), parent)
|
||||
return
|
||||
}
|
||||
duration, err := strconv.Atoi(durationEntry.Text)
|
||||
if err != nil {
|
||||
dialog.ShowError(fmt.Errorf("Ungültige Dauer"), parent)
|
||||
return
|
||||
}
|
||||
reps, err := strconv.Atoi(repsEntry.Text)
|
||||
if err != nil {
|
||||
dialog.ShowError(fmt.Errorf("Ungültige Wiederholungszahl"), parent)
|
||||
return
|
||||
}
|
||||
weightLeft, err := strconv.ParseFloat(weightLeftEntry.Text, 64)
|
||||
if err != nil {
|
||||
dialog.ShowError(fmt.Errorf("Ungültiges Gewicht links"), parent)
|
||||
return
|
||||
}
|
||||
weightRight, err := strconv.ParseFloat(weightRightEntry.Text, 64)
|
||||
if err != nil {
|
||||
dialog.ShowError(fmt.Errorf("Ungültiges Gewicht rechts"), parent)
|
||||
return
|
||||
}
|
||||
|
||||
session.Sets = int64(sets)
|
||||
session.Duration = int64(duration)
|
||||
session.RepsPerSet = int64(reps)
|
||||
session.WeightLeft = weightLeft
|
||||
session.WeightRight = weightRight
|
||||
|
||||
if err := db.UpdateTraining(session); err != nil {
|
||||
dialog.ShowError(err, parent)
|
||||
return
|
||||
}
|
||||
dialog.ShowInformation("Erfolg", "Trainingseintrag aktualisiert.", parent)
|
||||
refreshData()
|
||||
detailDialog.Hide()
|
||||
})
|
||||
|
||||
deleteBtn := widget.NewButtonWithIcon("Löschen", theme.DeleteIcon(), func() {
|
||||
dialog.ShowConfirm("Eintrag löschen", "Möchtest du diesen Eintrag wirklich löschen?", func(ok bool) {
|
||||
if ok {
|
||||
err := db.DeleteTraining(session.ID)
|
||||
if err != nil {
|
||||
dialog.ShowError(err, parent)
|
||||
return
|
||||
}
|
||||
refreshData()
|
||||
detailDialog.Hide()
|
||||
}
|
||||
}, parent)
|
||||
})
|
||||
|
||||
cancelBtn := widget.NewButton("Abbrechen", func() {
|
||||
detailDialog.Hide()
|
||||
})
|
||||
|
||||
btns := container.NewHBox(saveBtn, deleteBtn, cancelBtn)
|
||||
|
||||
content := container.NewVBox(
|
||||
info,
|
||||
form,
|
||||
btns,
|
||||
)
|
||||
|
||||
detailDialog = dialog.NewCustom("Training bearbeiten/löschen", "Schließen", content, parent)
|
||||
detailDialog.Show()
|
||||
}
|
||||
|
||||
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)
|
||||
return
|
||||
}
|
||||
if len(history) == 0 {
|
||||
placeholder.Show()
|
||||
list.Hide()
|
||||
} else {
|
||||
placeholder.Hide()
|
||||
list.Show()
|
||||
}
|
||||
list.Refresh()
|
||||
}
|
||||
|
||||
header := container.NewGridWithColumns(4,
|
||||
widget.NewLabelWithStyle("Datum", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
|
||||
widget.NewLabelWithStyle("Sätze", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
|
||||
widget.NewLabelWithStyle("Dauer", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
|
||||
widget.NewLabelWithStyle("Reps/Satz", fyne.TextAlignCenter, fyne.TextStyle{Bold: true}),
|
||||
)
|
||||
|
||||
toolbar := widget.NewToolbar(widget.NewToolbarAction(theme.ViewRefreshIcon(), refreshData))
|
||||
// content := container.NewVBox(header, container.NewStack(list, container.NewCenter(placeholder)))
|
||||
// layout := container.NewBorder(toolbar, nil, nil, nil, content)
|
||||
|
||||
content := container.NewBorder(
|
||||
header,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
container.NewVScroll(list),
|
||||
)
|
||||
|
||||
layout := container.NewBorder(
|
||||
toolbar,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
content,
|
||||
)
|
||||
if layout.Visible() {
|
||||
refreshData()
|
||||
}
|
||||
return layout
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"git.patanix.de/git/kettlebell-app/internal/data"
|
||||
"git.patanix.de/git/kettlebell-app/internal/services"
|
||||
|
||||
"git.patanix.de/git/kettlebell-app/internal/ui/theme"
|
||||
"git.patanix.de/git/kettlebell-app/internal/ui/utils"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/canvas"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
func MakeHomeScreen(ts *services.TrainingService, db *data.DatabaseService, onStart func()) fyne.CanvasObject {
|
||||
headerTitle := canvas.NewText("Kettlebell Workout Tracker", theme.OneDarkText)
|
||||
headerTitle.TextSize = 28
|
||||
headerTitle.TextStyle.Bold = true
|
||||
|
||||
header := container.NewCenter(
|
||||
widget.NewSeparator(),
|
||||
headerTitle,
|
||||
)
|
||||
|
||||
state := ts.State
|
||||
startButton := widget.NewButton("Training starten", onStart)
|
||||
startButton.Importance = widget.HighImportance
|
||||
|
||||
nextTrainingCard := widget.NewCard(
|
||||
"Nächstes Training",
|
||||
fmt.Sprintf("%s - Tag %d", state.CurrentProgram, state.CurrentBlockDay),
|
||||
container.NewVBox(
|
||||
widget.NewLabel(fmt.Sprintf("Ziel: %d Wiederholungen pro Satz", state.CurrentReps)),
|
||||
startButton,
|
||||
),
|
||||
)
|
||||
centerContent := container.NewCenter(nextTrainingCard)
|
||||
|
||||
setsValue := widget.NewLabelWithStyle("–", fyne.TextAlignCenter, fyne.TextStyle{Bold: true})
|
||||
durationValue := widget.NewLabelWithStyle("–", fyne.TextAlignCenter, fyne.TextStyle{Bold: true})
|
||||
weightValue := widget.NewLabelWithStyle("–", fyne.TextAlignCenter, fyne.TextStyle{Bold: true})
|
||||
|
||||
statsCard := widget.NewCard("Letzte Leistung", "", container.NewGridWithColumns(3,
|
||||
container.NewVBox(widget.NewLabel("Sätze"), setsValue),
|
||||
container.NewVBox(widget.NewLabel("Dauer"), durationValue),
|
||||
container.NewVBox(widget.NewLabel("Gewicht"), weightValue),
|
||||
))
|
||||
|
||||
loadLastPerformance := func() {
|
||||
lastSession, err := db.GetLastTraining()
|
||||
if err != nil {
|
||||
log.Printf("Fehler beim Laden der letzten Session: %v", err)
|
||||
return
|
||||
}
|
||||
if lastSession != nil {
|
||||
setsValue.SetText(fmt.Sprintf("%d", lastSession.Sets))
|
||||
durationValue.SetText(utils.FormatDuration(lastSession.Duration))
|
||||
weightValue.SetText(fmt.Sprintf("%.1fkg", lastSession.WeightLeft))
|
||||
trainedToday := false
|
||||
trainedToday = EqualDate(lastSession.Date, time.Now())
|
||||
if trainedToday {
|
||||
startButton.Disabled()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
borderLayout := container.NewBorder(
|
||||
header,
|
||||
statsCard,
|
||||
nil,
|
||||
nil,
|
||||
centerContent,
|
||||
)
|
||||
|
||||
paddedLayout := container.NewPadded(borderLayout)
|
||||
if paddedLayout.Visible() {
|
||||
loadLastPerformance()
|
||||
}
|
||||
|
||||
return paddedLayout
|
||||
}
|
||||
|
||||
func EqualDate(date1, date2 time.Time) bool {
|
||||
y1, m1, d1 := date1.Date()
|
||||
y2, m2, d2 := date2.Date()
|
||||
return y1 == y2 && m1 == m2 && d1 == d2
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"git.patanix.de/git/kettlebell-app/internal/ui/components"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
)
|
||||
|
||||
func MakeNavBar(screens map[string]fyne.CanvasObject, content *fyne.Container) (fyne.CanvasObject, func(string)) {
|
||||
buttons := make(map[string]*components.NavButton)
|
||||
|
||||
navigateTo := func(name string) {
|
||||
for key, screen := range screens {
|
||||
screen.Hide()
|
||||
if key == name {
|
||||
screen.Show()
|
||||
}
|
||||
}
|
||||
for key, button := range buttons {
|
||||
button.SetActive(false)
|
||||
if key == name {
|
||||
button.SetActive(true)
|
||||
}
|
||||
}
|
||||
content.Refresh()
|
||||
}
|
||||
|
||||
buttons["home"] = components.NewNavButton("Home", theme.HomeIcon(), false, func() { navigateTo("home") })
|
||||
buttons["training"] = components.NewNavButton("Training", theme.MediaPlayIcon(), false, func() { navigateTo("training") })
|
||||
buttons["history"] = components.NewNavButton("Historie", theme.ListIcon(), false, func() { navigateTo("history") })
|
||||
buttons["settings"] = components.NewNavButton("Einstellungen", theme.SettingsIcon(), false, func() { navigateTo("settings") })
|
||||
|
||||
navContainer := container.NewGridWithColumns(4,
|
||||
buttons["home"],
|
||||
buttons["training"],
|
||||
buttons["history"],
|
||||
buttons["settings"],
|
||||
)
|
||||
|
||||
return navContainer, navigateTo
|
||||
}
|
||||
|
|
@ -1,82 +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/widget"
|
||||
)
|
||||
|
||||
func MakeSettingsScreen(settingsService *services.SettingsService, parent fyne.Window) fyne.CanvasObject {
|
||||
var timeEntry, setsEntry, weightLeftEntry, weightRightEntry *widget.Entry
|
||||
|
||||
loadData := func() {
|
||||
currentSettings := settingsService.LoadSettings()
|
||||
timeEntry.SetText(fmt.Sprintf("%d", currentSettings.TrainingTimeMinutes))
|
||||
setsEntry.SetText(fmt.Sprintf("%d", currentSettings.GoalSets))
|
||||
weightLeftEntry.SetText(fmt.Sprintf("%.1f", currentSettings.WeightLeft))
|
||||
weightRightEntry.SetText(fmt.Sprintf("%.1f", currentSettings.WeightRight))
|
||||
}
|
||||
|
||||
timeEntry = widget.NewEntry()
|
||||
timeEntry.Validator = func(s string) error {
|
||||
if _, err := strconv.Atoi(s); err != nil {
|
||||
return fmt.Errorf("muss eine Zahl sein")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
setsEntry = widget.NewEntry()
|
||||
setsEntry.Validator = timeEntry.Validator
|
||||
|
||||
weightLeftEntry = widget.NewEntry()
|
||||
weightLeftEntry.Validator = func(s string) error {
|
||||
if _, err := strconv.ParseFloat(s, 64); err != nil {
|
||||
return fmt.Errorf("muss eine Zahl sein")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
weightRightEntry = widget.NewEntry()
|
||||
weightRightEntry.Validator = weightLeftEntry.Validator
|
||||
|
||||
form := &widget.Form{
|
||||
Items: []*widget.FormItem{
|
||||
{Text: "Trainingszeit (Minuten)", Widget: timeEntry},
|
||||
{Text: "Ziel-Sätze", Widget: setsEntry},
|
||||
{Text: "Links (kg)", Widget: weightLeftEntry},
|
||||
{Text: "Rechts (kg)", Widget: weightRightEntry},
|
||||
},
|
||||
OnSubmit: func() {
|
||||
timeMin, _ := strconv.Atoi(timeEntry.Text)
|
||||
goal, _ := strconv.Atoi(setsEntry.Text)
|
||||
weightL, _ := strconv.ParseFloat(weightLeftEntry.Text, 64)
|
||||
weightR, _ := strconv.ParseFloat(weightRightEntry.Text, 64)
|
||||
|
||||
newSettings := &services.Settings{
|
||||
TrainingTimeMinutes: timeMin,
|
||||
GoalSets: goal,
|
||||
WeightLeft: weightL,
|
||||
WeightRight: weightR,
|
||||
InitialProgram: settingsService.LoadSettings().InitialProgram,
|
||||
}
|
||||
settingsService.SaveSettings(newSettings)
|
||||
fyne.CurrentApp().SendNotification(&fyne.Notification{
|
||||
Title: "Gespeichert",
|
||||
Content: "Die Einstellungen wurden erfolgreich aktualisiert.",
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
title := widget.NewLabelWithStyle("Einstellungen", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||
layout := container.NewVBox(title, widget.NewSeparator(), form)
|
||||
paddedLayout := container.NewPadded(layout)
|
||||
if paddedLayout.Visible() {
|
||||
loadData()
|
||||
}
|
||||
|
||||
return paddedLayout
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
package theme
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
)
|
||||
|
||||
var (
|
||||
OneDarkBackground = color.NRGBA{R: 40, G: 44, B: 52, A: 0xff} // #282c34 (helleres Grau)
|
||||
OneDarkCardBackground = color.NRGBA{R: 30, G: 32, B: 40, A: 0xff} // #1e2028 (dunkler für Cards)
|
||||
OneDarkText = color.NRGBA{R: 171, G: 178, B: 191, A: 0xff} // #abb2bf (Standard-Text)
|
||||
OneDarkSubtleText = color.NRGBA{R: 110, G: 115, B: 141, A: 0xff} // #6e738d (deaktiviert, Placeholder)
|
||||
|
||||
OneDarkGreen = color.NRGBA{R: 152, G: 195, B: 121, A: 0xff} // #98c379
|
||||
OneDarkRed = color.NRGBA{R: 224, G: 108, B: 117, A: 0xff} // #e06c75
|
||||
OneDarkYellow = color.NRGBA{R: 229, G: 192, B: 123, A: 0xff} // #e5c07b
|
||||
)
|
||||
|
||||
type KettlebellThemeOneDark struct{}
|
||||
|
||||
func (t *KettlebellThemeOneDark) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color {
|
||||
switch name {
|
||||
case theme.ColorNameBackground:
|
||||
return OneDarkBackground
|
||||
case theme.ColorNameButton:
|
||||
return OneDarkGreen
|
||||
case theme.ColorNameDisabledButton:
|
||||
return OneDarkSubtleText
|
||||
case theme.ColorNamePrimary:
|
||||
return OneDarkGreen
|
||||
case theme.ColorNamePlaceHolder:
|
||||
return OneDarkSubtleText
|
||||
case theme.ColorNameHover:
|
||||
return OneDarkYellow
|
||||
case theme.ColorNameForeground:
|
||||
return OneDarkText
|
||||
case theme.ColorNameDisabled:
|
||||
return OneDarkSubtleText
|
||||
case theme.ColorNameError:
|
||||
return OneDarkRed
|
||||
case theme.ColorNameInputBackground:
|
||||
return OneDarkCardBackground
|
||||
case theme.ColorNameSeparator:
|
||||
return OneDarkSubtleText
|
||||
case theme.ColorNameSelection:
|
||||
return OneDarkYellow
|
||||
case theme.ColorNameShadow:
|
||||
return OneDarkCardBackground
|
||||
case theme.ColorNameFocus:
|
||||
return OneDarkYellow
|
||||
default:
|
||||
return theme.DefaultTheme().Color(name, variant)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *KettlebellThemeOneDark) Icon(name fyne.ThemeIconName) fyne.Resource {
|
||||
return theme.DefaultTheme().Icon(name)
|
||||
}
|
||||
|
||||
func (t *KettlebellThemeOneDark) Font(style fyne.TextStyle) fyne.Resource {
|
||||
return theme.DefaultTheme().Font(style)
|
||||
}
|
||||
|
||||
func (t *KettlebellThemeOneDark) Size(name fyne.ThemeSizeName) float32 {
|
||||
switch name {
|
||||
case theme.SizeNamePadding:
|
||||
return 8
|
||||
case theme.SizeNameText:
|
||||
return 16
|
||||
case theme.SizeNameHeadingText:
|
||||
return 24
|
||||
case theme.SizeNameSubHeadingText:
|
||||
return 20
|
||||
default:
|
||||
return theme.DefaultTheme().Size(name)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.patanix.de/git/kettlebell-app/internal/data"
|
||||
"git.patanix.de/git/kettlebell-app/internal/services"
|
||||
"git.patanix.de/git/kettlebell-app/internal/ui/theme"
|
||||
"git.patanix.de/git/kettlebell-app/internal/ui/utils"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/canvas"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
func MakeTrainingScreen(ts *services.TrainingService, ss *services.SettingsService, parent fyne.Window) (fyne.CanvasObject, func()) {
|
||||
timerLabel := canvas.NewText("00:00", theme.OneDarkText)
|
||||
timerLabel.TextSize = 60
|
||||
timerLabel.TextStyle.Bold = true
|
||||
timerLabel.Alignment = fyne.TextAlignCenter
|
||||
|
||||
setsLabel := canvas.NewText("0 / 0", theme.OneDarkGreen)
|
||||
setsLabel.TextSize = 48
|
||||
setsLabel.TextStyle.Bold = true
|
||||
setsLabel.Alignment = fyne.TextAlignCenter
|
||||
|
||||
repsLabel := canvas.NewText("0 Wiederholungen", theme.OneDarkSubtleText)
|
||||
repsLabel.TextSize = 20
|
||||
repsLabel.Alignment = fyne.TextAlignCenter
|
||||
|
||||
var finishButton *widget.Button
|
||||
|
||||
var mainTimer *time.Ticker
|
||||
|
||||
updateUI := func() {
|
||||
state := ts.State
|
||||
timerLabel.Text = utils.FormatDuration(int64(state.RemainingSeconds))
|
||||
setsLabel.Text = fmt.Sprintf("%d / %d", state.SetsDone, state.GoalSets)
|
||||
repsLabel.Text = fmt.Sprintf("%d Wiederholungen", state.RepsPerSet)
|
||||
|
||||
if finishButton != nil {
|
||||
if state.RemainingSeconds <= 0 && state.IsTrainingRunning {
|
||||
finishButton.Show()
|
||||
} else {
|
||||
finishButton.Hide()
|
||||
}
|
||||
}
|
||||
|
||||
timerLabel.Refresh()
|
||||
setsLabel.Refresh()
|
||||
repsLabel.Refresh()
|
||||
}
|
||||
|
||||
finishAction := func() {
|
||||
if ts.State.RemainingSeconds > 0 {
|
||||
return
|
||||
}
|
||||
if mainTimer != nil {
|
||||
mainTimer.Stop()
|
||||
mainTimer = nil
|
||||
}
|
||||
if !ts.State.IsTrainingRunning {
|
||||
return
|
||||
}
|
||||
session := &data.TrainingSession{
|
||||
Date: time.Now(),
|
||||
Sets: int64(ts.State.SetsDone),
|
||||
WeightLeft: ss.LoadSettings().WeightLeft,
|
||||
WeightRight: ss.LoadSettings().WeightRight,
|
||||
RepsPerSet: int64(ts.State.RepsPerSet),
|
||||
Duration: int64(ts.State.InitialDurationSeconds - ts.State.RemainingSeconds),
|
||||
}
|
||||
ts.FinishTraining(session)
|
||||
fyne.CurrentApp().SendNotification(&fyne.Notification{Title: "Training gespeichert!", Content: "Gut gemacht!"})
|
||||
updateUI()
|
||||
}
|
||||
|
||||
startAction := func() {
|
||||
if ts.State.IsTrainingRunning {
|
||||
return
|
||||
}
|
||||
settings := ss.LoadSettings()
|
||||
ts.StartTraining(settings.TrainingTimeMinutes, settings.GoalSets)
|
||||
updateUI()
|
||||
|
||||
mainTimer = time.NewTicker(time.Second)
|
||||
go func() {
|
||||
for mainTimer != nil {
|
||||
<-mainTimer.C
|
||||
if ts.State.RemainingSeconds <= 0 {
|
||||
finishAction()
|
||||
return
|
||||
}
|
||||
ts.Tick()
|
||||
updateUI()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
setAction := func() {
|
||||
if !ts.State.IsTrainingRunning {
|
||||
return
|
||||
}
|
||||
ts.CompleteSet()
|
||||
updateUI()
|
||||
}
|
||||
|
||||
topPart := container.NewVBox(widget.NewLabelWithStyle("Verbleibende Zeit", fyne.TextAlignCenter, fyne.TextStyle{}), timerLabel)
|
||||
middlePart := container.NewVBox(widget.NewLabelWithStyle("Sätze", fyne.TextAlignCenter, fyne.TextStyle{}), setsLabel, repsLabel)
|
||||
finishButton = widget.NewButton("Training beenden", finishAction)
|
||||
finishButton.Hide() // oder: finishButton.Hide() // finishButton.Disable()
|
||||
setButton := widget.NewButton("Satz abschließen", setAction)
|
||||
setButton.Importance = widget.HighImportance
|
||||
setButton.Resize(fyne.NewSize(120, 60))
|
||||
|
||||
bottomPart := container.NewVBox(
|
||||
setButton,
|
||||
finishButton,
|
||||
)
|
||||
|
||||
layout := container.NewBorder(topPart, bottomPart, nil, nil, container.NewCenter(middlePart))
|
||||
if layout.Visible() {
|
||||
updateUI()
|
||||
}
|
||||
|
||||
return layout, startAction
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
package utils
|
||||
|
||||
import "fmt"
|
||||
|
||||
func FormatDuration(totalSeconds int64) string {
|
||||
if totalSeconds < 0 {
|
||||
totalSeconds = 0
|
||||
}
|
||||
mins := totalSeconds / 60
|
||||
secs := totalSeconds % 60
|
||||
return fmt.Sprintf("%02d:%02d", mins, secs)
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
package utils
|
||||
|
||||
import "fmt"
|
||||
|
||||
func formatDuration(totalSeconds int64) string {
|
||||
mins := totalSeconds / 60
|
||||
secs := totalSeconds % 60
|
||||
return fmt.Sprintf("%02d:%02d", mins, secs)
|
||||
}
|
||||
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")
|
||||
|
||||