chore: remove obsolete Android app configuration files
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
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:
9
mobile-app/build.gradle.kts
Normal file
9
mobile-app/build.gradle.kts
Normal 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
|
||||
}
|
||||
68
mobile-app/composeApp/build.gradle.kts
Normal file
68
mobile-app/composeApp/build.gradle.kts
Normal 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)
|
||||
}
|
||||
25
mobile-app/composeApp/src/androidMain/AndroidManifest.xml
Normal file
25
mobile-app/composeApp/src/androidMain/AndroidManifest.xml
Normal 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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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()) }
|
||||
}
|
||||
@@ -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 ?: ""}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
34
mobile-app/gradle.properties
Normal file
34
mobile-app/gradle.properties
Normal 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
|
||||
48
mobile-app/gradle/libs.versions.toml
Normal file
48
mobile-app/gradle/libs.versions.toml
Normal 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" }
|
||||
20
mobile-app/settings.gradle.kts
Normal file
20
mobile-app/settings.gradle.kts
Normal 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")
|
||||
37
mobile-app/shared/build.gradle.kts
Normal file
37
mobile-app/shared/build.gradle.kts
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()) }
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user