Compare commits

...
Sign in to create a new pull request.

17 commits

Author SHA1 Message Date
6241efca58 fix: fix wrong url for sending finished trainings 2025-10-08 21:55:12 +02:00
cfbd2a313b feat: add Training Timer and calculation of optimal Round Times 2025-10-08 21:55:12 +02:00
git
6d9151c8ec Merge pull request 'chore: Configure Renovate' (#1) from renovate/configure into main
Reviewed-on: #1
2025-09-07 12:41:32 +02:00
Renovate Bot
5377dfce27 Add renovate.json 2025-09-07 12:38:27 +02:00
217623efe2 refactor: add missing files 2025-07-31 09:00:48 +02:00
b94c6e1ec1 refactor: remove old golang files 2025-07-30 22:33:34 +02:00
e6dc77116b change from golang fyne to kotlin and Jetpack Compose 2025-07-30 22:31:57 +02:00
e3eb2c9aa4 refactor: finish refactor by cleaning up codebase 2025-07-04 18:49:05 +02:00
519daeec40 refactor: update edit functionality to be less confusing 2025-07-04 18:40:47 +02:00
4f8e353d48 refactor: update wrong list behaviour in history screen 2025-07-04 17:40:51 +02:00
084ea252a2 refactor: change history screen to use a list instead of cards 2025-07-04 17:12:28 +02:00
059db8f2fb refactor: show finish Button only if time elapsed and center training card on home screen 2025-07-04 12:56:02 +02:00
16b2409ae8 refactor: update coding style 2025-07-04 10:57:09 +02:00
a7e427ca14 refactor: perform clean up 2025-06-28 14:13:14 +02:00
c15cdea57d refactor: new ui step3 2025-06-27 20:18:32 +02:00
24430d0fae refactor: ui refactor step 2 2025-06-27 19:55:34 +02:00
9cae00d2a5 refactor: first step to refactor the ui 2025-06-27 19:37:15 +02:00
68 changed files with 2068 additions and 1212 deletions

15
.gitignore vendored Normal file
View file

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

1
app/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

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

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

21
app/proguard-rules.pro vendored Normal file
View 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

View file

@ -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)
}
}

View 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>

View 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)
}
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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?
)

View file

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

View file

@ -0,0 +1,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
}
}
}

View file

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

View file

@ -0,0 +1,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")
}
}
)
}

View file

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

View file

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

View file

@ -0,0 +1,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)
)
}
}
}
}
}

View file

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

View file

@ -0,0 +1,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
)
}

View file

@ -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
)
)

View file

@ -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)
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,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)
}

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
View file

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

View file

@ -1,8 +0,0 @@
Website = "https://patanix.de"
[Details]
Icon = "Icon.png"
Name = "kettlebell_tracker"
ID = "de.patanix.kettlebell_tracker"
Version = "1.0.0"
Build = 5

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

View file

@ -1 +0,0 @@
fyne package -os android -release --tags -ldflags="-s -w"

View file

@ -1,54 +0,0 @@
package main
import (
"log"
"path/filepath"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/theme"
"git.patanix.de/git/kettlebell-app/internal/data"
"git.patanix.de/git/kettlebell-app/internal/services"
"git.patanix.de/git/kettlebell-app/internal/ui"
)
func main() {
myApp := app.NewWithID("com.example.kettlebell-tracker")
// myApp.Settings().SetTheme(theme.DarkTheme())
mainWIndow := myApp.NewWindow("Kettlebell Programm Tracker")
dbDir := myApp.Storage().RootURI().Path()
dbPath := filepath.Join(dbDir, "kb_training.db")
log.Println("Datenbankpfad:", dbPath)
dbService, err := data.NewDatabaseService(dbPath)
if err != nil {
log.Fatalf("Fehler bei der Initialisierung der Datenbank: %v", err)
}
settingsService := services.NewSettingsService(myApp)
apiService := services.NewApiService(myApp.UniqueID())
trainingService := services.NewTrainingService(dbService, settingsService, apiService)
homeScreen := ui.MakeHomeScreen()
settingsScreen := ui.MakeSettingsScreen(settingsService, mainWIndow)
historyScreen := ui.MakeHistoryScreen(dbService, mainWIndow)
trainingScreen := ui.MakeTrainingScreen(trainingService, settingsService, mainWIndow)
tabs := container.NewAppTabs(
container.NewTabItemWithIcon("Home", theme.HomeIcon(), homeScreen),
container.NewTabItemWithIcon("Training", theme.MediaPlayIcon(), trainingScreen),
container.NewTabItemWithIcon("Historie", theme.HistoryIcon(), historyScreen),
container.NewTabItemWithIcon("Einstellungen", theme.SettingsIcon(), settingsScreen),
)
tabs.SetTabLocation(container.TabLocationBottom)
mainWIndow.Resize(fyne.NewSize(400, 600))
mainWIndow.SetContent(tabs)
mainWIndow.SetMaster()
mainWIndow.ShowAndRun()
}

