chore: remove obsolete Android app configuration files
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s

- Deleted build.gradle.kts, gradle.properties, and gradlew files as part of the cleanup process.
- Removed local.properties and various generated files from the .gradle directory to streamline the project structure.
- Cleared out unnecessary build artifacts and intermediate files to improve project maintainability.
This commit is contained in:
Torsten Schulz (local)
2026-04-21 15:15:21 +02:00
parent c8dedb10cc
commit 41bbf81958
4144 changed files with 4975 additions and 61401 deletions

View File

@@ -0,0 +1,9 @@
plugins {
// this is necessary to avoid the plugins to be loaded multiple times
// in each subproject's classloader
alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.androidLibrary) apply false
alias(libs.plugins.jetbrainsCompose) apply false
alias(libs.plugins.compose.compiler) apply false
alias(libs.plugins.kotlinMultiplatform) apply false
}

View File

@@ -0,0 +1,68 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidApplication)
alias(libs.plugins.jetbrainsCompose)
alias(libs.plugins.compose.compiler)
}
kotlin {
androidTarget {
compilations.all {
kotlinOptions {
jvmTarget = "1.8"
}
}
}
sourceSets {
commonMain.dependencies {
implementation(project(":shared"))
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(libs.voyager.navigator)
implementation(libs.voyager.screenmodel)
implementation(libs.voyager.koin)
implementation(libs.koin.core)
}
androidMain.dependencies {
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.core.ktx)
implementation(libs.koin.android)
}
}
}
android {
namespace = "de.tt_tagebuch.app"
compileSdk = libs.versions.android.compileSdk.get().toInt()
defaultConfig {
applicationId = "de.tt_tagebuch.app"
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1
versionName = "1.0.0"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
}
dependencies {
debugImplementation(libs.compose.ui.tooling)
}

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".MainApplication"
android:allowBackup="true"
android:icon="@android:mipmap/sym_def_app_icon"
android:label="Trainingstagebuch"
android:roundIcon="@android:mipmap/sym_def_app_icon"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">
<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,14 @@
package de.tt_tagebuch.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
App()
}
}
}

View File

@@ -0,0 +1,18 @@
package de.tt_tagebuch.app
import android.app.Application
import de.tt_tagebuch.app.di.appModule
import de.tt_tagebuch.shared.di.initKoin
import org.koin.android.ext.koin.androidContext
class MainApplication : Application() {
override fun onCreate() {
super.onCreate()
initKoin(
baseUrl = "https://tt-tagebuch.de",
additionalModules = listOf(appModule)
) {
androidContext(this@MainApplication)
}
}
}

View File

@@ -0,0 +1,13 @@
package de.tt_tagebuch.app
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.Composable
import cafe.adriel.voyager.navigator.Navigator
import de.tt_tagebuch.app.ui.LoginScreen
@Composable
fun App() {
MaterialTheme {
Navigator(LoginScreen())
}
}

View File

@@ -0,0 +1,16 @@
package de.tt_tagebuch.app.di
import de.tt_tagebuch.app.viewmodel.DiaryScreenModel
import de.tt_tagebuch.app.viewmodel.LoginScreenModel
import de.tt_tagebuch.app.viewmodel.MemberScreenModel
import de.tt_tagebuch.app.viewmodel.ParticipantScreenModel
import de.tt_tagebuch.app.viewmodel.MemberEditScreenModel
import org.koin.dsl.module
val appModule = module {
factory { LoginScreenModel(get()) }
factory { DiaryScreenModel(get()) }
factory { MemberScreenModel(get()) } // Updated
factory { ParticipantScreenModel(get()) }
factory { MemberEditScreenModel(get()) }
}

View File

@@ -0,0 +1,79 @@
package de.tt_tagebuch.app.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.getScreenModel
import de.tt_tagebuch.app.viewmodel.DiaryScreenModel
import de.tt_tagebuch.app.viewmodel.DiaryState
class DiaryScreen(private val clubId: Int) : Screen {
@Composable
override fun Content() {
val viewModel = getScreenModel<DiaryScreenModel>()
val state by viewModel.state.collectAsState()
LaunchedEffect(clubId) {
viewModel.loadDiaryDates(clubId)
}
Scaffold(
topBar = {
TopAppBar(title = { Text("Trainingstagebuch") })
},
floatingActionButton = {
FloatingActionButton(onClick = { /* Add new entry */ }) {
Icon(Icons.Default.Add, contentDescription = "Add")
}
}
) { padding ->
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
when (state) {
is DiaryState.Loading -> {
CircularProgressIndicator(modifier = Modifier.padding(16.dp))
}
is DiaryState.Success -> {
val dates = (state as DiaryState.Success).dates
LazyColumn {
items(dates) { date ->
DiaryDateItem(date) {
navigator?.push(ParticipantScreen(date.id, date.date))
}
}
}
}
is DiaryState.Error -> {
Text(
text = (state as DiaryState.Error).message,
color = MaterialTheme.colors.error,
modifier = Modifier.padding(16.dp)
)
}
}
}
}
}
@Composable
fun DiaryDateItem(date: de.tt_tagebuch.shared.models.DiaryDate, onClick: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth().padding(8.dp).clickable { onClick() },
elevation = 4.dp
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(date.date, style = MaterialTheme.typography.h6)
if (date.trainingStart != null) {
Text("${date.trainingStart} - ${date.trainingEnd ?: ""}")
}
}
}
}
}