52
go.mod
View file

@ -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
View file

@ -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
View file

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

36
gradle/libs.versions.toml Normal file
View 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" }

View file

@ -1,117 +0,0 @@
package data
import (
"database/sql"
"log"
"time"
_ "modernc.org/sqlite" // Importiert den SQLite-Treiber
)
type DatabaseService struct {
DB *sql.DB
}
func NewDatabaseService(dbPath string) (*DatabaseService, error) {
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, err
}
if err = db.Ping(); err != nil {
return nil, err
}
createTableSQL := `
CREATE TABLE IF NOT EXISTS training (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
sets INTEGER,
weightLeft REAL,
weightRight REAL,
repsPerSet INTEGER,
duration INTEGER,
program TEXT,
blockDay INTEGER
);`
_, err = db.Exec(createTableSQL)
if err != nil {
log.Printf("Fehler beim Erstellen der Tabelle: %v", err)
return nil, err
}
// Hier könnten wir auch komplexere Migrationen wie dein _onUpgrade handle,
// aber für den Anfang reicht das Erstellen der Tabelle.
log.Println("Datenbank erfolgreich initialisiert.")
return &DatabaseService{DB: db}, nil
}
func (s *DatabaseService) SaveTraining(session *TrainingSession) error {
dateStr := session.Date.Format(time.RFC3339)
query := `
INSERT INTO training (id, date, sets, weightLeft, weightRight, repsPerSet, duration, program, blockDay)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
date = excluded.date,
sets = excluded.sets,
weightLeft = excluded.weightLeft,
weightRight = excluded.weightRight,
repsPerSet = excluded.repsPerSet,
duration = excluded.duration,
program = excluded.program,
blockDay = excluded.blockDay;
`
var id any
if session.ID != 0 {
id = session.ID
}
_, err := s.DB.Exec(query, id, dateStr, session.Sets, session.WeightLeft, session.WeightRight, session.RepsPerSet, session.Duration, session.Program, session.BlockDay)
return err
}
func (s *DatabaseService) GetTrainingCount() (int, error) {
var count int
query := "SELECT COUNT(*) FROM training;"
err := s.DB.QueryRow(query).Scan(&count)
if err != nil {
if err == sql.ErrNoRows {
return 0, nil
}
return 0, err
}
return count, nil
}
func (s *DatabaseService) GetHistory() ([]TrainingSession, error) {
query := `SELECT id, date, sets, weightLeft, weightRight, repsPerSet, duration, program, blockDay FROM training ORDER BY date DESC LIMIT 20;`
rows, err := s.DB.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var sessions []TrainingSession
for rows.Next() {
var s TrainingSession
var dateStr string
err := rows.Scan(&s.ID, &dateStr, &s.Sets, &s.WeightLeft, &s.WeightRight, &s.RepsPerSet, &s.Duration, &s.Program, &s.BlockDay)
if err != nil {
return nil, err
}
s.Date, err = time.Parse(time.RFC3339, dateStr)
if err != nil {
return nil, err
}
sessions = append(sessions, s)
}
return sessions, nil
}

View file

@ -1,17 +0,0 @@
package data
import "time"
// TrainingSession repräsentiert eine einzelne Trainingseinheit.
// Die `db`-Tags werden verwendet, um die Struct-Felder den Datenbankspalten zuzuordnen.
type TrainingSession struct {
ID int64 `db:"id"`
Date time.Time `db:"date"`
Sets int64 `db:"sets"`
WeightLeft float64 `db:"weightLeft"`
WeightRight float64 `db:"weightRight"`
RepsPerSet int64 `db:"repsPerSet"`
Duration int64 `db:"duration"` // in Sekunden
Program string `db:"program"`
BlockDay int64 `db:"blockDay"`
}