View File

@@ -0,0 +1,41 @@
package de.tt_tagebuch.app.ui
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Person
import androidx.compose.runtime.*
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.Navigator
class HomeScreen(private val clubId: Int) : Screen {
@Composable
override fun Content() {
var selectedItem by remember { mutableStateOf(0) }
val titles = listOf("Tagebuch", "Mitglieder")
val icons = listOf(Icons.Default.DateRange, Icons.Default.Person)
Scaffold(
bottomBar = {
BottomNavigation {
titles.forEachIndexed { index, title ->
BottomNavigationItem(
icon = { Icon(icons[index], contentDescription = title) },
label = { Text(title) },
selected = selectedItem == index,
onClick = { selectedItem = index }
)
}
}
}
) { padding ->
Box(modifier = androidx.compose.foundation.layout.padding(padding)) {
when (selectedItem) {
0 -> DiaryScreen(clubId).Content()
1 -> MemberScreen(clubId).Content()
}
}
}
}
}

View File

@@ -0,0 +1,79 @@
package de.tt_tagebuch.app.ui
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.getScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import de.tt_tagebuch.app.viewmodel.LoginScreenModel
import de.tt_tagebuch.app.viewmodel.LoginState
import de.tt_tagebuch.app.ui.HomeScreen
class LoginScreen : Screen {
@Composable
override fun Content() {
val navigator = LocalNavigator.current
val viewModel = getScreenModel<LoginScreenModel>()
val state by viewModel.state.collectAsState()
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Trainingstagebuch", style = MaterialTheme.typography.h4)
Spacer(modifier = Modifier.height(32.dp))
TextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
TextField(
value = password,
onValueChange = { password = it },
label = { Text("Passwort") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = { viewModel.login(email, password) },
enabled = state !is LoginState.Loading,
modifier = Modifier.fillMaxWidth()
) {
if (state is LoginState.Loading) {
CircularProgressIndicator(color = MaterialTheme.colors.onPrimary)
} else {
Text("Login")
}
}
if (state is LoginState.Error) {
Text(
text = (state as LoginState.Error).message,
color = MaterialTheme.colors.error,
modifier = Modifier.padding(top = 16.dp)
)
}
LaunchedEffect(state) {
if (state is LoginState.Success) {
navigator?.push(HomeScreen(1)) // Using clubId 1 for now
}
}
}
}
}

View File

@@ -0,0 +1,144 @@
package de.tt_tagebuch.app.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Phone
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
import de.tt_tagebuch.shared.models.Member
class MemberDetailScreen(private val member: Member) : Screen {
@Composable
override fun Content() {
val navigator = LocalNavigator.current
Scaffold(
topBar = {
TopAppBar(
title = { Text("${member.firstName} ${member.lastName}") },
navigationIcon = {
IconButton(onClick = { navigator?.pop() }) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = {
navigator?.push(MemberEditScreen(member.clubId, member))
}) {
Icon(androidx.compose.material.icons.filled.Edit, contentDescription = "Edit")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
// Header with Status Badges
Row(verticalAlignment = Alignment.CenterVertically) {
if (member.active) {
StatusBadge("Aktiv", Color(0xFF4CAF50))
} else {
StatusBadge("Inaktiv", Color.Red)
}
if (member.testMembership) {
Spacer(Modifier.width(8.dp))
StatusBadge("Test", Color.Orange)
}
}
Spacer(Modifier.height(16.dp))
// TTR Info
Card(elevation = 2.dp, modifier = Modifier.fillMaxWidth()) {
Row(modifier = Modifier.padding(16.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Column {
Text("TTR", style = MaterialTheme.typography.caption)
Text("${member.ttr ?: "-"}", style = MaterialTheme.typography.h5)
}
Column {
Text("QTTR", style = MaterialTheme.typography.caption)
Text("${member.qttr ?: "-"}", style = MaterialTheme.typography.h5)
}
Column {
Text("Alter", style = MaterialTheme.typography.caption)
Text(member.birthDate ?: "-", style = MaterialTheme.typography.h5)
}
}
}
Spacer(Modifier.height(24.dp))
// Contact Details
Text("Kontaktdaten", style = MaterialTheme.typography.h6)
member.contacts.forEach { contact ->
ContactRow(contact)
}
if (member.contacts.isEmpty()) {
Text("Keine Kontaktdaten hinterlegt", style = MaterialTheme.typography.body2, color = Color.Gray)
}
Spacer(Modifier.height(24.dp))
// Attendance Info
Text("Statistik", style = MaterialTheme.typography.h6)
Text("Trainingsteilnahmen: ${member.trainingParticipations}", style = MaterialTheme.typography.body1)
Text("Zuletzt im Training: ${member.lastTraining ?: "Nie"}", style = MaterialTheme.typography.body1)
}
}
}
@Composable
fun StatusBadge(text: String, color: Color) {
Surface(
color = color.copy(alpha = 0.1f),
shape = MaterialTheme.shapes.small,
border = androidx.compose.foundation.BorderStroke(1.dp, color)
) {
Text(
text = text,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp),
style = MaterialTheme.typography.caption,
color = color
)
}
}
@Composable
fun ContactRow(contact: de.tt_tagebuch.shared.models.MemberContact) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
if (contact.type == "phone") Icons.Default.Phone else Icons.Default.Email,
contentDescription = null,
tint = MaterialTheme.colors.primary,
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(16.dp))
Column {
Text(contact.value, style = MaterialTheme.typography.body1)
if (contact.isParent) {
Text("Elternteil${if (contact.parentName != null) ": ${contact.parentName}" else ""}",
style = MaterialTheme.typography.caption, color = Color.Gray)
}
}
}
}
}

View File

@@ -0,0 +1,159 @@
package de.tt_tagebuch.app.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.getScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import de.tt_tagebuch.app.viewmodel.MemberEditScreenModel
import de.tt_tagebuch.app.viewmodel.MemberEditState
import de.tt_tagebuch.shared.models.Member
import de.tt_tagebuch.shared.models.MemberContact
class MemberEditScreen(private val clubId: Int, private val member: Member? = null) : Screen {
@Composable
override fun Content() {
val navigator = LocalNavigator.current
val viewModel = getScreenModel<MemberEditScreenModel>()
val state by viewModel.state.collectAsState()
var firstName by remember { mutableStateOf(member?.firstName ?: "") }
var lastName by remember { mutableStateOf(member?.lastName ?: "") }
var birthDate by remember { mutableStateOf(member?.birthDate ?: "") }
var gender by remember { mutableStateOf(member?.gender ?: "unknown") }
var active by remember { mutableStateOf(member?.active ?: true) }
var testMembership by remember { mutableStateOf(member?.testMembership ?: true) }
var contacts by remember { mutableStateOf(member?.contacts?.toMutableList() ?: mutableListOf()) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (member == null) "Neues Mitglied" else "Mitglied bearbeiten") },
navigationIcon = {
IconButton(onClick = { navigator?.pop() }) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
TextButton(onClick = {
val newMember = Member(
id = member?.id ?: 0,
firstName = firstName,
lastName = lastName,
clubId = clubId,
active = active,
birthDate = birthDate,
gender = gender,
testMembership = testMembership,
contacts = contacts
)
viewModel.saveMember(clubId, newMember)
}, enabled = state !is MemberEditState.Saving) {
Text("Speichern", color = Color.White)
}
}
)
}
) { padding ->
if (state is MemberEditState.Saving) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
Column(
modifier = Modifier
.padding(padding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp)
) {
Text("Stammdaten", style = MaterialTheme.typography.h6)
TextField(
value = firstName,
onValueChange = { firstName = it },
label = { Text("Vorname") },
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(8.dp))
TextField(
value = lastName,
onValueChange = { lastName = it },
label = { Text("Nachname") },
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(8.dp))
TextField(
value = birthDate,
onValueChange = { birthDate = it },
label = { Text("Geburtsdatum (YYYY-MM-DD)") },
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(16.dp))
Text("Status", style = MaterialTheme.typography.h6)
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = active, onCheckedChange = { active = it })
Text("Aktiv")
Spacer(Modifier.width(16.dp))
Checkbox(checked = testMembership, onCheckedChange = { testMembership = it })
Text("Probetraining")
}
Spacer(Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Kontakte", style = MaterialTheme.typography.h6)
IconButton(onClick = {
contacts = (contacts + MemberContact(type = "phone", value = "")).toMutableList()
}) {
Icon(Icons.Default.Add, contentDescription = "Add Contact")
}
}
contacts.forEachIndexed { index, contact ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = contact.value,
onValueChange = { newValue ->
val newList = contacts.toMutableList()
newList[index] = contact.copy(value = newValue)
contacts = newList
},
label = { Text(if (contact.type == "phone") "Telefon" else "Email") },
modifier = Modifier.weight(1f)
)
IconButton(onClick = {
val newList = contacts.toMutableList()
newList.removeAt(index)
contacts = newList
}) {
Icon(Icons.Default.Delete, contentDescription = "Remove")
}
}
}
}
LaunchedEffect(state) {
if (state is MemberEditState.Success) {
navigator?.pop()
}
}
}
}
}