View file

@ -1,90 +0,0 @@
package services
import (
"bytes"
"encoding/json"
"log"
"net/http"
"time"
"git.patanix.de/git/kettlebell-app/internal/data"
)
// TrainingPayload ist die JSON-Struktur, die an das Backend gesendet wird.
// Die `json:"..."`-Tags stellen sicher, dass die Feldnamen im JSON korrekt sind.
type TrainingPayload struct {
Reps int `json:"reps"`
Rest float64 `json:"rest"`
Sets int `json:"sets"`
UUID string `json:"uuid"`
}
// ApiService kümmert sich um die Kommunikation mit dem Backend.
type ApiService struct {
client *http.Client
endpoint string
uuid string
}
// NewApiService erstellt einen neuen Service für die API-Kommunikation.
func NewApiService(appUUID string) *ApiService {
return &ApiService{
// Erstellt einen HTTP-Client mit einem 5-Sekunden-Timeout, genau wie in deiner Flutter-App.
client: &http.Client{
Timeout: 5 * time.Second,
},
endpoint: "http://192.168.178.43:8080/trainings/",
uuid: appUUID,
}
}
// SendTrainingData sendet eine abgeschlossene Trainingseinheit an das Backend.
func (s *ApiService) SendTrainingData(session *data.TrainingSession) {
// Berechnung für 'rest' durchführen.
var rest float64
if session.Sets > 0 {
rest = float64(session.Duration) / float64(session.Sets)
}
// Die zu sendenden Daten vorbereiten.
payload := TrainingPayload{
Reps: int(session.RepsPerSet),
Rest: rest,
Sets: int(session.Sets),
UUID: s.uuid,
}
// Daten in JSON umwandeln.
jsonData, err := json.Marshal(payload)
if err != nil {
log.Printf("API Fehler: Konnte Payload nicht in JSON umwandeln: %v", err)
return
}
// Den HTTP-Request erstellen.
req, err := http.NewRequest("POST", s.endpoint, bytes.NewBuffer(jsonData))
if err != nil {
log.Printf("API Fehler: Konnte Request nicht erstellen: %v", err)
return
}
req.Header.Set("Content-Type", "application/json")
// Request senden.
log.Printf("Sende Training an Backend: %s", string(jsonData))
resp, err := s.client.Do(req)
if err != nil {
log.Printf("API Fehler: Fehler beim Senden des Trainings: %v", err)
return
}
defer resp.Body.Close()
// Antwort des Servers prüfen.
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {
log.Println("Training erfolgreich an Backend gesendet.")
} else {
log.Printf("API Fehler: Unerwarteter Statuscode: %s", resp.Status)
// Optional: Den Body der Antwort lesen, um mehr Details zu erhalten.
// body, _ := io.ReadAll(resp.Body)
// log.Printf("Antwort-Body: %s", string(body))
}
}

View file

@ -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)
}

View file