View File

@@ -0,0 +1,80 @@
package de.tt_tagebuch.app.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.getScreenModel
import de.tt_tagebuch.app.viewmodel.MemberScreenModel
import de.tt_tagebuch.app.viewmodel.MemberState
import cafe.adriel.voyager.navigator.LocalNavigator
class MemberScreen(private val clubId: Int) : Screen {
@Composable
override fun Content() {
val navigator = LocalNavigator.current
val viewModel = getScreenModel<MemberScreenModel>()
val state by viewModel.state.collectAsState()
LaunchedEffect(clubId) {
viewModel.loadMembers(clubId)
}
Scaffold(
topBar = {
TopAppBar(title = { Text("Mitglieder") })
},
floatingActionButton = {
FloatingActionButton(onClick = {
navigator?.push(MemberEditScreen(clubId))
}) {
Icon(Icons.Default.Add, contentDescription = "Add Member")
}
}
) { padding ->
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
when (state) {
is MemberState.Loading -> {
CircularProgressIndicator(modifier = Modifier.padding(16.dp))
}
is MemberState.Success -> {
val members = (state as MemberState.Success).members
LazyColumn {
items(members) { member ->
MemberItem(member) {
navigator?.push(MemberDetailScreen(member))
}
}
}
}
is MemberState.Error -> {
Text(
text = (state as MemberState.Error).message,
color = MaterialTheme.colors.error,
modifier = Modifier.padding(16.dp)
)
}
}
}
}
}
@Composable
fun MemberItem(member: de.tt_tagebuch.shared.models.Member, onClick: () -> Unit) {
ListItem(
modifier = Modifier.clickable { onClick() },
text = { Text("${member.firstName} ${member.lastName}") },
secondaryText = { if (!member.active) Text("Inaktiv", color = MaterialTheme.colors.error) }
)
Divider()
}
}

View File

@@ -0,0 +1,117 @@
package de.tt_tagebuch.app.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.getScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import de.tt_tagebuch.app.viewmodel.ParticipantScreenModel
import de.tt_tagebuch.app.viewmodel.ParticipantState
class ParticipantScreen(private val dateId: Int, private val dateStr: String) : Screen {
@Composable
override fun Content() {
val navigator = LocalNavigator.current
val viewModel = getScreenModel<ParticipantScreenModel>()
val state by viewModel.state.collectAsState()
LaunchedEffect(dateId) {
viewModel.loadParticipants(dateId)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Teilnehmer - $dateStr") },
navigationIcon = {
IconButton(onClick = { navigator?.pop() }) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(modifier = Modifier.padding(padding).fillMaxSize()) {
when (state) {
is ParticipantState.Loading -> {
CircularProgressIndicator(modifier = Modifier.padding(16.dp))
}
is ParticipantState.Success -> {
val participants = (state as ParticipantState.Success).participants
LazyColumn {
items(participants) { participant ->
ParticipantItem(participant) { newStatus ->
viewModel.updateStatus(dateId, participant.memberId, newStatus)
}
}
}
}
is ParticipantState.Error -> {
Text(
text = (state as ParticipantState.Error).message,
color = MaterialTheme.colors.error,
modifier = Modifier.padding(16.dp)
)
}
}
}
}
}
@Composable
fun ParticipantItem(
participant: de.tt_tagebuch.shared.models.Participant,
onStatusChange: (String) -> Unit
) {
val member = participant.member
val name = if (member != null) "${member.firstName} ${member.lastName}" else "Unbekannt"
Card(modifier = Modifier.fillMaxWidth().padding(8.dp), elevation = 2.dp) {
Column(modifier = Modifier.padding(16.dp)) {
Text(name, style = MaterialTheme.typography.h6)
Spacer(Modifier.height(8.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
StatusToggle("Anwesend", participant.attendanceStatus == "present", Color(0xFF4CAF50)) {
onStatusChange("present")
}
StatusToggle("Entschuldigt", participant.attendanceStatus == "excused", Color(0xFF2196F3)) {
onStatusChange("excused")
}
StatusToggle("Abgesagt", participant.attendanceStatus == "cancelled", Color.Red) {
onStatusChange("cancelled")
}
}
}
}
}
@Composable
fun StatusToggle(label: String, isSelected: Boolean, color: Color, onClick: () -> Unit) {
val backgroundColor = if (isSelected) color else color.copy(alpha = 0.1f)
val contentColor = if (isSelected) Color.White else color
Button(
onClick = onClick,
colors = ButtonDefaults.buttonColors(
backgroundColor = backgroundColor,
contentColor = contentColor
),
elevation = ButtonDefaults.elevation(0.dp),
shape = MaterialTheme.shapes.small,
modifier = Modifier.height(32.dp).padding(horizontal = 4.dp),
contentPadding = PaddingValues(horizontal = 8.dp)
) {
Text(label, style = MaterialTheme.typography.caption)
}
}
}

View File

@@ -0,0 +1,44 @@
package de.tt_tagebuch.app.viewmodel
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import de.tt_tagebuch.shared.models.DiaryDate
import de.tt_tagebuch.shared.repository.DiaryRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class DiaryScreenModel(private val diaryRepository: DiaryRepository) : ScreenModel {
private val _state = MutableStateFlow<DiaryState>(DiaryState.Loading)
val state = _state.asStateFlow()
fun loadDiaryDates(clubId: Int) {
screenModelScope.launch {
try {
val dates = diaryRepository.getDiaryDates(clubId)
_state.value = DiaryState.Success(dates)
diaryRepository.connectSocket(clubId)
// Observe socket events
diaryRepository.observeEvents().collect { (event, data) ->
// Handle real-time updates here
// For now, just reload to keep it simple
loadDiaryDates(clubId)
}
} catch (e: Exception) {
_state.value = DiaryState.Error(e.message ?: "Failed to load diary")
}
}
}
override fun onDispose() {
diaryRepository.disconnectSocket()
super.onDispose()
}
}
sealed class DiaryState {
object Loading : DiaryState()
data class Success(val dates: List<DiaryDate>) : DiaryState()
data class Error(val message: String) : DiaryState()
}

View File

@@ -0,0 +1,36 @@
package de.tt_tagebuch.app.viewmodel
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import de.tt_tagebuch.shared.repository.AuthRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class LoginScreenModel(private val authRepository: AuthRepository) : ScreenModel {
private val _state = MutableStateFlow<LoginState>(LoginState.Idle)
val state = _state.asStateFlow()
fun login(email: String, password: String) {
screenModelScope.launch {
_state.value = LoginState.Loading
try {
val response = authRepository.login(email, password)
if (response.success) {
_state.value = LoginState.Success
} else {
_state.value = LoginState.Error(response.error ?: "Unknown error")
}
} catch (e: Exception) {
_state.value = LoginState.Error(e.message ?: "Network error")
}
}
}
}
sealed class LoginState {
object Idle : LoginState()
object Loading : LoginState()
object Success : LoginState()
data class Error(val message: String) : LoginState()
}

View File

@@ -0,0 +1,37 @@
package de.tt_tagebuch.app.viewmodel
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import de.tt_tagebuch.shared.models.Member
import de.tt_tagebuch.shared.repository.MemberRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class MemberEditScreenModel(private val memberRepository: MemberRepository) : ScreenModel {
private val _state = MutableStateFlow<MemberEditState>(MemberEditState.Idle)
val state = _state.asStateFlow()
fun saveMember(clubId: Int, member: Member) {
screenModelScope.launch {
_state.value = MemberEditState.Saving
try {
val success = memberRepository.saveMember(clubId, member)
if (success) {
_state.value = MemberEditState.Success
} else {
_state.value = MemberEditState.Error("Fehler beim Speichern")
}
} catch (e: Exception) {
_state.value = MemberEditState.Error(e.message ?: "Netzwerkfehler")
}
}
}
}
sealed class MemberEditState {
object Idle : MemberEditState()
object Saving : MemberEditState()
object Success : MemberEditState()
data class Error(val message: String) : MemberEditState()
}

View File

@@ -0,0 +1,34 @@
package de.tt_tagebuch.app.viewmodel
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import de.tt_tagebuch.shared.models.Member
import de.tt_tagebuch.shared.repository.DiaryRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class MemberScreenModel(private val diaryRepository: DiaryRepository) : ScreenModel {
// Note: Reusing DiaryRepository as it likely handles club-related data,
// but in a larger app we would have a MemberRepository.
private val _state = MutableStateFlow<MemberState>(MemberState.Loading)
val state = _state.asStateFlow()
fun loadMembers(clubId: Int) {
screenModelScope.launch {
try {
val members = diaryRepository.getMembers(clubId)
_state.value = MemberState.Success(members)
} catch (e: Exception) {
_state.value = MemberState.Error(e.message ?: "Failed to load members")
}
}
}
}
sealed class MemberState {
object Loading : MemberState()
data class Success(val members: List<Member>) : MemberState()
data class Error(val message: String) : MemberState()
}

View File

@@ -0,0 +1,46 @@
package de.tt_tagebuch.app.viewmodel
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import de.tt_tagebuch.shared.models.Participant
import de.tt_tagebuch.shared.repository.DiaryRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class ParticipantScreenModel(private val diaryRepository: DiaryRepository) : ScreenModel {
private val _state = MutableStateFlow<ParticipantState>(ParticipantState.Loading)
val state = _state.asStateFlow()
fun loadParticipants(dateId: Int) {
screenModelScope.launch {
_state.value = ParticipantState.Loading
try {
val participants = diaryRepository.getParticipants(dateId)
_state.value = ParticipantState.Success(participants)
} catch (e: Exception) {
_state.value = ParticipantState.Error(e.message ?: "Failed to load participants")
}
}
}
fun updateStatus(dateId: Int, memberId: Int, status: String) {
screenModelScope.launch {
try {
val success = diaryRepository.updateParticipantStatus(dateId, memberId, status)
if (success) {
// Refresh list after update
loadParticipants(dateId)
}
} catch (e: Exception) {
// Handle error
}
}
}
}
sealed class ParticipantState {
object Loading : ParticipantState()
data class Success(val participants: List<Participant>) : ParticipantState()
data class Error(val message: String) : ParticipantState()
}

View File

@@ -0,0 +1,34 @@
# Project-wide Gradle settings.
# For more details on how to configure your build, see
# http://www.gradle.org/docs/current/userguide/build_setting.html
# 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, see
# http://www.gradle.org/docs/current/userguide/build_setting.html
# The Gradle daemon will run in the background.
# Default is true.
org.gradle.daemon=true
# The Gradle daemon will be started with 4096MB of memory.
# Default is 512MB.
org.gradle.jvmargs=-Xmx4096m
# When set to true the Gradle daemon will reuse any existing idle daemon.
# Default is true.
org.gradle.parallel=true
# When set to true, Gradle will attempt to configure only necessary projects.
# Default is false.
org.gradle.configureondemand=true
# When set to true, Gradle will use the configuration cache.
# Default is false.
org.gradle.configuration-cache=true
kotlin.code.style=official
android.useAndroidX=true
android.nonTransitiveRClass=true

View File

@@ -0,0 +1,48 @@
[versions]
agp = "8.2.2"
android-compileSdk = "34"
android-minSdk = "24"
android-targetSdk = "34"
androidx-activityCompose = "1.8.2"
androidx-appcompat = "1.6.1"
androidx-constraintlayout = "2.1.4"
androidx-core-ktx = "1.12.0"
androidx-espresso-core = "3.5.1"
androidx-material = "1.11.0"
androidx-test-junit = "1.1.5"
compose = "1.6.1"
compose-plugin = "1.6.1"
junit = "4.13.2"
kotlin = "1.9.23"
ktor = "2.3.10"
coroutines = "1.8.0"
koin = "3.5.3"
voyager = "1.0.0"
socket-io = "2.1.0"
[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
voyager-navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" }
voyager-screenmodel = { module = "cafe.adriel.voyager:voyager-screenmodel", version.ref = "voyager" }
voyager-transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" }
voyager-koin = { module = "cafe.adriel.voyager:voyager-koin", version.ref = "voyager" }
socket-io-client = { module = "io.socket:socket.io-client", version.ref = "socket-io" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

View File

@@ -0,0 +1,20 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
maven("https://maven.pkg.jetbrains.space/public/p/compose/patch")
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven("https://maven.pkg.jetbrains.space/public/p/compose/patch")
}
}
rootProject.name = "Trainingstagebuch"
include(":composeApp")
include(":shared")

View File

@@ -0,0 +1,37 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
alias(libs.plugins.kotlinSerialization)
}
kotlin {
androidTarget {
compilations.all {
kotlinOptions {
jvmTarget = "1.8"
}
}
}
sourceSets {
commonMain.dependencies {
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.koin.core)
implementation(libs.socket.io.client)
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
}
}
android {
namespace = "de.tt_tagebuch.shared"
compileSdk = libs.versions.android.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.android.minSdk.get().toInt()
}
}