@ -1,356 +0,0 @@
// package services
//
// import (
//
// "log"
// "time"
//
// "git.patanix.de/git/kettlebell-app/internal/data"
//
// )
//
// // TrainingState hält den aktuellen Zustand einer laufenden Trainingseinheit.
//
// type TrainingState struct {
// IsTrainingRunning bool
// RemainingSeconds int
// InitialDurationSeconds int
// SetsDone int
// GoalSets int
// RepsPerSet int
// SetTimes []time.Time
// Progress float64
// SecondsSinceLastSet int
// LastSetTimestamp *time.Time
// CurrentProgram string
// CurrentBlockDay int
// CurrentReps int
// TotalTrainingDays int
// }
//
// func NewTrainingState() *TrainingState {
// return &TrainingState{
// IsTrainingRunning: false,
// RemainingSeconds: 0,
// InitialDurationSeconds: 0,
// SetsDone: 0,
// GoalSets: 5,
// RepsPerSet: 5,
// Progress: 0.0,
// SecondsSinceLastSet: 0,
// LastSetTimestamp: nil,
// CurrentProgram: "giant_1.0",
// CurrentBlockDay: 1,
// CurrentReps: 5,
// TotalTrainingDays: 0,
// SetTimes: []time.Time{},
// }
// }
//
// type TrainingService struct {
// State *TrainingState
// dbService *data.DatabaseService
// settingsService *SettingsService
// }
//
// func NewTrainingService(db *data.DatabaseService, settings *SettingsService) *TrainingService {
// initialState := NewTrainingState()
// trainingCount, err := db.GetTrainingCount()
// if err != nil {
// log.Printf("Fehler beim Abrufen der Trainingsanzahl, setze auf 0: %v", err)
// initialState.TotalTrainingDays = 0
// } else {
// initialState.TotalTrainingDays = trainingCount
// }
// return &TrainingService{
// State: initialState,
// dbService: db,
// settingsService: settings,
// }
// }
//
// func (s *TrainingService) updateProgram() {
// st := s.State
// newTotalDays := st.TotalTrainingDays + 1
// newProgram := st.CurrentProgram
// newDay := (st.CurrentBlockDay % 3) + 1
// newReps := st.CurrentReps
//
// if newTotalDays > 0 && newTotalDays%12 == 0 {
// switch st.CurrentProgram {
// case "giant_1.0":
// newProgram = "ksk_1.0"
// case "giant_1.1":
// newProgram = "ksk_1.1"
// case "giant_1.2":
// newProgram = "ksk_1.2"
// case "ksk_1.0":
// newProgram = "giant_1.1"
// case "ksk_1.1":
// newProgram = "giant_1.2"
// case "ksk_1.2":
// newProgram = "giant_1.0"
// default:
// newProgram = "giant_1.0"
// }
// newDay = 1
// }
//
// repsMap := map[string][]int{
// "giant_1.0": {5, 6, 4},
// "giant_1.1": {6, 8, 7},
// "giant_1.2": {7, 9, 8},
// "ksk_1.0": {5, 6, 4},
// "ksk_1.1": {6, 8, 7},
// "ksk_1.2": {7, 9, 8},
// }
//
// if reps, ok := repsMap[newProgram]; ok && len(reps) >= newDay {
// newReps = reps[newDay-1]
// } else {
// newReps = 5
// }
//
// st.CurrentProgram = newProgram
// st.CurrentBlockDay = newDay
// st.CurrentReps = newReps
// st.TotalTrainingDays = newTotalDays
// }
//
// func (s *TrainingService) StartTraining(minutes, goal int) {
// s.updateProgram()
// duration := minutes * 60
// s.State = &TrainingState{
// IsTrainingRunning: true,
// InitialDurationSeconds: duration,
// RemainingSeconds: duration,
// GoalSets: goal,
// RepsPerSet: s.State.CurrentReps,
// CurrentProgram: s.State.CurrentProgram,
// CurrentBlockDay: s.State.CurrentBlockDay,
// TotalTrainingDays: s.State.TotalTrainingDays,
// SetTimes: []time.Time{},
// }
// }
//
// func (s *TrainingService) Tick() {
// if s.State.RemainingSeconds > 0 {
// s.State.RemainingSeconds--
// }
// }
//
// func (s *TrainingService) TickLastSetTimer() {
// if s.State.IsTrainingRunning && s.State.LastSetTimestamp != nil {
// s.State.SecondsSinceLastSet = int(time.Since(*s.State.LastSetTimestamp).Seconds())
// }
// }
//
// func (s *TrainingService) CompleteSet() {
// st := s.State
// st.SetsDone++
// now := time.Now()
// st.SetTimes = append(st.SetTimes, now)
// if st.GoalSets > 0 {
// st.Progress = min(float64(st.SetsDone)/float64(st.GoalSets), 1.0)
// }
// st.LastSetTimestamp = &now
// st.SecondsSinceLastSet = 0
// }
//
// func (s *TrainingService) FinishTraining(session *data.TrainingSession) error {
// session.Program = s.State.CurrentProgram
// session.BlockDay = int64(s.State.CurrentBlockDay)
//
// err := s.dbService.SaveTraining(session)
// if err != nil {
// return err
// }
//
// // Platzhalter für den API-Aufruf (aus api_service.dart)
// s.sendToBackend(session)
//
// s.ResetTraining()
// return nil
// }
//
// func (s *TrainingService) ResetTraining() {
// // Diesen Teil nochmals pruefen
// s.State = NewTrainingState()
// trainingCount, err := s.dbService.GetTrainingCount()
// if err != nil {
// log.Print("Unable to get training count")
// }
// s.State.CurrentBlockDay = trainingCount
// // Hier müsste man TotalTrainingDays wieder korrekt laden.
// }
//
// // sendToBackend ist ein Platzhalter für deinen API-Aufruf.
//
// func (s *TrainingService) sendToBackend(session *data.TrainingSession) {
// // Hier würde die Logik aus deinem `api_service.dart` hinkommen.
// // z.B. ein HTTP POST Request mit den Trainingsdaten.
// // Da der Service nicht existiert, loggen wir es nur.
// log.Println("Sende Trainingsdaten an das Backend (Platzhalter)...")
// // rest := float64(session.Duration) / float64(session.Sets)
// // log.Printf("Reps: %d, Rest: %.2f, Sets: %d", session.RepsPerSet, rest, session.Sets)
// }
package services
import (
"log"
"time"
"git.patanix.de/git/kettlebell-app/internal/data"
)
type TrainingState struct {
IsTrainingRunning bool
RemainingSeconds int
InitialDurationSeconds int
SetsDone int
GoalSets int
RepsPerSet int
SetTimes []time.Time
Progress float64
SecondsSinceLastSet int
LastSetTimestamp *time.Time
CurrentProgram string
CurrentBlockDay int
CurrentReps int
TotalTrainingDays int
}
func calculateStateByDayCount(totalDays int) (program string, blockDay, reps int) {
program = "giant_1.0"
blockDay = 1
reps = 5
if totalDays > 0 {
cycleIndex := (totalDays / 12) % 6
programs := []string{"giant_1.0", "ksk_1.0", "giant_1.1", "ksk_1.1", "giant_1.2", "ksk_1.2"}
program = programs[cycleIndex]
blockDay = (totalDays % 3) + 1
}
repsMap := map[string][]int{
"giant_1.0": {5, 6, 4},
"giant_1.1": {6, 8, 7},
"giant_1.2": {7, 9, 8},
"ksk_1.0": {5, 6, 4},
"ksk_1.1": {6, 8, 7},
"ksk_1.2": {7, 9, 8},
}
if r, ok := repsMap[program]; ok && len(r) >= blockDay {
reps = r[blockDay-1]
}
return
}
func NewTrainingState(db *data.DatabaseService) *TrainingState {
trainingCount, err := db.GetTrainingCount()
if err != nil {
log.Printf("Fehler beim Abrufen der Trainingsanzahl, setze auf 0: %v", err)
trainingCount = 0
}
program, blockDay, reps := calculateStateByDayCount(trainingCount)
return &TrainingState{
IsTrainingRunning: false,
TotalTrainingDays: trainingCount,
CurrentProgram: program,
CurrentBlockDay: blockDay,
CurrentReps: reps,
SetTimes: []time.Time{},
GoalSets: 5,
RepsPerSet: reps,
}
}
type TrainingService struct {
State *TrainingState
dbService *data.DatabaseService
settingsService *SettingsService
apiService *ApiService
}
func NewTrainingService(db *data.DatabaseService, settings *SettingsService, api *ApiService) *TrainingService {
return &TrainingService{
State: NewTrainingState(db),
dbService: db,
settingsService: settings,
apiService: api,
}
}
func (s *TrainingService) StartTraining(minutes, goal int) {
program, blockDay, reps := calculateStateByDayCount(s.State.TotalTrainingDays)
st := s.State
st.IsTrainingRunning = true
st.InitialDurationSeconds = minutes * 60
st.RemainingSeconds = st.InitialDurationSeconds
st.GoalSets = goal
st.CurrentProgram = program
st.CurrentBlockDay = blockDay
st.CurrentReps = reps
st.RepsPerSet = reps
st.SetsDone = 0
st.Progress = 0.0
st.SetTimes = []time.Time{}
st.LastSetTimestamp = nil
}
func (s *TrainingService) Tick() {
if s.State.RemainingSeconds > 0 {
s.State.RemainingSeconds--
}
}
func (s *TrainingService) TickLastSetTimer() {
if s.State.IsTrainingRunning && s.State.LastSetTimestamp != nil {
s.State.SecondsSinceLastSet = int(time.Since(*s.State.LastSetTimestamp).Seconds())
}
}
func (s *TrainingService) CompleteSet() {
st := s.State
st.SetsDone++
now := time.Now()
st.SetTimes = append(st.SetTimes, now)
if st.GoalSets > 0 {
st.Progress = min(float64(st.SetsDone)/float64(st.GoalSets), 1.0)
}
st.LastSetTimestamp = &now
st.SecondsSinceLastSet = 0
}
func (s *TrainingService) FinishTraining(session *data.TrainingSession) error {
session.Program = s.State.CurrentProgram
session.BlockDay = int64(s.State.CurrentBlockDay)
err := s.dbService.SaveTraining(session)
if err != nil {
return err
}
// s.sendToBackend(session)
go s.apiService.SendTrainingData(session)
s.ResetTraining()
return nil
}
func (s *TrainingService) ResetTraining() {
s.State = NewTrainingState(s.dbService)
}
// sendToBackend ist ein Platzhalter für deinen API-Aufruf.
func (s *TrainingService) sendToBackend(session *data.TrainingSession) {
log.Println("Sende Trainingsdaten an das Backend (Platzhalter)...")
}