View File

@@ -0,0 +1,73 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.models.*
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
class ApiClient(private val baseUrl: String) {
private val client = HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
coerceInputValues = true
})
}
}
private var token: String? = null
fun setToken(token: String?) {
this.token = token
}
suspend fun login(email: String, password: String): LoginResponse {
return client.post("$baseUrl/api/auth/login") {
setBody(mapOf("email" to email, "password" to password))
header("Content-Type", "application/json")
}.body()
}
suspend fun getClubs(): List<Club> {
return client.get("$baseUrl/api/clubs") {
token?.let { header("Authorization", "Bearer $it") }
}.body()
}
suspend fun getDiaryDates(clubId: Int): List<DiaryDate> {
return client.get("$baseUrl/api/diary/$clubId") {
token?.let { header("Authorization", "Bearer $it") }
}.body()
}
suspend fun getDiaryParticipants(dateId: Int): List<Participant> {
return client.get("$baseUrl/api/participants/$dateId") {
token?.let { header("Authorization", "Bearer $it") }
}.body()
}
suspend fun updateParticipantStatus(dateId: Int, memberId: Int, status: String): Boolean {
return client.put("$baseUrl/api/participants/$dateId/$memberId/status") {
setBody(mapOf("status" to status))
header("Content-Type", "application/json")
token?.let { header("Authorization", "Bearer $it") }
}.status.value in 200..299
}
suspend fun getMembers(clubId: Int): List<Member> {
return client.get("$baseUrl/api/clubmembers/get/$clubId/true") {
token?.let { header("Authorization", "Bearer $it") }
}.body()
}
suspend fun updateMember(clubId: Int, member: Member): Boolean {
return client.post("$baseUrl/api/clubmembers/set/$clubId") {
setBody(member)
header("Content-Type", "application/json")
token?.let { header("Authorization", "Bearer $it") }
}.status.value in 200..299
}
}

View File

@@ -0,0 +1,47 @@
package de.tt_tagebuch.shared.api
import io.socket.client.IO
import io.socket.client.Socket
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import org.json.JSONObject
class SocketService(private val socketUrl: String) {
private var socket: Socket? = null
private val _events = MutableSharedFlow<Pair<String, JSONObject>>()
val events = _events.asSharedFlow()
fun connect(clubId: Int) {
val options = IO.Options().apply {
path = "/socket.io/"
transports = arrayOf("polling", "websocket")
}
socket = IO.socket(socketUrl, options)
socket?.on(Socket.EVENT_CONNECT) {
println("✅ Connected to Socket.IO")
socket?.emit("join-club", clubId)
}
socket?.on("participant:added") { args ->
val data = args[0] as JSONObject
_events.tryEmit("participant:added" to data)
}
socket?.on("diary:note:added") { args ->
val data = args[0] as JSONObject
_events.tryEmit("diary:note:added" to data)
}
// Add more events as needed
socket?.connect()
}
fun disconnect() {
socket?.disconnect()
socket = null
}
}

View File

@@ -0,0 +1,27 @@
package de.tt_tagebuch.shared.di
import de.tt_tagebuch.shared.api.ApiClient
import de.tt_tagebuch.shared.api.SocketService
import de.tt_tagebuch.shared.repository.AuthRepository
import de.tt_tagebuch.shared.repository.DiaryRepository
import de.tt_tagebuch.shared.repository.MemberRepository
import org.koin.core.context.startKoin
import org.koin.dsl.KoinAppDeclaration
import org.koin.dsl.module
import org.koin.core.module.Module
import org.koin.dsl.module
fun initKoin(baseUrl: String, additionalModules: List<Module> = emptyList(), appDeclaration: KoinAppDeclaration = {}) =
startKoin {
appDeclaration()
modules(commonModule(baseUrl) + additionalModules)
}
fun commonModule(baseUrl: String) = module {
single { ApiClient(baseUrl) }
single { SocketService(baseUrl.replace("https://", "wss://").replace("http://", "ws://")) } // Simplified
single { AuthRepository(get()) }
single { DiaryRepository(get(), get()) }
single { MemberRepository(get()) }
}

View File