View file

@ -1,92 +0,0 @@
package ui
import (
"fmt"
"log"
"git.patanix.de/git/kettlebell-app/internal/data"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
func formatDuration(totalSeconds int64) string {
mins := totalSeconds / 60
secs := totalSeconds % 60
return fmt.Sprintf("%02d:%02d", mins, secs)
}
// MakeHistoryScreen erstellt den Bildschirm für die Trainingshistorie.
func MakeHistoryScreen(db *data.DatabaseService, parent fyne.Window) fyne.CanvasObject {
var history []data.TrainingSession
// Platzhalter, wenn die Liste leer ist
placeholder := widget.NewLabel("Noch keine Trainingsdaten vorhanden.")
placeholder.Alignment = fyne.TextAlignCenter
list := widget.NewList(
func() int {
return len(history)
},
func() fyne.CanvasObject {
// Template für einen Listeneintrag
return widget.NewCard("", "", container.NewVBox(
widget.NewLabel(""), // Datum
widget.NewSeparator(),
widget.NewLabel(""), // Sätze
widget.NewLabel(""), // Gewicht
widget.NewLabel(""), // Reps
widget.NewLabel(""), // Dauer
))
},
func(i widget.ListItemID, o fyne.CanvasObject) {
// Template mit Daten füllen
session := history[i]
card := o.(*widget.Card)
// Datum als Titel der Karte
card.SetTitle(session.Date.Format("02.01.2006 15:04"))
// Details im Inhalt der Karte
box := card.Content.(*fyne.Container)
labels := box.Objects
labels[0].(*widget.Label).SetText(fmt.Sprintf("Programm: %s - Tag %d", session.Program, session.BlockDay))
labels[2].(*widget.Label).SetText(fmt.Sprintf("Sätze: %d", session.Sets))
labels[3].(*widget.Label).SetText(fmt.Sprintf("Kettlebells: %.1fkg / %.1fkg", session.WeightLeft, session.WeightRight))
labels[4].(*widget.Label).SetText(fmt.Sprintf("Reps pro Satz: %d", session.RepsPerSet))
labels[5].(*widget.Label).SetText(fmt.Sprintf("Dauer: %s", formatDuration(session.Duration)))
},
)
// Funktion zum Neuladen der Daten
refreshData := func() {
var err error
history, err = db.GetHistory()
if err != nil {
log.Printf("Fehler beim Laden der Historie: %v", err)
dialog.ShowError(err, parent)
}
if len(history) == 0 {
placeholder.Show()
list.Hide()
} else {
placeholder.Hide()
list.Show()
}
list.Refresh()
}
// Initiales Laden
refreshData()
// Toolbar mit Refresh-Button
toolbar := widget.NewToolbar(
widget.NewToolbarAction(theme.ViewRefreshIcon(), refreshData),
)
return container.NewBorder(toolbar, nil, nil, nil, container.NewStack(list, placeholder))
}

View file

@ -1,36 +0,0 @@
package ui
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
// MakeHomeScreen erstellt den statischen Willkommensbildschirm.
func MakeHomeScreen() fyne.CanvasObject {
primaryColor := theme.PrimaryColor()
title := canvas.NewText("Willkommen beim Giant Programm Tracker!", primaryColor)
title.TextStyle.Bold = true
title.Alignment = fyne.TextAlignCenter
title.TextSize = 24
subtitle := widget.NewLabel("Verwalte deine Kettlebell-Trainings effizient.")
subtitle.Alignment = fyne.TextAlignCenter
icon := widget.NewIcon(theme.MediaPlayIcon())
icon.Resize(fyne.NewSize(150, 150))
// Layout erstellen, das dem Flutter-Layout entspricht
content := container.NewVBox(
title,
widget.NewSeparator(),
subtitle,
container.NewPadded(icon), // Icon mit etwas Abstand
)
// Zentriert den Inhalt
return container.NewCenter(content)
}

View file

@ -1,64 +0,0 @@
package ui
import (
"fmt"
"strconv"
"git.patanix.de/git/kettlebell-app/internal/services"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/widget"
)
func MakeSettingsScreen(settingsService *services.SettingsService, parent fyne.Window) fyne.CanvasObject {
currentSettings := settingsService.LoadSettings()
trainingTimeEntry := widget.NewEntry()
trainingTimeEntry.SetText(fmt.Sprintf("%d", currentSettings.TrainingTimeMinutes))
weightLeftEntry := widget.NewEntry()
weightLeftEntry.SetText(fmt.Sprintf("%.1f", currentSettings.WeightLeft))
weightRightEntry := widget.NewEntry()
weightRightEntry.SetText(fmt.Sprintf("%.1f", currentSettings.WeightRight))
goalSetsEntry := widget.NewEntry()
goalSetsEntry.SetText(fmt.Sprintf("%d", currentSettings.GoalSets))
form := &widget.Form{
Items: []*widget.FormItem{
{Text: "Trainingszeit (Minuten)", Widget: trainingTimeEntry},
{Text: "Linke Kettlebell (kg)", Widget: weightLeftEntry},
{Text: "Rechte Kettlebell (kg)", Widget: weightRightEntry},
{Text: "Ziel-Sätze", Widget: goalSetsEntry},
},
OnSubmit: func() {
timeMin, err1 := strconv.Atoi(trainingTimeEntry.Text)
weightL, err2 := strconv.ParseFloat(weightLeftEntry.Text, 64)
weightR, err3 := strconv.ParseFloat(weightRightEntry.Text, 64)
goal, err4 := strconv.Atoi(goalSetsEntry.Text)
if err1 != nil || err2 != nil || err3 != nil || err4 != nil {
dialog.ShowError(fmt.Errorf("Bitte gib gültige Zahlen ein"), parent)
return
}
newSettings := &services.Settings{
TrainingTimeMinutes: timeMin,
WeightLeft: weightL,
WeightRight: weightR,
GoalSets: goal,
}
settingsService.SaveSettings(newSettings)
fyne.CurrentApp().SendNotification(&fyne.Notification{
Title: "Gespeichert",
Content: "Die Einstellungen wurden erfolgreich aktualisiert.",
})
},
}
return container.NewPadded(form)
}

View file

@ -1,161 +0,0 @@
package ui
import (
"fmt"
"log"
"time"
"git.patanix.de/git/kettlebell-app/internal/data"
"git.patanix.de/git/kettlebell-app/internal/services"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
func MakeTrainingScreen(ts *services.TrainingService, ss *services.SettingsService, parent fyne.Window) fyne.CanvasObject {
programLabel := widget.NewLabelWithStyle("", fyne.TextAlignCenter, fyne.TextStyle{Bold: true})
blockDayLabel := widget.NewLabelWithStyle("", fyne.TextAlignCenter, fyne.TextStyle{})
repsLabel := widget.NewLabelWithStyle("", fyne.TextAlignCenter, fyne.TextStyle{Bold: true})
timerLabel := widget.NewLabelWithStyle("00:00", fyne.TextAlignCenter, fyne.TextStyle{Bold: true})
progressBar := widget.NewProgressBar()
progressLabel := widget.NewLabelWithStyle("Fortschritt: 0%", fyne.TextAlignCenter, fyne.TextStyle{})
var startButton, setButton, finishButton *widget.Button
setHistoryList := widget.NewList(
func() int {
return len(ts.State.SetTimes)
},
func() fyne.CanvasObject {
return widget.NewLabel("")
},
func(id widget.ListItemID, obj fyne.CanvasObject) {
t := ts.State.SetTimes[id]
obj.(*widget.Label).SetText(fmt.Sprintf("#%d um %s (%d Reps)", id+1, t.Format("15:04:05"), ts.State.CurrentReps))
},
)
var mainTimer, lastSetTimer *time.Ticker
updateUI := func() {
state := ts.State
programLabel.SetText(state.CurrentProgram)
blockDayLabel.SetText(fmt.Sprintf("Block Tag: %d", state.CurrentBlockDay))
repsLabel.SetText(fmt.Sprintf("Reps pro Satz: %d", state.CurrentReps))
timerLabel.SetText(formatDuration(int64(state.RemainingSeconds)))
progressBar.SetValue(state.Progress)
progressLabel.SetText(fmt.Sprintf("Fortschritt: %.0f%%", state.Progress*100))
if state.IsTrainingRunning {
startButton.Disable()
setButton.Enable()
finishButton.Enable()
} else {
startButton.Enable()
setButton.Disable()
finishButton.Disable()
}
setHistoryList.Refresh()
}
stopTimers := func() {
if mainTimer != nil {
mainTimer.Stop()
mainTimer = nil
}
if lastSetTimer != nil {
lastSetTimer.Stop()
lastSetTimer = nil
}
}
startAction := func() {
settings := ss.LoadSettings()
ts.StartTraining(settings.TrainingTimeMinutes, settings.GoalSets)
updateUI()
mainTimer = time.NewTicker(time.Second)
go func() {
for range mainTimer.C {
if ts.State.RemainingSeconds <= 0 {
stopTimers()
fyne.CurrentApp().SendNotification(&fyne.Notification{
Title: "Zeit abgelaufen!",
Content: "Training wird automatisch gespeichert.",
})
finishButton.OnTapped()
return
}
ts.Tick()
updateUI()
}
}()
lastSetTimer = time.NewTicker(time.Second)
go func() {
for range lastSetTimer.C {
ts.TickLastSetTimer()
}
}()
}
setAction := func() {
ts.CompleteSet()
fyne.CurrentApp().SendNotification(&fyne.Notification{Title: "Satz gespeichert!", Content: ""})
updateUI()
}
finishAction := func() {
stopTimers()
settings := ss.LoadSettings()
state := ts.State
session := &data.TrainingSession{
Date: time.Now(),
Sets: int64(state.SetsDone),
WeightLeft: settings.WeightLeft,
WeightRight: settings.WeightRight,
RepsPerSet: int64(state.RepsPerSet),
Duration: int64(state.InitialDurationSeconds - state.RemainingSeconds),
}
if err := ts.FinishTraining(session); err != nil {
dialog.ShowError(err, parent)
log.Printf("Fehler beim Speichern des Trainings: %v", err)
} else {
fyne.CurrentApp().SendNotification(&fyne.Notification{Title: "Training gespeichert!", Content: "Gut gemacht!"})
}
updateUI()
}
startButton = widget.NewButtonWithIcon("Start", theme.MediaPlayIcon(), startAction)
setButton = widget.NewButtonWithIcon("Satz", theme.ConfirmIcon(), setAction)
finishButton = widget.NewButtonWithIcon("Beenden", theme.MediaStopIcon(), finishAction)
updateUI()
headerCard := widget.NewCard("", "", container.NewVBox(programLabel, blockDayLabel, repsLabel))
timerCard := widget.NewCard("", "", container.NewVBox(
widget.NewLabelWithStyle("Verbleibende Zeit", fyne.TextAlignCenter, fyne.TextStyle{}),
timerLabel,
progressBar,
progressLabel,
))
actionButtons := container.NewGridWithColumns(3, startButton, setButton, finishButton)
historyCard := widget.NewCard("Satz-Historie", "", setHistoryList)
return container.NewVBox(
headerCard,
timerCard,
actionButtons,
historyCard,
)
}

3
renovate.json Normal file
View file

@ -0,0 +1,3 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json"
}

24
settings.gradle.kts Normal file
View file

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