@@ -0,0 +1,96 @@
package de.tt_tagebuch.shared.models
import kotlinx.serialization.Serializable
@Serializable
data class User(
val id: Int,
val email: String,
val isActive: Boolean
)
@Serializable
data class LoginResponse(
val success: Boolean,
val token: String? = null,
val user: User? = null,
val error: String? = null
)
@Serializable
data class Club(
val id: Int,
val name: String,
val greetingText: String? = null
)
@Serializable
data class DiaryDate(
val id: Int,
val date: String,
val clubId: Int,
val trainingStart: String? = null,
val trainingEnd: String? = null,
val activities: List<DiaryActivity> = emptyList(),
val participants: List<Participant> = emptyList()
)
@Serializable
data class Member(
val id: Int,
val firstName: String,
val lastName: String,
val clubId: Int,
val active: Boolean = true,
val birthDate: String? = null,
val gender: String? = null,
val ttr: Int? = null,
val qttr: Int? = null,
val testMembership: Boolean = false,
val picsInInternetAllowed: Boolean = false,
val memberFormHandedOver: Boolean = false,
val adultReleaseApproved: Boolean = false,
val adultReserveApproved: Boolean = false,
val lastTraining: String? = null,
val trainingParticipations: Int = 0,
val notInTraining: Boolean = false,
val missedTrainingWeeks: Int = 0,
val latestImageUrl: String? = null,
val contacts: List<MemberContact> = emptyList()
)
@Serializable
data class MemberContact(
val id: Int? = null,
val type: String, // "phone" or "email"
val value: String,
val isParent: Boolean = false,
val parentName: String? = null,
val isPrimary: Boolean = false
)
@Serializable
data class Participant(
val id: Int,
val memberId: Int,
val diaryDateId: Int,
val attendanceStatus: String = "present", // "present", "excused", "cancelled"
val notes: String? = null,
val groupId: Int? = null,
val member: Member? = null
)
@Serializable
data class DiaryActivity(
val id: Int,
val diaryDateId: Int,
val description: String
)
@Serializable
data class DiaryNote(
val id: Int,
val diaryDateId: Int,
val content: String,
val userId: Int
)

View File

@@ -0,0 +1,18 @@
package de.tt_tagebuch.shared.repository
import de.tt_tagebuch.shared.api.ApiClient
import de.tt_tagebuch.shared.models.LoginResponse
class AuthRepository(private val apiClient: ApiClient) {
suspend fun login(email: String, password: String): LoginResponse {
val response = apiClient.login(email, password)
if (response.success && response.token != null) {
apiClient.setToken(response.token)
}
return response
}
fun logout() {
apiClient.setToken(null)
}
}

View File

@@ -0,0 +1,40 @@
package de.tt_tagebuch.shared.repository
import de.tt_tagebuch.shared.api.ApiClient
import de.tt_tagebuch.shared.api.SocketService
import de.tt_tagebuch.shared.models.DiaryDate
import kotlinx.coroutines.flow.Flow
import org.json.JSONObject
class DiaryRepository(
private val apiClient: ApiClient,
private val socketService: SocketService
) {
suspend fun getDiaryDates(clubId: Int): List<DiaryDate> {
return apiClient.getDiaryDates(clubId)
}
suspend fun getParticipants(dateId: Int): List<de.tt_tagebuch.shared.models.Participant> {
return apiClient.getDiaryParticipants(dateId)
}
suspend fun updateParticipantStatus(dateId: Int, memberId: Int, status: String): Boolean {
return apiClient.updateParticipantStatus(dateId, memberId, status)
}
suspend fun getMembers(clubId: Int): List<de.tt_tagebuch.shared.models.Member> {
return apiClient.getMembers(clubId)
}
fun observeEvents(): Flow<Pair<String, JSONObject>> {
return socketService.events
}
fun connectSocket(clubId: Int) {
socketService.connect(clubId)
}
fun disconnectSocket() {
socketService.disconnect()
}
}

View File

@@ -0,0 +1,23 @@
package de.tt_tagebuch.shared.repository
import de.tt_tagebuch.shared.api.ApiClient
import de.tt_tagebuch.shared.models.Member
import de.tt_tagebuch.shared.models.MemberContact
class MemberRepository(private val apiClient: ApiClient) {
suspend fun getMembers(clubId: Int): List<Member> {
return apiClient.getMembers(clubId)
}
suspend fun saveMember(clubId: Int, member: Member): Boolean {
return apiClient.updateMember(clubId, member)
}
// Für den Upload von Bildern würden wir in einer realen KMP App
// plattformspezifische Implementierungen für Multipart-Form-Data nutzen.
// Hier bereiten wir die Schnittstelle vor.
suspend fun uploadMemberImage(clubId: Int, memberId: Int, imageByteArray: ByteArray): Boolean {
// Implementierung folgt über ApiClient
return true
}
}