chore: update .gitignore and enhance backend and mobile app functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s

- Added mobile app build directories and configuration files to .gitignore for cleaner repository management.
- Improved error handling in diaryMemberController by requiring diaryDateId and memberId query parameters.
- Refactored DiaryMemberService to log tag IDs instead of raw values for better debugging.
- Enhanced TournamentParticipantsTab and TournamentTab components with improved touch-action properties for better user experience.
- Updated mobile app's gradle.properties and build.gradle.kts for compatibility with AGP 9.x and Kotlin 2.1.21, including new dependencies for Coil and UCrop.
- Refactored MainApplication to simplify initialization and improved MainActivity to handle dependencies more robustly.
- Updated various UI components in the mobile app to enhance layout and functionality, including MemberDetailScreen and MemberEditScreen.
This commit is contained in:
Torsten Schulz (local)
2026-05-12 23:14:31 +02:00
parent 27f8af559b
commit 48f71b9df1
138 changed files with 54488 additions and 56 deletions

View File

@@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidLibrary)
@@ -7,11 +9,23 @@ plugins {
kotlin {
androidTarget {
compilations.all {
kotlinOptions {
jvmTarget = "1.8"
compilerOptions.configure {
jvmTarget.set(JvmTarget.JVM_17)
}
}
}
val iosTargets = listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64(),
)
iosTargets.forEach {
it.binaries.framework {
baseName = "TTShared"
isStatic = true
}
}
sourceSets {
commonMain.dependencies {
@@ -24,6 +38,10 @@ kotlin {
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
implementation(libs.androidx.security.crypto)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
}
}

View File

@@ -0,0 +1,9 @@
package de.tt_tagebuch.shared.api.http
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.okhttp.OkHttp
class AndroidHttpClientEngineFactory : HttpClientEngineFactory {
override fun create(): HttpClientEngine = OkHttp.create()
}

View File

@@ -0,0 +1,38 @@
package de.tt_tagebuch.shared.state
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class AndroidClubStorage(
context: Context,
) : ClubStorage {
private val prefs = EncryptedSharedPreferences.create(
context,
PREFS_NAME,
MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
override suspend fun loadCurrentClubId(): Int? = withContext(Dispatchers.Default) {
val value = prefs.getString(KEY_CURRENT_CLUB_ID, null) ?: return@withContext null
value.toIntOrNull()
}
override suspend fun saveCurrentClubId(clubId: Int?) = withContext(Dispatchers.Default) {
val editor = prefs.edit()
if (clubId == null) editor.remove(KEY_CURRENT_CLUB_ID) else editor.putString(KEY_CURRENT_CLUB_ID, clubId.toString())
editor.apply()
}
private companion object {
private const val PREFS_NAME = "tttagebuch_club"
private const val KEY_CURRENT_CLUB_ID = "currentClubId"
}
}

View File

@@ -0,0 +1,24 @@
package de.tt_tagebuch.shared.state
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class AndroidLanguageStorage(
context: Context,
) : LanguageStorage {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
override suspend fun loadLanguageCode(): String? = withContext(Dispatchers.Default) {
prefs.getString(KEY_LANGUAGE_CODE, null)
}
override suspend fun saveLanguageCode(languageCode: String) = withContext(Dispatchers.Default) {
prefs.edit().putString(KEY_LANGUAGE_CODE, languageCode).apply()
}
private companion object {
private const val PREFS_NAME = "tttagebuch_language"
private const val KEY_LANGUAGE_CODE = "languageCode"
}
}

View File

@@ -0,0 +1,49 @@
package de.tt_tagebuch.shared.state
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class AndroidTokenStorage(
context: Context,
) : TokenStorage {
private val prefs = EncryptedSharedPreferences.create(
context,
PREFS_NAME,
MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
override suspend fun load(): AuthTokens? = withContext(Dispatchers.Default) {
val token = prefs.getString(KEY_TOKEN, null)
val username = prefs.getString(KEY_USERNAME, null)
if (token.isNullOrBlank() || username.isNullOrBlank()) null else AuthTokens(token, username)
}
override suspend fun save(tokens: AuthTokens) = withContext(Dispatchers.Default) {
prefs.edit()
.putString(KEY_TOKEN, tokens.token)
.putString(KEY_USERNAME, tokens.username)
.apply()
}
override suspend fun clear() = withContext(Dispatchers.Default) {
prefs.edit()
.remove(KEY_TOKEN)
.remove(KEY_USERNAME)
.apply()
}
private companion object {
private const val PREFS_NAME = "tttagebuch_auth"
private const val KEY_TOKEN = "token"
private const val KEY_USERNAME = "username"
}
}

View File

@@ -0,0 +1,23 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.AccidentReportDto
import de.tt_tagebuch.shared.api.models.CreateAccidentBody
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
class AccidentApi(
private val client: AuthedHttpClient,
) {
suspend fun list(clubId: Int, diaryDateId: Int): List<AccidentReportDto> {
return client.http.get("/api/accident/$clubId/$diaryDateId").body()
}
suspend fun create(body: CreateAccidentBody) {
client.http.post("/api/accident") {
setBody(body)
}
}
}

View File

@@ -0,0 +1,18 @@
package de.tt_tagebuch.shared.api
data class ApiConfig(
val baseUrl: String,
)
fun ApiConfig.toAbsoluteUrl(relativeOrAbsolute: String): String {
val t = relativeOrAbsolute.trim()
if (t.startsWith("http://", ignoreCase = true) || t.startsWith("https://", ignoreCase = true)) {
return t
}
val base = baseUrl.trimEnd('/')
val path = if (t.startsWith("/")) t else "/$t"
return base + path
}
fun memberProfileImagePath(clubId: Int, memberId: Int) = "/api/clubmembers/image/$clubId/$memberId"

View File

@@ -0,0 +1,23 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.LoginRequest
import de.tt_tagebuch.shared.api.models.LoginResponse
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
class AuthApi(
private val client: AuthedHttpClient,
) {
suspend fun login(email: String, password: String): LoginResponse {
return client.http.post("/api/auth/login") {
setBody(LoginRequest(email = email, password = password))
}.body()
}
suspend fun logout() {
client.http.post("/api/auth/logout")
}
}

View File

@@ -0,0 +1,35 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.Club
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import kotlinx.serialization.Serializable
@Serializable
private data class CreateClubBody(val name: String)
class ClubsApi(
private val client: AuthedHttpClient,
) {
suspend fun listClubs(): List<Club> {
return client.http.get("/api/clubs").body()
}
suspend fun createClub(name: String): Club {
return client.http.post("/api/clubs") {
setBody(CreateClubBody(name.trim()))
}.body()
}
suspend fun getClub(clubId: Int): Club {
return client.http.get("/api/clubs/$clubId").body()
}
suspend fun requestAccess(clubId: Int) {
client.http.get("/api/clubs/request/$clubId")
}
}

View File

@@ -0,0 +1,133 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.AddDiaryNoteRequest
import de.tt_tagebuch.shared.api.models.AddFreeformActivityBody
import de.tt_tagebuch.shared.api.models.AddDiaryPlanGroupActivityRequest
import de.tt_tagebuch.shared.api.models.CreateDiaryDateRequest
import de.tt_tagebuch.shared.api.models.CreateDiaryPlanActivityRequest
import de.tt_tagebuch.shared.api.models.DiaryDate
import de.tt_tagebuch.shared.api.models.DiaryDateActivityItem
import de.tt_tagebuch.shared.api.models.DiaryFreeformActivity
import de.tt_tagebuch.shared.api.models.DiaryNote
import de.tt_tagebuch.shared.api.models.DiaryTag
import de.tt_tagebuch.shared.api.models.LinkDiaryTagRequest
import de.tt_tagebuch.shared.api.models.UpdateDiaryPlanActivityOrderRequest
import de.tt_tagebuch.shared.api.models.UpdateDiaryPlanActivityRequest
import de.tt_tagebuch.shared.api.models.UpdateDiaryTimesRequest
import de.tt_tagebuch.shared.api.models.UpdateNestedPlanGroupActivityRequest
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.put
import io.ktor.client.request.setBody
class DiaryApi(
private val client: AuthedHttpClient,
) {
suspend fun listDates(clubId: Int): List<DiaryDate> {
return client.http.get("/api/diary/$clubId").body()
}
suspend fun listFreeformActivities(diaryDateId: Int): List<DiaryFreeformActivity> {
return client.http.get("/api/activities/$diaryDateId").body()
}
suspend fun addFreeformActivity(diaryDateId: Int, description: String): DiaryFreeformActivity {
return client.http.post("/api/activities/add") {
setBody(AddFreeformActivityBody(diaryDateId = diaryDateId, description = description.trim()))
}.body()
}
suspend fun listDateActivities(clubId: Int, diaryDateId: Int): List<DiaryDateActivityItem> {
return client.http.get("/api/diary-date-activities/$clubId/$diaryDateId").body()
}
suspend fun createDateActivity(clubId: Int, body: CreateDiaryPlanActivityRequest) {
client.http.post("/api/diary-date-activities/$clubId") {
setBody(body)
}
}
suspend fun addDateGroupActivity(body: AddDiaryPlanGroupActivityRequest) {
client.http.post("/api/diary-date-activities/group") {
setBody(body)
}
}
suspend fun updateDateActivity(clubId: Int, activityId: Int, body: UpdateDiaryPlanActivityRequest) {
client.http.put("/api/diary-date-activities/$clubId/$activityId") {
setBody(body)
}
}
suspend fun updateDateActivityOrder(clubId: Int, activityId: Int, orderId: Int) {
client.http.put("/api/diary-date-activities/$clubId/$activityId/order") {
setBody(UpdateDiaryPlanActivityOrderRequest(orderId))
}
}
suspend fun deleteDateActivity(clubId: Int, activityId: Int) {
client.http.delete("/api/diary-date-activities/$clubId/$activityId")
}
suspend fun updateNestedGroupActivity(clubId: Int, groupActivityId: Int, body: UpdateNestedPlanGroupActivityRequest) {
client.http.put("/api/diary-date-activities/group/$clubId/$groupActivityId") {
setBody(body)
}
}
suspend fun deleteNestedGroupActivity(clubId: Int, groupActivityId: Int) {
client.http.delete("/api/diary-date-activities/group/$clubId/$groupActivityId")
}
suspend fun createDate(clubId: Int, date: String, trainingStart: String?, trainingEnd: String?): DiaryDate {
return client.http.post("/api/diary/$clubId") {
setBody(CreateDiaryDateRequest(date, trainingStart, trainingEnd))
}.body()
}
suspend fun updateTimes(clubId: Int, dateId: Int, trainingStart: String?, trainingEnd: String?): DiaryDate {
return client.http.put("/api/diary/$clubId") {
setBody(UpdateDiaryTimesRequest(dateId, trainingStart, trainingEnd))
}.body()
}
suspend fun deleteDate(clubId: Int, dateId: Int) {
client.http.delete("/api/diary/$clubId/$dateId")
}
suspend fun addNote(diaryDateId: Int, content: String): List<DiaryNote> {
return client.http.post("/api/diary/note") {
setBody(AddDiaryNoteRequest(diaryDateId, content))
}.body()
}
suspend fun deleteNote(noteId: Int): List<DiaryNote> {
return client.http.delete("/api/diary/note/$noteId").body()
}
suspend fun createTag(name: String): DiaryTag {
return client.http.post("/api/tags") {
setBody(mapOf("name" to name))
}.body()
}
suspend fun listTags(): List<DiaryTag> {
return client.http.get("/api/tags").body()
}
suspend fun linkTag(clubId: Int, diaryDateId: Int, tagId: Int): List<DiaryTag> {
return client.http.post("/api/diary/tag/$clubId/add-tag") {
setBody(LinkDiaryTagRequest(diaryDateId, tagId))
}.body()
}
suspend fun removeTag(clubId: Int, tagId: Int) {
client.http.delete("/api/diary/$clubId/tag") {
parameter("tagId", tagId)
}
}
}

View File

@@ -0,0 +1,28 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.AddMemberActivityParticipantsBody
import de.tt_tagebuch.shared.api.models.DiaryMemberActivityLink
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
class DiaryMemberActivitiesApi(
private val client: AuthedHttpClient,
) {
suspend fun list(clubId: Int, diaryDateOrGroupActivityId: Int): List<DiaryMemberActivityLink> {
return client.http.get("/api/diary-member-activities/$clubId/$diaryDateOrGroupActivityId").body()
}
suspend fun add(clubId: Int, diaryDateOrGroupActivityId: Int, participantIds: List<Int>) {
client.http.post("/api/diary-member-activities/$clubId/$diaryDateOrGroupActivityId") {
setBody(AddMemberActivityParticipantsBody(participantIds = participantIds))
}
}
suspend fun remove(clubId: Int, diaryDateOrGroupActivityId: Int, participantRowId: Int) {
client.http.delete("/api/diary-member-activities/$clubId/$diaryDateOrGroupActivityId/$participantRowId")
}
}

View File

@@ -0,0 +1,56 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.AddDiaryMemberNoteBody
import de.tt_tagebuch.shared.api.models.DiaryMemberNoteDto
import de.tt_tagebuch.shared.api.models.DiaryMemberTagLinkDto
import de.tt_tagebuch.shared.api.models.DiaryMemberTagMutationBody
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
class DiaryMemberApi(
private val client: AuthedHttpClient,
) {
suspend fun listNotes(clubId: Int, diaryDateId: Int, memberId: Int): List<DiaryMemberNoteDto> {
return client.http.get("/api/diarymember/$clubId/note") {
parameter("diaryDateId", diaryDateId)
parameter("memberId", memberId)
}.body()
}
suspend fun addNote(clubId: Int, body: AddDiaryMemberNoteBody): List<DiaryMemberNoteDto> {
return client.http.post("/api/diarymember/$clubId/note") {
setBody(body)
}.body()
}
suspend fun deleteNote(clubId: Int, noteId: Int, diaryDateId: Int, memberId: Int): List<DiaryMemberNoteDto> {
return client.http.delete("/api/diarymember/$clubId/note/$noteId") {
parameter("diaryDateId", diaryDateId)
parameter("memberId", memberId)
}.body()
}
suspend fun listTags(clubId: Int, diaryDateId: Int, memberId: Int): List<DiaryMemberTagLinkDto> {
return client.http.get("/api/diarymember/$clubId/tag") {
parameter("diaryDateId", diaryDateId)
parameter("memberId", memberId)
}.body()
}
suspend fun addTag(clubId: Int, body: DiaryMemberTagMutationBody): List<DiaryMemberTagLinkDto> {
return client.http.post("/api/diarymember/$clubId/tag") {
setBody(body)
}.body()
}
suspend fun removeTag(clubId: Int, body: DiaryMemberTagMutationBody): List<DiaryMemberTagLinkDto> {
return client.http.post("/api/diarymember/$clubId/tag/remove") {
setBody(body)
}.body()
}
}

View File

@@ -0,0 +1,42 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.CreateTrainingGroupBody
import de.tt_tagebuch.shared.api.models.DeleteTrainingGroupBody
import de.tt_tagebuch.shared.api.models.DiaryPlanGroup
import de.tt_tagebuch.shared.api.models.UpdateTrainingGroupBody
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.put
import io.ktor.client.request.setBody
/**
* Trainingsgruppen am Tagebuch-Tag (`/api/group`), für gruppenbezogene Plan-Einträge.
*/
class GroupApi(
private val client: AuthedHttpClient,
) {
suspend fun listForDiaryDate(clubId: Int, diaryDateId: Int): List<DiaryPlanGroup> {
return client.http.get("/api/group/$clubId/$diaryDateId").body()
}
suspend fun create(body: CreateTrainingGroupBody): DiaryPlanGroup {
return client.http.post("/api/group") {
setBody(body)
}.body()
}
suspend fun update(groupId: Int, body: UpdateTrainingGroupBody): DiaryPlanGroup {
return client.http.put("/api/group/$groupId") {
setBody(body)
}.body()
}
suspend fun delete(groupId: Int, body: DeleteTrainingGroupBody) {
client.http.delete("/api/group/$groupId") {
setBody(body)
}
}
}

View File

@@ -0,0 +1,24 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.MemberActivityStatDto
import de.tt_tagebuch.shared.api.models.MemberLastParticipationDto
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.parameter
class MemberActivitiesApi(
private val client: AuthedHttpClient,
) {
suspend fun listActivityStats(clubId: Int, memberId: Int, period: String = "year"): List<MemberActivityStatDto> {
return client.http.get("/api/member-activities/$clubId/$memberId") {
parameter("period", period)
}.body()
}
suspend fun listLastParticipations(clubId: Int, memberId: Int, limit: Int = 12): List<MemberLastParticipationDto> {
return client.http.get("/api/member-activities/$clubId/$memberId/last-participations") {
parameter("limit", limit)
}.body()
}
}

View File

@@ -0,0 +1,51 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.MemberGroupPhotoDto
import de.tt_tagebuch.shared.api.models.MemberGroupPhotoListResponse
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.forms.MultiPartFormDataContent
import io.ktor.client.request.forms.formData
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.Headers
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
class MemberGroupPhotosApi(
private val client: AuthedHttpClient,
) {
suspend fun list(clubId: Int): List<MemberGroupPhotoDto> {
val res: MemberGroupPhotoListResponse = client.http.get("/api/member-group-photos/$clubId").body()
return res.photos
}
suspend fun upload(clubId: Int, imageBytes: ByteArray, title: String, description: String) {
client.http.post("/api/member-group-photos/$clubId") {
contentType(ContentType.MultiPart.FormData)
setBody(
MultiPartFormDataContent(
formData {
append("title", title.ifBlank { "Gruppenfoto" })
append("description", description)
append(
"image",
imageBytes,
Headers.build {
append(HttpHeaders.ContentType, "image/jpeg")
append(HttpHeaders.ContentDisposition, "filename=\"group.jpg\"")
},
)
},
),
)
}
}
suspend fun delete(clubId: Int, photoId: Int) {
client.http.delete("/api/member-group-photos/$clubId/$photoId")
}
}

View File

@@ -0,0 +1,54 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.Member
import de.tt_tagebuch.shared.api.models.MemberSetBody
import io.ktor.client.call.body
import io.ktor.client.request.forms.formData
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.Headers
import io.ktor.http.HttpHeaders
import io.ktor.http.contentType
import io.ktor.client.request.forms.MultiPartFormDataContent
class MembersApi(
private val client: AuthedHttpClient,
) {
suspend fun listMembers(clubId: Int, showAll: Boolean = true): List<Member> {
return client.http.get("/api/clubmembers/get/$clubId/$showAll").body()
}
suspend fun setMember(clubId: Int, body: MemberSetBody) {
client.http.post("/api/clubmembers/set/$clubId") {
setBody(body)
}
}
suspend fun uploadMemberImage(clubId: Int, memberId: Int, imageBytes: ByteArray, makePrimary: Boolean = true) {
client.http.post("/api/clubmembers/image/$clubId/$memberId") {
if (makePrimary) {
parameter("makePrimary", "true")
}
contentType(ContentType.MultiPart.FormData)
setBody(
MultiPartFormDataContent(
formData {
append(
"image",
imageBytes,
Headers.build {
append(HttpHeaders.ContentType, "image/jpeg")
append(HttpHeaders.ContentDisposition, "filename=\"member.jpg\"")
},
)
},
),
)
}
}
}

View File

@@ -0,0 +1,44 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.DiaryTrainingParticipant
import de.tt_tagebuch.shared.api.models.ParticipantGroupRequest
import de.tt_tagebuch.shared.api.models.ParticipantMutationRequest
import io.ktor.client.call.body
import de.tt_tagebuch.shared.api.models.ParticipantStatusRequest
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.put
import io.ktor.client.request.setBody
class ParticipantsApi(
private val client: AuthedHttpClient,
) {
suspend fun listForDate(diaryDateId: Int): List<DiaryTrainingParticipant> {
return client.http.get("/api/participants/$diaryDateId").body()
}
suspend fun add(diaryDateId: Int, memberId: Int): DiaryTrainingParticipant {
return client.http.post("/api/participants/add") {
setBody(ParticipantMutationRequest(diaryDateId, memberId))
}.body()
}
suspend fun remove(diaryDateId: Int, memberId: Int) {
client.http.post("/api/participants/remove") {
setBody(ParticipantMutationRequest(diaryDateId, memberId))
}
}
suspend fun updateAttendanceStatus(diaryDateId: Int, memberId: Int, attendanceStatus: String) {
client.http.put("/api/participants/$diaryDateId/$memberId/status") {
setBody(ParticipantStatusRequest(attendanceStatus = attendanceStatus))
}
}
suspend fun updateParticipantGroup(diaryDateId: Int, memberId: Int, groupId: Int?) {
client.http.put("/api/participants/$diaryDateId/$memberId/group") {
setBody(ParticipantGroupRequest(groupId = groupId))
}
}
}

View File

@@ -0,0 +1,15 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.UserClubPermissions
import io.ktor.client.call.body
import io.ktor.client.request.get
class PermissionsApi(
private val client: AuthedHttpClient,
) {
suspend fun getUserPermissions(clubId: Int): UserClubPermissions {
return client.http.get("/api/permissions/$clubId").body()
}
}

View File

@@ -0,0 +1,28 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.PredefinedActivityDto
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.parameter
class PredefinedActivitiesApi(
private val client: AuthedHttpClient,
) {
suspend fun list(scope: String? = null): List<PredefinedActivityDto> {
return client.http.get("/api/predefined-activities") {
if (scope != null) parameter("scope", scope)
}.body()
}
suspend fun search(query: String, limit: Int = 20): List<PredefinedActivityDto> {
return client.http.get("/api/predefined-activities/search/query") {
parameter("q", query)
parameter("limit", limit)
}.body()
}
suspend fun getById(id: Int): PredefinedActivityDto {
return client.http.get("/api/predefined-activities/$id").body()
}
}

View File

@@ -0,0 +1,37 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.PublicHttpClient
import de.tt_tagebuch.shared.api.models.ForgotPasswordRequest
import de.tt_tagebuch.shared.api.models.MessageResponse
import de.tt_tagebuch.shared.api.models.RegisterRequest
import de.tt_tagebuch.shared.api.models.ResetPasswordRequest
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
class PublicAuthApi(
private val client: PublicHttpClient,
) {
suspend fun register(email: String, password: String) {
client.http.post("/api/auth/register") {
setBody(RegisterRequest(email = email, password = password))
}
}
suspend fun activate(activationCode: String) {
client.http.get("/api/auth/activate/$activationCode")
}
suspend fun forgotPassword(email: String): MessageResponse {
return client.http.post("/api/auth/forgot-password") {
setBody(ForgotPasswordRequest(email = email))
}.body()
}
suspend fun resetPassword(token: String, password: String): MessageResponse {
return client.http.post("/api/auth/reset-password") {
setBody(ResetPasswordRequest(token = token, password = password))
}.body()
}
}

View File

@@ -0,0 +1,15 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.SessionStatusResponse
import io.ktor.client.call.body
import io.ktor.client.request.get
class SessionApi(
private val client: AuthedHttpClient,
) {
suspend fun status(): SessionStatusResponse {
return client.http.get("/api/session/status").body()
}
}

View File

@@ -0,0 +1,34 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.TrainingGroupDto
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
class TrainingGroupsApi(
private val client: AuthedHttpClient,
) {
suspend fun listGroups(clubId: Int): List<TrainingGroupDto> {
return client.http.get("/api/training-groups/$clubId").body()
}
suspend fun listMemberGroups(clubId: Int, memberId: Int): List<TrainingGroupDto> {
return client.http.get("/api/training-groups/$clubId/member/$memberId").body()
}
suspend fun addMemberToGroup(clubId: Int, groupId: Int, memberId: Int) {
client.http.post("/api/training-groups/$clubId/$groupId/member/$memberId") {
contentType(ContentType.Application.Json)
setBody("{}")
}
}
suspend fun removeMemberFromGroup(clubId: Int, groupId: Int, memberId: Int) {
client.http.delete("/api/training-groups/$clubId/$groupId/member/$memberId")
}
}

View File

@@ -0,0 +1,15 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.TrainingStats
import io.ktor.client.call.body
import io.ktor.client.request.get
class TrainingStatsApi(
private val client: AuthedHttpClient,
) {
suspend fun getStats(clubId: Int): TrainingStats {
return client.http.get("/api/training-stats/$clubId").body()
}
}

View File

@@ -0,0 +1,14 @@
package de.tt_tagebuch.shared.api
import de.tt_tagebuch.shared.api.http.AuthedHttpClient
import de.tt_tagebuch.shared.api.models.TrainingGroupDto
import io.ktor.client.call.body
import io.ktor.client.request.get
class TrainingTimesApi(
private val client: AuthedHttpClient,
) {
suspend fun listGroupsWithTimes(clubId: Int): List<TrainingGroupDto> {
return client.http.get("/api/training-times/$clubId").body()
}
}

View File

@@ -0,0 +1,35 @@
package de.tt_tagebuch.shared.api.http
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
private val looseJson = Json { ignoreUnknownKeys = true }
/**
* Liest JSON-Fehlerantworten (`error`, `message`) wie die Node-API sie oft liefert.
*/
internal suspend fun HttpResponse.userFacingErrorOr(fallback: String): String {
val raw = runCatching { bodyAsText() }.getOrNull().orEmpty().trim()
if (raw.isEmpty()) return fallback
val extracted = runCatching {
val el = looseJson.parseToJsonElement(raw)
if (el !is JsonObject) return@runCatching null
el["error"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() }
?: el["message"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() }
}.getOrNull()
val text = extracted ?: raw.take(500).let { if (raw.length > 500) "$it" else it }
return mapKnownBackendErrorTokens(text)
}
private fun mapKnownBackendErrorTokens(raw: String): String {
return when (raw.trim().lowercase()) {
"alreadyexists" -> "Ein Verein mit diesem Namen existiert bereits."
"noaccess" -> "Kein Zugriff auf diese Ressource."
"internalerror" -> "Serverfehler. Bitte später erneut versuchen."
"notrequested" -> "Für diesen Verein wurde kein Zugriff beantragt."
else -> raw
}
}

View File

@@ -0,0 +1,7 @@
package de.tt_tagebuch.shared.api.http
class ApiException(
val statusCode: Int,
message: String,
) : RuntimeException(message)

View File

@@ -0,0 +1,61 @@
package de.tt_tagebuch.shared.api.http
import de.tt_tagebuch.shared.api.ApiConfig
import de.tt_tagebuch.shared.state.TokenProvider
import io.ktor.client.HttpClient
import io.ktor.client.plugins.DefaultRequest
import io.ktor.client.plugins.HttpResponseValidator
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.header
import io.ktor.client.request.url
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
class AuthedHttpClient(
apiConfig: ApiConfig,
private val tokenProvider: TokenProvider,
httpClientEngineFactory: HttpClientEngineFactory,
private val onUnauthorized: () -> Unit = {},
) {
val http: HttpClient = HttpClient(httpClientEngineFactory.create()) {
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
}
)
}
install(HttpTimeout) {
requestTimeoutMillis = 60_000
connectTimeoutMillis = 15_000
socketTimeoutMillis = 60_000
}
HttpResponseValidator {
validateResponse { response ->
val statusCode = response.status.value
if (statusCode == 401) {
onUnauthorized()
val detail = response.userFacingErrorOr("Session abgelaufen")
throw ApiException(statusCode, detail)
}
if (statusCode >= 400) {
val detail = response.userFacingErrorOr("API Fehler $statusCode")
throw ApiException(statusCode, detail)
}
}
}
install(DefaultRequest) {
url(apiConfig.baseUrl)
contentType(ContentType.Application.Json)
tokenProvider.token?.let { token ->
header("authcode", token)
}
tokenProvider.username?.let { username ->
header("userid", username)
}
}
}
}

View File

@@ -0,0 +1,8 @@
package de.tt_tagebuch.shared.api.http
import io.ktor.client.engine.HttpClientEngine
interface HttpClientEngineFactory {
fun create(): HttpClientEngine
}

View File

@@ -0,0 +1,50 @@
package de.tt_tagebuch.shared.api.http
import de.tt_tagebuch.shared.api.ApiConfig
import io.ktor.client.HttpClient
import io.ktor.client.plugins.DefaultRequest
import io.ktor.client.plugins.HttpResponseValidator
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.url
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
/**
* HTTP-Client ohne Auth-Header für Login, Registrierung, Passwort-Reset.
* 401 löst **keinen** globalen Logout aus (nur [ApiException]).
*/
class PublicHttpClient(
apiConfig: ApiConfig,
httpClientEngineFactory: HttpClientEngineFactory,
) {
val http: HttpClient = HttpClient(httpClientEngineFactory.create()) {
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
},
)
}
install(HttpTimeout) {
requestTimeoutMillis = 60_000
connectTimeoutMillis = 15_000
socketTimeoutMillis = 60_000
}
HttpResponseValidator {
validateResponse { response ->
val statusCode = response.status.value
if (statusCode >= 400) {
val detail = response.userFacingErrorOr("API Fehler $statusCode")
throw ApiException(statusCode, detail)
}
}
}
install(DefaultRequest) {
url(apiConfig.baseUrl)
contentType(ContentType.Application.Json)
}
}
}

View File

@@ -0,0 +1,23 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class AccidentReportDto(
val accident: String,
val firstName: String? = null,
val lastName: String? = null,
)
fun AccidentReportDto.memberLabel(): String {
val parts = listOfNotNull(firstName?.trim()?.takeIf { it.isNotEmpty() }, lastName?.trim()?.takeIf { it.isNotEmpty() })
return if (parts.isEmpty()) "" else parts.joinToString(" ")
}
@Serializable
data class CreateAccidentBody(
val clubId: Int,
val memberId: Int,
val diaryDateId: Int,
val accident: String,
)

View File

@@ -0,0 +1,14 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class Club(
val id: Int,
val name: String,
val greetingText: String? = null,
val associationMemberNumber: String? = null,
val myTischtennisFedNickname: String? = null,
val autoFetchRankings: Boolean? = null,
)

View File

@@ -0,0 +1,32 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
private fun JsonObject.boolAt(module: String, key: String): Boolean {
val mod = this[module]?.jsonObject ?: return false
return mod[key]?.jsonPrimitive?.booleanOrNull == true
}
/** Vereinsbesitzer haben volle Rechte. */
fun UserClubPermissions.canReadDiary(): Boolean {
if (isOwner) return true
return permissions.boolAt("diary", "read")
}
fun UserClubPermissions.canWriteDiary(): Boolean {
if (isOwner) return true
return permissions.boolAt("diary", "write")
}
fun UserClubPermissions.canReadMembers(): Boolean {
if (isOwner) return true
return permissions.boolAt("members", "read")
}
fun UserClubPermissions.canWriteMembers(): Boolean {
if (isOwner) return true
return permissions.boolAt("members", "write")
}

View File

@@ -0,0 +1,30 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
/** Freitext-Aktivitäten zum Tag (`/api/activities`), nicht der Trainingsplan. */
@Serializable
data class DiaryFreeformActivity(
val id: Int,
val description: String,
val diaryDateId: Int,
)
@Serializable
data class AddFreeformActivityBody(
val diaryDateId: Int,
val description: String,
)
/** Verknüpfung Teilnehmer-Zeile ↔ Plan-/Gruppen-Aktivität (`/api/diary-member-activities`). */
@Serializable
data class DiaryMemberActivityLink(
val id: Int,
val diaryDateActivityId: Int,
val participantId: Int,
)
@Serializable
data class AddMemberActivityParticipantsBody(
val participantIds: List<Int>,
)

View File

@@ -0,0 +1,53 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class DiaryDate(
val id: Int,
val clubId: Int,
val date: String,
val trainingStart: String? = null,
val trainingEnd: String? = null,
val diaryNotes: List<DiaryNote> = emptyList(),
val diaryTags: List<DiaryTag> = emptyList(),
)
@Serializable
data class DiaryNote(
val id: Int,
val content: String? = null,
val createdAt: String? = null,
)
@Serializable
data class DiaryTag(
val id: Int,
val name: String,
)
@Serializable
data class CreateDiaryDateRequest(
val date: String,
val trainingStart: String? = null,
val trainingEnd: String? = null,
)
@Serializable
data class UpdateDiaryTimesRequest(
val dateId: Int,
val trainingStart: String? = null,
val trainingEnd: String? = null,
)
@Serializable
data class LinkDiaryTagRequest(
val diaryDateId: Int,
val tagId: Int,
)
@Serializable
data class AddDiaryNoteRequest(
val diaryDateId: Int,
val content: String,
)

View File

@@ -0,0 +1,55 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class DiaryDateActivityItem(
val id: Int,
val orderId: Int = 0,
val isTimeblock: Boolean = false,
val duration: Int? = null,
val durationText: String? = null,
/** Trainingsgruppe (Tabellen `Group`) für getrennte Pläne am selben Tag. */
val groupId: Int? = null,
val planGroup: DiaryPlanGroupSummary? = null,
val predefinedActivity: PredefinedActivitySummary? = null,
val groupActivities: List<GroupActivitySummary> = emptyList(),
)
@Serializable
data class DiaryPlanGroupSummary(
val id: Int? = null,
val name: String? = null,
)
@Serializable
data class PredefinedActivitySummary(
val id: Int? = null,
val name: String? = null,
val code: String? = null,
/** Wie Backend: z. B. `/api/predefined-activities/…/image/…` */
val imageLink: String? = null,
val imageUrl: String? = null,
)
@Serializable
data class GroupActivitySummary(
val id: Int? = null,
val orderId: Int? = null,
val groupPredefinedActivity: PredefinedActivitySummary? = null,
)
fun PredefinedActivitySummary?.displayLabel(): String {
if (this == null) return ""
val n = name?.trim().orEmpty()
if (n.isNotEmpty()) return n
val c = code?.trim().orEmpty()
if (c.isNotEmpty()) return c
return ""
}
fun DiaryDateActivityItem.displayTitle(fallbackTimeblock: String): String {
val label = predefinedActivity.displayLabel()
if (label.isNotEmpty()) return label
return if (isTimeblock) fallbackTimeblock else ""
}

View File

@@ -0,0 +1,23 @@
package de.tt_tagebuch.shared.api.models
private val imageIdInPath = Regex("/image/(\\d+)")
fun PredefinedActivitySummary?.hasDisplayableImage(): Boolean {
if (this == null) return false
val link = imageLink?.trim().orEmpty()
if (link.isEmpty()) return false
return imageIdInPath.containsMatchIn(link)
}
/** Relativer API-Pfad (beginnt mit `/`) oder `null`. */
fun PredefinedActivitySummary?.imageRelativePath(): String? {
if (!hasDisplayableImage()) return null
val link = this!!.imageLink!!.trim()
return if (link.startsWith("/")) link else "/$link"
}
fun DiaryDateActivityItem.mainActivityImagePath(): String? =
predefinedActivity.imageRelativePath()
fun GroupActivitySummary.nestedActivityImagePath(): String? =
groupPredefinedActivity.imageRelativePath()

View File

@@ -0,0 +1,51 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class DiaryMemberNoteDto(
val id: Int,
val memberId: Int? = null,
val diaryDateId: Int? = null,
val content: String? = null,
val createdAt: String? = null,
)
@Serializable
data class DiaryTagNested(
val id: Int,
val name: String? = null,
val label: String? = null,
)
@Serializable
data class DiaryMemberTagLinkDto(
val id: Int,
val memberId: Int? = null,
val diaryDateId: Int? = null,
val tagId: Int? = null,
val tag: DiaryTagNested? = null,
)
fun DiaryMemberTagLinkDto.tagDefinitionId(): Int = tag?.id ?: tagId ?: 0
fun DiaryMemberTagLinkDto.tagDisplayName(): String {
val fromNested = tag?.label?.takeIf { it.isNotBlank() } ?: tag?.name?.takeIf { it.isNotBlank() }
if (!fromNested.isNullOrBlank()) return fromNested
val tid = tagDefinitionId()
return if (tid > 0) "Tag $tid" else "Tag"
}
@Serializable
data class AddDiaryMemberNoteBody(
val memberId: Int,
val diaryDateId: Int,
val content: String,
)
@Serializable
data class DiaryMemberTagMutationBody(
val diaryDateId: Int,
val memberId: Int,
val tagId: Int,
)

View File

@@ -0,0 +1,80 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class DiaryPlanGroup(
val id: Int,
val name: String? = null,
val lead: String? = null,
val diaryDateId: Int? = null,
)
@Serializable
data class CreateDiaryPlanActivityRequest(
val diaryDateId: Int,
val activity: String = "",
val predefinedActivityId: Int? = null,
val duration: Int? = null,
val durationText: String? = null,
val isTimeblock: Boolean = false,
val groupId: Int? = null,
)
@Serializable
data class AddDiaryPlanGroupActivityRequest(
val clubId: Int,
val diaryDateId: Int,
val groupId: Int,
val activity: String,
val predefinedActivityId: Int? = null,
val timeblockId: Int? = null,
val duration: Int? = null,
val durationText: String? = null,
)
@Serializable
data class UpdateDiaryPlanActivityRequest(
val predefinedActivityId: Int? = null,
val customActivityName: String? = null,
val duration: Int? = null,
val durationText: String? = null,
val orderId: Int? = null,
val groupId: Int? = null,
)
@Serializable
data class UpdateDiaryPlanActivityOrderRequest(
val orderId: Int,
)
@Serializable
data class UpdateNestedPlanGroupActivityRequest(
val predefinedActivityId: Int? = null,
val duration: Int? = null,
val durationText: String? = null,
val orderId: Int? = null,
val groupId: Int? = null,
)
@Serializable
data class CreateTrainingGroupBody(
val clubid: Int,
val dateid: Int,
val name: String,
val lead: String? = null,
)
@Serializable
data class UpdateTrainingGroupBody(
val clubid: Int,
val dateid: Int,
val name: String,
val lead: String? = null,
)
@Serializable
data class DeleteTrainingGroupBody(
val clubid: Int,
val dateid: Int,
)

View File

@@ -0,0 +1,30 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class DiaryTrainingParticipant(
val id: Int,
val diaryDateId: Int,
val memberId: Int,
val attendanceStatus: String? = null,
val groupId: Int? = null,
val notes: String? = null,
)
@Serializable
data class ParticipantMutationRequest(
val diaryDateId: Int,
val memberId: Int,
)
@Serializable
data class ParticipantGroupRequest(
val groupId: Int? = null,
)
/** Wie im Web: nur diese gelten als „nimmt teil“ für die Auswahl. */
fun DiaryTrainingParticipant.isPresentParticipant(): Boolean {
val s = attendanceStatus
return s.isNullOrBlank() || s == "present"
}

View File

@@ -0,0 +1,10 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class LoginRequest(
val email: String,
val password: String,
)

View File

@@ -0,0 +1,9 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class LoginResponse(
val token: String,
)

View File

@@ -0,0 +1,38 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class Member(
val id: Int,
val firstName: String = "",
val lastName: String = "",
val clubId: Int? = null,
val active: Boolean = true,
val birthDate: String? = null,
val gender: String? = null,
val ttr: Int? = null,
val qttr: Int? = null,
val street: String? = null,
val city: String? = null,
val postalCode: String? = null,
val phone: String? = null,
val email: String? = null,
val testMembership: Boolean? = null,
val picsInInternetAllowed: Boolean? = null,
val memberFormHandedOver: Boolean? = null,
val adultReleaseApproved: Boolean? = null,
val adultReserveApproved: Boolean? = null,
val lastTraining: String? = null,
val trainingParticipations: Int? = null,
val notInTraining: Boolean? = null,
val missedTrainingWeeks: Int? = null,
val contacts: List<MemberContactDto> = emptyList(),
val images: List<MemberImageDto> = emptyList(),
val primaryImageId: Int? = null,
val primaryImageUrl: String? = null,
val imageUrl: String? = null,
val hasImage: Boolean? = null,
val myTischtennisPlayerId: String? = null,
val myTischtennisHistoryPlayerId: String? = null,
)

View File

@@ -0,0 +1,20 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonArray
@Serializable
data class MemberActivityStatDto(
val name: String? = null,
val code: String? = null,
val count: Int = 0,
val dates: JsonArray? = null,
)
@Serializable
data class MemberLastParticipationDto(
val activityName: String? = null,
val activityFullName: String? = null,
val date: String? = null,
val diaryDateId: Int? = null,
)

View File

@@ -0,0 +1,14 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class MemberContactDto(
val id: Int? = null,
val memberId: Int? = null,
val type: String,
val value: String = "",
val isParent: Boolean = false,
val parentName: String? = null,
val isPrimary: Boolean = false,
)

View File

@@ -0,0 +1,19 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class MemberGroupPhotoListResponse(
val success: Boolean = true,
val photos: List<MemberGroupPhotoDto> = emptyList(),
)
@Serializable
data class MemberGroupPhotoDto(
val id: Int,
val clubId: Int? = null,
val title: String? = null,
val description: String? = null,
/** Relativer Pfad inkl. Query (Cache-Buster), z. B. `/api/member-group-photos/1/2/image?t=…` */
val imageUrl: String? = null,
)

View File

@@ -0,0 +1,12 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class MemberImageDto(
val id: Int? = null,
val memberId: Int? = null,
val fileName: String? = null,
val sortOrder: Int? = null,
val url: String? = null,
)

View File

@@ -0,0 +1,39 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class MemberContactSetBody(
val type: String,
val value: String,
val isParent: Boolean = false,
val parentName: String? = null,
val isPrimary: Boolean = false,
)
/**
* Body für [POST /api/clubmembers/set/:clubId] Feldnamen wie Web (`firstname`/`lastname`/`birthdate`).
*/
@Serializable
data class MemberSetBody(
val id: Int? = null,
@SerialName("firstname") val firstname: String,
@SerialName("lastname") val lastname: String,
val street: String? = "",
val city: String? = "",
val postalCode: String? = null,
@SerialName("birthdate") val birthdate: String? = null,
val phone: String? = "",
val email: String? = "",
val active: Boolean = true,
val testMembership: Boolean = false,
val picsInInternetAllowed: Boolean = false,
val gender: String? = "unknown",
val ttr: Int? = null,
val qttr: Int? = null,
val memberFormHandedOver: Boolean = false,
val adultReleaseApproved: Boolean = false,
val adultReserveApproved: Boolean = false,
val contacts: List<MemberContactSetBody> = emptyList(),
)

View File

@@ -0,0 +1,49 @@
package de.tt_tagebuch.shared.api.models
fun Member.toSetBody(
firstname: String,
lastname: String,
street: String?,
city: String?,
postalCode: String?,
birthdate: String?,
phone: String?,
email: String?,
active: Boolean,
testMembership: Boolean,
picsInInternetAllowed: Boolean,
gender: String?,
memberFormHandedOver: Boolean,
adultReleaseApproved: Boolean,
adultReserveApproved: Boolean,
contacts: List<MemberContactSetBody>,
): MemberSetBody = MemberSetBody(
id = if (id <= 0) null else id,
firstname = firstname,
lastname = lastname,
street = street.orEmpty(),
city = city.orEmpty(),
postalCode = postalCode?.ifBlank { null },
birthdate = birthdate?.ifBlank { null },
phone = phone.orEmpty(),
email = email.orEmpty(),
active = active,
testMembership = testMembership,
picsInInternetAllowed = picsInInternetAllowed,
gender = gender?.ifBlank { null } ?: "unknown",
ttr = ttr,
qttr = qttr,
memberFormHandedOver = memberFormHandedOver,
adultReleaseApproved = adultReleaseApproved,
adultReserveApproved = adultReserveApproved,
contacts = contacts,
)
fun MemberContactDto.toSetBody(): MemberContactSetBody =
MemberContactSetBody(
type = type,
value = value,
isParent = isParent,
parentName = parentName,
isPrimary = isPrimary,
)

View File

@@ -0,0 +1,8 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class ParticipantStatusRequest(
val attendanceStatus: String,
)

View File

@@ -0,0 +1,22 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class PredefinedActivityDto(
val id: Int,
val name: String? = null,
val code: String? = null,
val description: String? = null,
val duration: Int? = null,
val durationText: String? = null,
val excludeFromStats: Boolean? = null,
)
fun PredefinedActivityDto.displayLabel(): String {
val n = name?.trim().orEmpty()
if (n.isNotEmpty()) return n
val c = code?.trim().orEmpty()
if (c.isNotEmpty()) return c
return "Übung $id"
}

View File

@@ -0,0 +1,25 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class RegisterRequest(
val email: String,
val password: String,
)
@Serializable
data class ForgotPasswordRequest(
val email: String,
)
@Serializable
data class MessageResponse(
val message: String? = null,
)
@Serializable
data class ResetPasswordRequest(
val token: String,
val password: String,
)

View File

@@ -0,0 +1,10 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class SessionStatusResponse(
val valid: Boolean,
val message: String? = null,
)

View File

@@ -0,0 +1,24 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class TrainingTimeDto(
val id: Int = 0,
val trainingGroupId: Int? = null,
val weekday: Int = 0,
val startTime: String = "",
val endTime: String = "",
val sortOrder: Int = 0,
)
@Serializable
data class TrainingGroupDto(
val id: Int = 0,
val clubId: Int? = null,
val name: String = "",
val sortOrder: Int = 0,
val isPreset: Boolean = false,
val presetType: String? = null,
val trainingTimes: List<TrainingTimeDto> = emptyList(),
)

View File

@@ -0,0 +1,101 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
@Serializable
data class TrainingStats(
val members: List<TrainingStatsMember> = emptyList(),
val trainingsCount12Months: Int = 0,
val trainingsCount3Months: Int = 0,
val trainingDays: List<TrainingStatsDay> = emptyList(),
val overview: TrainingStatsOverview = TrainingStatsOverview(),
val weekdayStats: List<TrainingStatsWeekdayBucket> = emptyList(),
val monthlyTrend: List<TrainingStatsMonthlyTrend> = emptyList(),
val memberDistribution: TrainingStatsMemberDistribution = TrainingStatsMemberDistribution(),
)
@Serializable
data class TrainingStatsOverview(
val activeMembersCount: Int = 0,
val totalParticipants12Months: Int = 0,
val averageParticipants12Months: Double = 0.0,
val attendanceRate12Months: Double = 0.0,
val inactiveMembersCount: Int = 0,
val bestTrainingDay: TrainingStatsDay? = null,
)
@Serializable
data class TrainingStatsMemberDistribution(
val highlyActive: Int = 0,
val regular: Int = 0,
val occasional: Int = 0,
val inactive: Int = 0,
)
@Serializable
data class TrainingStatsWeekdayBucket(
val weekday: String = "",
val weekdayIndex: Int = 0,
val trainingCount: Int = 0,
val participantCount: Int = 0,
val averageParticipants: Double = 0.0,
)
@Serializable
data class TrainingStatsMonthlyTrend(
val key: String = "",
val label: String = "",
val trainingCount: Int = 0,
val participantCount: Int = 0,
val averageParticipants: Double = 0.0,
)
@Serializable
data class TrainingStatsTrainingGroup(
val id: Int = 0,
val name: String = "",
)
@Serializable
data class TrainingStatsTrainingDetail(
val id: Int = 0,
val date: String? = null,
val activityName: String? = null,
val startTime: String? = null,
val endTime: String? = null,
)
@Serializable
data class TrainingStatsMember(
val id: Int,
val firstName: String = "",
val lastName: String = "",
val birthDate: String? = null,
val ttr: Int? = null,
val qttr: Int? = null,
val participation12Months: Int = 0,
val participation3Months: Int = 0,
val participationTotal: Int = 0,
val participationRate12Months: Double = 0.0,
val lastTraining: String? = null,
val lastTrainingTs: Long = 0,
val missedTrainingWeeks: Int = 0,
val notInTraining: Boolean = false,
val trainingGroups: List<TrainingStatsTrainingGroup> = emptyList(),
val trainingDetails: List<TrainingStatsTrainingDetail> = emptyList(),
)
@Serializable
data class TrainingStatsDay(
val id: Int,
val date: String,
val participantCount: Int = 0,
val participants: List<TrainingStatsParticipant> = emptyList(),
)
@Serializable
data class TrainingStatsParticipant(
val id: Int,
val firstName: String = "",
val lastName: String = "",
)

View File

@@ -0,0 +1,12 @@
package de.tt_tagebuch.shared.api.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
@Serializable
data class UserClubPermissions(
val role: String,
val isOwner: Boolean,
val permissions: JsonObject = JsonObject(emptyMap()),
)

View File

@@ -0,0 +1,13 @@
# i18n
Source of truth is the webapp locale JSONs in `frontend/src/i18n/locales/`.
The mobile app generates a KMP-safe translation bundle into this package:
- `MobileStrings.kt` (generated)
German (`de.json`) is the canonical key set. Missing keys in other locales are filled with German fallback values during generation so every supported mobile language has the same keys.
Generate via:
```bash
node scripts/generate-mobile-i18n.js
```

View File

@@ -0,0 +1,63 @@
package de.tt_tagebuch.shared.state
import de.tt_tagebuch.shared.api.AuthApi
import de.tt_tagebuch.shared.api.SessionApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class AuthManager(
private val tokenProvider: MutableTokenProvider,
private val tokenStorage: TokenStorage,
private val authApi: AuthApi,
private val sessionApi: SessionApi,
) : TokenProvider {
private val _state = MutableStateFlow(AuthState(isHydrating = true))
val state: StateFlow<AuthState> = _state.asStateFlow()
override val token: String? get() = tokenProvider.token
override val username: String? get() = tokenProvider.username
suspend fun hydrate() {
_state.value = _state.value.copy(isHydrating = true)
try {
val stored = tokenStorage.load()
if (stored != null) {
tokenProvider.token = stored.token
tokenProvider.username = stored.username
_state.value = AuthState(token = stored.token, username = stored.username, isHydrating = true)
val status = runCatching { sessionApi.status() }.getOrNull()
if (status != null && !status.valid) {
clearLocal()
}
} else {
clearLocal()
}
} finally {
_state.value = _state.value.copy(isHydrating = false)
}
}
suspend fun login(email: String, password: String) {
val response = authApi.login(email = email, password = password)
val tokens = AuthTokens(token = response.token, username = email)
tokenProvider.token = tokens.token
tokenProvider.username = tokens.username
tokenStorage.save(tokens)
_state.value = AuthState(token = tokens.token, username = tokens.username, isHydrating = false)
}
suspend fun logout() {
runCatching { authApi.logout() }
clearLocal()
}
suspend fun clearLocal() {
tokenProvider.token = null
tokenProvider.username = null
tokenStorage.clear()
_state.value = AuthState(token = null, username = null, isHydrating = false)
}
}

View File

@@ -0,0 +1,10 @@
package de.tt_tagebuch.shared.state
data class AuthState(
val token: String? = null,
val username: String? = null,
val isHydrating: Boolean = false,
) {
val isLoggedIn: Boolean get() = !token.isNullOrBlank()
}

View File

@@ -0,0 +1,7 @@
package de.tt_tagebuch.shared.state
data class AuthTokens(
val token: String,
val username: String,
)

View File

@@ -0,0 +1,90 @@
package de.tt_tagebuch.shared.state
import de.tt_tagebuch.shared.api.ClubsApi
import de.tt_tagebuch.shared.api.PermissionsApi
import de.tt_tagebuch.shared.api.models.Club
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class ClubManager(
private val clubStorage: ClubStorage,
private val clubsApi: ClubsApi,
private val permissionsApi: PermissionsApi,
) {
private val _state = MutableStateFlow(ClubState())
val state: StateFlow<ClubState> = _state.asStateFlow()
suspend fun hydrate() {
val stored = clubStorage.loadCurrentClubId()
_state.value = _state.value.copy(currentClubId = stored)
}
suspend fun loadClubs() {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val clubs = clubsApi.listClubs()
_state.value = _state.value.copy(clubs = clubs, isLoading = false)
} catch (t: Throwable) {
if (t is CancellationException) throw t
_state.value = _state.value.copy(isLoading = false, error = t.toUserMessage("Fehler beim Laden der Clubs"))
}
}
suspend fun createClub(name: String) {
val trimmed = name.trim()
if (trimmed.isEmpty()) return
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val newClub = clubsApi.createClub(trimmed)
loadClubs()
selectClub(newClub.id)
} catch (t: Throwable) {
if (t is CancellationException) throw t
_state.value = _state.value.copy(isLoading = false, error = t.toUserMessage("Verein konnte nicht erstellt werden"))
}
}
suspend fun fetchClubDetail(clubId: Int): Club {
return clubsApi.getClub(clubId)
}
suspend fun selectClub(clubId: Int) {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val permissions = permissionsApi.getUserPermissions(clubId)
clubStorage.saveCurrentClubId(clubId)
_state.value = _state.value.copy(
currentClubId = clubId,
currentPermissions = permissions,
isLoading = false,
)
} catch (t: Throwable) {
if (t is CancellationException) throw t
_state.value = _state.value.copy(
isLoading = false,
error = t.toUserMessage("Keine Berechtigung oder Fehler beim Laden der Permissions"),
currentPermissions = null,
currentClubId = null,
)
clubStorage.saveCurrentClubId(null)
}
}
suspend fun requestAccess(clubId: Int) {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
clubsApi.requestAccess(clubId)
_state.value = _state.value.copy(isLoading = false)
} catch (t: Throwable) {
if (t is CancellationException) throw t
_state.value = _state.value.copy(isLoading = false, error = t.toUserMessage("Access request fehlgeschlagen"))
}
}
suspend fun clearSelection() {
clubStorage.saveCurrentClubId(null)
_state.value = _state.value.copy(currentClubId = null, currentPermissions = null)
}
}

View File

@@ -0,0 +1,13 @@
package de.tt_tagebuch.shared.state
import de.tt_tagebuch.shared.api.models.Club
import de.tt_tagebuch.shared.api.models.UserClubPermissions
data class ClubState(
val clubs: List<Club> = emptyList(),
val currentClubId: Int? = null,
val currentPermissions: UserClubPermissions? = null,
val isLoading: Boolean = false,
val error: String? = null,
)

View File

@@ -0,0 +1,7 @@
package de.tt_tagebuch.shared.state
interface ClubStorage {
suspend fun loadCurrentClubId(): Int?
suspend fun saveCurrentClubId(clubId: Int?)
}

View File

@@ -0,0 +1,303 @@
package de.tt_tagebuch.shared.state
import de.tt_tagebuch.shared.api.AccidentApi
import de.tt_tagebuch.shared.api.DiaryApi
import de.tt_tagebuch.shared.api.DiaryMemberActivitiesApi
import de.tt_tagebuch.shared.api.DiaryMemberApi
import de.tt_tagebuch.shared.api.GroupApi
import de.tt_tagebuch.shared.api.MemberGroupPhotosApi
import de.tt_tagebuch.shared.api.ParticipantsApi
import de.tt_tagebuch.shared.api.PredefinedActivitiesApi
import de.tt_tagebuch.shared.api.models.AccidentReportDto
import de.tt_tagebuch.shared.api.models.AddDiaryMemberNoteBody
import de.tt_tagebuch.shared.api.models.CreateAccidentBody
import de.tt_tagebuch.shared.api.models.DiaryMemberNoteDto
import de.tt_tagebuch.shared.api.models.DiaryMemberTagLinkDto
import de.tt_tagebuch.shared.api.models.DiaryMemberTagMutationBody
import de.tt_tagebuch.shared.api.models.DiaryTag
import de.tt_tagebuch.shared.api.models.PredefinedActivityDto
import de.tt_tagebuch.shared.api.models.AddDiaryPlanGroupActivityRequest
import de.tt_tagebuch.shared.api.models.CreateDiaryPlanActivityRequest
import de.tt_tagebuch.shared.api.models.CreateTrainingGroupBody
import de.tt_tagebuch.shared.api.models.DeleteTrainingGroupBody
import de.tt_tagebuch.shared.api.models.DiaryDateActivityItem
import de.tt_tagebuch.shared.api.models.DiaryFreeformActivity
import de.tt_tagebuch.shared.api.models.DiaryMemberActivityLink
import de.tt_tagebuch.shared.api.models.MemberGroupPhotoDto
import de.tt_tagebuch.shared.api.models.DiaryPlanGroup
import de.tt_tagebuch.shared.api.models.DiaryTrainingParticipant
import de.tt_tagebuch.shared.api.models.UpdateDiaryPlanActivityRequest
import de.tt_tagebuch.shared.api.models.UpdateNestedPlanGroupActivityRequest
import de.tt_tagebuch.shared.api.models.UpdateTrainingGroupBody
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class DiaryManager(
private val diaryApi: DiaryApi,
private val participantsApi: ParticipantsApi,
private val groupApi: GroupApi,
private val diaryMemberActivitiesApi: DiaryMemberActivitiesApi,
private val diaryMemberApi: DiaryMemberApi,
private val predefinedActivitiesApi: PredefinedActivitiesApi,
private val accidentApi: AccidentApi,
private val memberGroupPhotosApi: MemberGroupPhotosApi,
) {
private val _state = MutableStateFlow(DiaryState())
val state: StateFlow<DiaryState> = _state.asStateFlow()
suspend fun fetchDateActivities(clubId: Int, diaryDateId: Int): List<DiaryDateActivityItem> {
return diaryApi.listDateActivities(clubId, diaryDateId)
}
suspend fun listFreeformActivities(diaryDateId: Int): List<DiaryFreeformActivity> {
return diaryApi.listFreeformActivities(diaryDateId)
}
suspend fun addFreeformActivity(diaryDateId: Int, description: String): DiaryFreeformActivity {
return diaryApi.addFreeformActivity(diaryDateId, description)
}
suspend fun listMemberActivityLinks(clubId: Int, planOrGroupActivityId: Int): List<DiaryMemberActivityLink> {
return diaryMemberActivitiesApi.list(clubId, planOrGroupActivityId)
}
suspend fun addParticipantsToMemberActivity(clubId: Int, planOrGroupActivityId: Int, participantRowIds: List<Int>) {
diaryMemberActivitiesApi.add(clubId, planOrGroupActivityId, participantRowIds)
}
suspend fun removeParticipantFromMemberActivity(clubId: Int, planOrGroupActivityId: Int, participantRowId: Int) {
diaryMemberActivitiesApi.remove(clubId, planOrGroupActivityId, participantRowId)
}
suspend fun listAllDiaryTags(): List<DiaryTag> {
return diaryApi.listTags()
}
suspend fun listMemberNotes(clubId: Int, diaryDateId: Int, memberId: Int): List<DiaryMemberNoteDto> {
return diaryMemberApi.listNotes(clubId, diaryDateId, memberId)
}
suspend fun addMemberNote(clubId: Int, diaryDateId: Int, memberId: Int, content: String): List<DiaryMemberNoteDto> {
return diaryMemberApi.addNote(
clubId,
AddDiaryMemberNoteBody(memberId = memberId, diaryDateId = diaryDateId, content = content.trim()),
)
}
suspend fun deleteMemberNote(clubId: Int, diaryDateId: Int, memberId: Int, noteId: Int): List<DiaryMemberNoteDto> {
return diaryMemberApi.deleteNote(clubId, noteId, diaryDateId, memberId)
}
suspend fun listMemberTags(clubId: Int, diaryDateId: Int, memberId: Int): List<DiaryMemberTagLinkDto> {
return diaryMemberApi.listTags(clubId, diaryDateId, memberId)
}
suspend fun addMemberTag(clubId: Int, diaryDateId: Int, memberId: Int, tagId: Int): List<DiaryMemberTagLinkDto> {
return diaryMemberApi.addTag(clubId, DiaryMemberTagMutationBody(diaryDateId, memberId, tagId))
}
suspend fun removeMemberTag(clubId: Int, diaryDateId: Int, memberId: Int, tagId: Int): List<DiaryMemberTagLinkDto> {
return diaryMemberApi.removeTag(clubId, DiaryMemberTagMutationBody(diaryDateId, memberId, tagId))
}
suspend fun createMemberTagAndLink(clubId: Int, diaryDateId: Int, memberId: Int, name: String): List<DiaryMemberTagLinkDto> {
val tag = diaryApi.createTag(name.trim())
return diaryMemberApi.addTag(clubId, DiaryMemberTagMutationBody(diaryDateId, memberId, tag.id))
}
suspend fun searchPredefinedActivities(query: String, limit: Int = 20): List<PredefinedActivityDto> {
return predefinedActivitiesApi.search(query, limit)
}
suspend fun listPredefinedActivities(scope: String? = null): List<PredefinedActivityDto> {
return predefinedActivitiesApi.list(scope)
}
suspend fun getPredefinedActivity(id: Int): PredefinedActivityDto {
return predefinedActivitiesApi.getById(id)
}
suspend fun listAccidents(clubId: Int, diaryDateId: Int): List<AccidentReportDto> {
return accidentApi.list(clubId, diaryDateId)
}
suspend fun addAccident(clubId: Int, diaryDateId: Int, memberId: Int, description: String) {
accidentApi.create(
CreateAccidentBody(
clubId = clubId,
memberId = memberId,
diaryDateId = diaryDateId,
accident = description.trim(),
),
)
}
suspend fun listMemberGroupPhotos(clubId: Int): List<MemberGroupPhotoDto> {
return memberGroupPhotosApi.list(clubId)
}
suspend fun uploadMemberGroupPhoto(clubId: Int, imageBytes: ByteArray, title: String, description: String) {
memberGroupPhotosApi.upload(clubId, imageBytes, title, description)
}
suspend fun deleteMemberGroupPhoto(clubId: Int, photoId: Int) {
memberGroupPhotosApi.delete(clubId, photoId)
}
/** Liefert die `Participant`-Zeilen-ID für das Mitglied am Tag, legt bei Bedarf „anwesend“ an. */
suspend fun ensureParticipantRowId(diaryDateId: Int, memberId: Int): Int {
val list = participantsApi.listForDate(diaryDateId)
list.find { it.memberId == memberId }?.id?.let { return it }
return participantsApi.add(diaryDateId, memberId).id
}
suspend fun createPlanActivity(clubId: Int, body: CreateDiaryPlanActivityRequest) {
diaryApi.createDateActivity(clubId, body)
}
suspend fun addPlanGroupActivity(body: AddDiaryPlanGroupActivityRequest) {
diaryApi.addDateGroupActivity(body)
}
suspend fun updatePlanActivity(clubId: Int, activityId: Int, body: UpdateDiaryPlanActivityRequest) {
diaryApi.updateDateActivity(clubId, activityId, body)
}
suspend fun updatePlanActivityOrder(clubId: Int, activityId: Int, orderId: Int) {
diaryApi.updateDateActivityOrder(clubId, activityId, orderId)
}
suspend fun deletePlanActivity(clubId: Int, activityId: Int) {
diaryApi.deleteDateActivity(clubId, activityId)
}
suspend fun updatePlanNestedGroupActivity(clubId: Int, groupActivityId: Int, body: UpdateNestedPlanGroupActivityRequest) {
diaryApi.updateNestedGroupActivity(clubId, groupActivityId, body)
}
suspend fun deletePlanNestedGroupActivity(clubId: Int, groupActivityId: Int) {
diaryApi.deleteNestedGroupActivity(clubId, groupActivityId)
}
suspend fun listTrainingGroups(clubId: Int, diaryDateId: Int): List<DiaryPlanGroup> {
return groupApi.listForDiaryDate(clubId, diaryDateId)
}
suspend fun createTrainingGroup(clubId: Int, diaryDateId: Int, name: String, lead: String?) {
groupApi.create(CreateTrainingGroupBody(clubid = clubId, dateid = diaryDateId, name = name, lead = lead))
}
suspend fun updateTrainingGroup(groupId: Int, clubId: Int, diaryDateId: Int, name: String, lead: String?) {
groupApi.update(groupId, UpdateTrainingGroupBody(clubid = clubId, dateid = diaryDateId, name = name, lead = lead))
}
suspend fun deleteTrainingGroup(groupId: Int, clubId: Int, diaryDateId: Int) {
groupApi.delete(groupId, DeleteTrainingGroupBody(clubid = clubId, dateid = diaryDateId))
}
suspend fun listTrainingParticipants(diaryDateId: Int): List<DiaryTrainingParticipant> {
return participantsApi.listForDate(diaryDateId)
}
suspend fun addTrainingParticipant(diaryDateId: Int, memberId: Int) {
participantsApi.add(diaryDateId, memberId)
}
suspend fun removeTrainingParticipant(diaryDateId: Int, memberId: Int) {
participantsApi.remove(diaryDateId, memberId)
}
suspend fun setTrainingParticipantAttendanceStatus(diaryDateId: Int, memberId: Int, attendanceStatus: String) {
participantsApi.updateAttendanceStatus(diaryDateId, memberId, attendanceStatus)
}
suspend fun setTrainingParticipantGroup(diaryDateId: Int, memberId: Int, groupId: Int?) {
participantsApi.updateParticipantGroup(diaryDateId, memberId, groupId)
}
suspend fun loadDates(clubId: Int) {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val dates = diaryApi.listDates(clubId)
_state.value = _state.value.copy(isLoading = false, dates = dates)
} catch (t: Throwable) {
_state.value = _state.value.copy(isLoading = false, error = t.toUserMessage("Fehler beim Laden des Tagebuchs"))
}
}
suspend fun createDate(clubId: Int, date: String, trainingStart: String?, trainingEnd: String?) {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
diaryApi.createDate(clubId, date, trainingStart, trainingEnd)
loadDates(clubId)
} catch (t: Throwable) {
_state.value = _state.value.copy(isLoading = false, error = t.toUserMessage("Eintrag konnte nicht erstellt werden"))
}
}
suspend fun updateTimes(clubId: Int, dateId: Int, trainingStart: String?, trainingEnd: String?) {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
diaryApi.updateTimes(clubId, dateId, trainingStart, trainingEnd)
loadDates(clubId)
} catch (t: Throwable) {
_state.value = _state.value.copy(isLoading = false, error = t.toUserMessage("Zeiten konnten nicht gespeichert werden"))
}
}
suspend fun deleteDate(clubId: Int, dateId: Int) {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
diaryApi.deleteDate(clubId, dateId)
loadDates(clubId)
} catch (t: Throwable) {
_state.value = _state.value.copy(isLoading = false, error = t.toUserMessage("Eintrag konnte nicht gelöscht werden"))
}
}
suspend fun addNote(clubId: Int, diaryDateId: Int, content: String) {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
diaryApi.addNote(diaryDateId, content)
loadDates(clubId)
} catch (t: Throwable) {
_state.value = _state.value.copy(isLoading = false, error = t.toUserMessage("Notiz konnte nicht gespeichert werden"))
}
}
suspend fun deleteNote(clubId: Int, noteId: Int) {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
diaryApi.deleteNote(noteId)
loadDates(clubId)
} catch (t: Throwable) {
_state.value = _state.value.copy(isLoading = false, error = t.toUserMessage("Notiz konnte nicht gelöscht werden"))
}
}
suspend fun createAndLinkTag(clubId: Int, diaryDateId: Int, name: String) {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val tag = diaryApi.createTag(name)
diaryApi.linkTag(clubId, diaryDateId, tag.id)
loadDates(clubId)
} catch (t: Throwable) {
_state.value = _state.value.copy(isLoading = false, error = t.toUserMessage("Tag konnte nicht gespeichert werden"))
}
}
suspend fun removeTag(clubId: Int, tagId: Int) {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
diaryApi.removeTag(clubId, tagId)
loadDates(clubId)
} catch (t: Throwable) {
_state.value = _state.value.copy(isLoading = false, error = t.toUserMessage("Tag konnte nicht entfernt werden"))
}
}
fun clear() {
_state.value = DiaryState()
}
}

View File

@@ -0,0 +1,10 @@
package de.tt_tagebuch.shared.state
import de.tt_tagebuch.shared.api.models.DiaryDate
data class DiaryState(
val dates: List<DiaryDate> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
)

View File

@@ -0,0 +1,27 @@
package de.tt_tagebuch.shared.state
import de.tt_tagebuch.shared.api.http.ApiException
internal fun Throwable.toUserMessage(fallback: String): String {
if (this is ApiException) {
return when (statusCode) {
401 -> "Session abgelaufen. Bitte erneut anmelden."
403 -> "Keine Berechtigung für diese Aktion."
else -> message ?: fallback
}
}
val rawMessage = message.orEmpty()
val normalizedMessage = rawMessage.lowercase()
if (
normalizedMessage.contains("failed to connect") ||
normalizedMessage.contains("unable to resolve") ||
normalizedMessage.contains("connection refused") ||
normalizedMessage.contains("timeout") ||
normalizedMessage.contains("network")
) {
return "Keine Netzwerkverbindung zum Backend. Bitte Verbindung und Backend-URL prüfen."
}
return rawMessage.ifBlank { fallback }
}

View File

@@ -0,0 +1,27 @@
package de.tt_tagebuch.shared.state
import de.tt_tagebuch.shared.i18n.MobileStrings
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class LanguageManager(
private val languageStorage: LanguageStorage,
) {
private val _state = MutableStateFlow(LanguageState())
val state: StateFlow<LanguageState> = _state.asStateFlow()
suspend fun hydrate() {
val stored = languageStorage.loadLanguageCode()
val languageCode = stored?.takeIf { code ->
MobileStrings.supportedLanguages.any { it.code == code }
} ?: MobileStrings.DEFAULT_LANGUAGE
_state.value = LanguageState(languageCode)
}
suspend fun selectLanguage(languageCode: String) {
if (MobileStrings.supportedLanguages.none { it.code == languageCode }) return
languageStorage.saveLanguageCode(languageCode)
_state.value = LanguageState(languageCode)
}
}

View File

@@ -0,0 +1,7 @@
package de.tt_tagebuch.shared.state
import de.tt_tagebuch.shared.i18n.MobileStrings
data class LanguageState(
val currentLanguageCode: String = MobileStrings.DEFAULT_LANGUAGE,
)

View File

@@ -0,0 +1,6 @@
package de.tt_tagebuch.shared.state
interface LanguageStorage {
suspend fun loadLanguageCode(): String?
suspend fun saveLanguageCode(languageCode: String)
}

View File

@@ -0,0 +1,69 @@
package de.tt_tagebuch.shared.state
import de.tt_tagebuch.shared.api.MemberActivitiesApi
import de.tt_tagebuch.shared.api.MembersApi
import de.tt_tagebuch.shared.api.TrainingGroupsApi
import de.tt_tagebuch.shared.api.TrainingTimesApi
import de.tt_tagebuch.shared.api.models.Member
import de.tt_tagebuch.shared.api.models.MemberActivityStatDto
import de.tt_tagebuch.shared.api.models.MemberLastParticipationDto
import de.tt_tagebuch.shared.api.models.MemberSetBody
import de.tt_tagebuch.shared.api.models.TrainingGroupDto
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class MembersManager(
private val membersApi: MembersApi,
private val trainingGroupsApi: TrainingGroupsApi,
private val memberActivitiesApi: MemberActivitiesApi,
private val trainingTimesApi: TrainingTimesApi,
) {
private val _state = MutableStateFlow(MembersState())
val state: StateFlow<MembersState> = _state.asStateFlow()
suspend fun loadMembers(clubId: Int) {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
val members = membersApi.listMembers(clubId)
.sortedWith(compareBy<Member> { it.lastName.lowercase() }.thenBy { it.firstName.lowercase() })
_state.value = _state.value.copy(members = members, isLoading = false)
} catch (t: Throwable) {
_state.value = _state.value.copy(isLoading = false, error = t.toUserMessage("Mitglieder konnten nicht geladen werden"))
}
}
suspend fun saveMember(clubId: Int, body: MemberSetBody) {
membersApi.setMember(clubId, body)
}
suspend fun uploadMemberPortrait(clubId: Int, memberId: Int, imageBytes: ByteArray) {
membersApi.uploadMemberImage(clubId, memberId, imageBytes, makePrimary = true)
}
suspend fun listTrainingGroups(clubId: Int): List<TrainingGroupDto> = trainingGroupsApi.listGroups(clubId)
suspend fun listMemberTrainingGroups(clubId: Int, memberId: Int): List<TrainingGroupDto> =
trainingGroupsApi.listMemberGroups(clubId, memberId)
suspend fun addMemberToTrainingGroup(clubId: Int, groupId: Int, memberId: Int) {
trainingGroupsApi.addMemberToGroup(clubId, groupId, memberId)
}
suspend fun removeMemberFromTrainingGroup(clubId: Int, groupId: Int, memberId: Int) {
trainingGroupsApi.removeMemberFromGroup(clubId, groupId, memberId)
}
suspend fun memberActivityStats(clubId: Int, memberId: Int, period: String = "year"): List<MemberActivityStatDto> =
memberActivitiesApi.listActivityStats(clubId, memberId, period)
suspend fun memberLastParticipations(clubId: Int, memberId: Int, limit: Int = 12): List<MemberLastParticipationDto> =
memberActivitiesApi.listLastParticipations(clubId, memberId, limit)
suspend fun trainingScheduleGroups(clubId: Int): List<TrainingGroupDto> =
trainingTimesApi.listGroupsWithTimes(clubId)
fun clear() {
_state.value = MembersState()
}
}

View File

@@ -0,0 +1,10 @@
package de.tt_tagebuch.shared.state
import de.tt_tagebuch.shared.api.models.Member
data class MembersState(
val members: List<Member> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
)

View File

@@ -0,0 +1,6 @@
package de.tt_tagebuch.shared.state
class MutableTokenProvider : TokenProvider {
override var token: String? = null
override var username: String? = null
}

View File

@@ -0,0 +1,6 @@
package de.tt_tagebuch.shared.state
interface TokenProvider {
val token: String?
val username: String?
}

View File

@@ -0,0 +1,8 @@
package de.tt_tagebuch.shared.state
interface TokenStorage {
suspend fun load(): AuthTokens?
suspend fun save(tokens: AuthTokens)
suspend fun clear()
}

View File

@@ -0,0 +1,26 @@
package de.tt_tagebuch.shared.state
import de.tt_tagebuch.shared.api.TrainingStatsApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class TrainingStatsManager(
private val trainingStatsApi: TrainingStatsApi,
) {
private val _state = MutableStateFlow(TrainingStatsState())
val state: StateFlow<TrainingStatsState> = _state.asStateFlow()
suspend fun loadStats(clubId: Int) {
_state.value = _state.value.copy(isLoading = true, error = null)
try {
_state.value = _state.value.copy(stats = trainingStatsApi.getStats(clubId), isLoading = false)
} catch (t: Throwable) {
_state.value = _state.value.copy(isLoading = false, error = t.toUserMessage("Statistik konnte nicht geladen werden"))
}
}
fun clear() {
_state.value = TrainingStatsState()
}
}

View File

@@ -0,0 +1,10 @@
package de.tt_tagebuch.shared.state
import de.tt_tagebuch.shared.api.models.TrainingStats
data class TrainingStatsState(
val stats: TrainingStats? = null,
val isLoading: Boolean = false,
val error: String? = null,
)

View File

@@ -0,0 +1,9 @@
package de.tt_tagebuch.shared.api.http
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.darwin.Darwin
class IosHttpClientEngineFactory : HttpClientEngineFactory {
override fun create(): HttpClientEngine = Darwin.create()
}

View File

@@ -0,0 +1,86 @@
package de.tt_tagebuch.shared.state
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.cinterop.alloc
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.ptr
import kotlinx.cinterop.refTo
import kotlinx.cinterop.memcpy
import platform.CoreFoundation.CFDictionaryRef
import platform.CoreFoundation.CFTypeRefVar
import platform.Foundation.NSData
import platform.Foundation.NSString
import platform.Foundation.NSUTF8StringEncoding
import platform.Foundation.dataUsingEncoding
import platform.Security.SecItemAdd
import platform.Security.SecItemCopyMatching
import platform.Security.SecItemDelete
import platform.Security.errSecSuccess
import platform.Security.kSecAttrAccount
import platform.Security.kSecAttrService
import platform.Security.kSecClass
import platform.Security.kSecClassGenericPassword
import platform.Security.kSecMatchLimit
import platform.Security.kSecMatchLimitOne
import platform.Security.kSecReturnData
import platform.Security.kSecValueData
class IosClubStorage : ClubStorage {
override suspend fun loadCurrentClubId(): Int? = withContext(Dispatchers.Default) {
loadString(KEY_CURRENT_CLUB_ID)?.toIntOrNull()
}
override suspend fun saveCurrentClubId(clubId: Int?) = withContext(Dispatchers.Default) {
if (clubId == null) delete(KEY_CURRENT_CLUB_ID) else saveString(KEY_CURRENT_CLUB_ID, clubId.toString())
}
private fun loadString(key: String): String? = memScoped {
val query = keychainQuery(
account = key,
extra = mapOf(
kSecReturnData to true,
kSecMatchLimit to kSecMatchLimitOne,
),
)
val result = alloc<CFTypeRefVar>()
val status = SecItemCopyMatching(query as CFDictionaryRef, result.ptr)
if (status != errSecSuccess) return@memScoped null
val data = result.value as? NSData ?: return@memScoped null
val bytes = data.bytes ?: return@memScoped null
val length = data.length.toInt()
val buffer = ByteArray(length)
memcpy(buffer.refTo(0), bytes, data.length)
buffer.decodeToString()
}
private fun saveString(key: String, value: String) {
delete(key)
val data = (value as NSString).dataUsingEncoding(NSUTF8StringEncoding) ?: return
val query = keychainQuery(
account = key,
extra = mapOf(kSecValueData to data),
)
SecItemAdd(query as CFDictionaryRef, null)
}
private fun delete(key: String) {
SecItemDelete(keychainQuery(key) as CFDictionaryRef)
}
private fun keychainQuery(account: String, extra: Map<Any?, Any?> = emptyMap()): Map<Any?, Any?> {
return mapOf(
kSecClass to kSecClassGenericPassword,
kSecAttrService to SERVICE_NAME,
kSecAttrAccount to account,
) + extra
}
private companion object {
private const val SERVICE_NAME = "de.tt_tagebuch.app"
private const val KEY_CURRENT_CLUB_ID = "currentClubId"
}
}

View File

@@ -0,0 +1,94 @@
package de.tt_tagebuch.shared.state
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.cinterop.alloc
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.ptr
import kotlinx.cinterop.refTo
import kotlinx.cinterop.memcpy
import platform.CoreFoundation.CFDictionaryRef
import platform.CoreFoundation.CFTypeRefVar
import platform.Foundation.NSData
import platform.Foundation.NSString
import platform.Foundation.NSUTF8StringEncoding
import platform.Foundation.dataUsingEncoding
import platform.Security.SecItemAdd
import platform.Security.SecItemCopyMatching
import platform.Security.SecItemDelete
import platform.Security.errSecSuccess
import platform.Security.kSecAttrAccount
import platform.Security.kSecAttrService
import platform.Security.kSecClass
import platform.Security.kSecClassGenericPassword
import platform.Security.kSecMatchLimit
import platform.Security.kSecMatchLimitOne
import platform.Security.kSecReturnData
import platform.Security.kSecValueData
class IosTokenStorage : TokenStorage {
override suspend fun load(): AuthTokens? = withContext(Dispatchers.Default) {
val token = loadString(KEY_TOKEN)
val username = loadString(KEY_USERNAME)
if (token.isNullOrBlank() || username.isNullOrBlank()) null else AuthTokens(token, username)
}
override suspend fun save(tokens: AuthTokens) = withContext(Dispatchers.Default) {
saveString(KEY_TOKEN, tokens.token)
saveString(KEY_USERNAME, tokens.username)
}
override suspend fun clear() = withContext(Dispatchers.Default) {
delete(KEY_TOKEN)
delete(KEY_USERNAME)
}
private fun loadString(key: String): String? = memScoped {
val query = keychainQuery(
account = key,
extra = mapOf(
kSecReturnData to true,
kSecMatchLimit to kSecMatchLimitOne,
),
)
val result = alloc<CFTypeRefVar>()
val status = SecItemCopyMatching(query as CFDictionaryRef, result.ptr)
if (status != errSecSuccess) return@memScoped null
val data = result.value as? NSData ?: return@memScoped null
val bytes = data.bytes ?: return@memScoped null
val length = data.length.toInt()
val buffer = ByteArray(length)
memcpy(buffer.refTo(0), bytes, data.length)
buffer.decodeToString()
}
private fun saveString(key: String, value: String) {
delete(key)
val data = (value as NSString).dataUsingEncoding(NSUTF8StringEncoding) ?: return
val query = keychainQuery(
account = key,
extra = mapOf(kSecValueData to data),
)
SecItemAdd(query as CFDictionaryRef, null)
}
private fun delete(key: String) {
SecItemDelete(keychainQuery(key) as CFDictionaryRef)
}
private fun keychainQuery(account: String, extra: Map<Any?, Any?> = emptyMap()): Map<Any?, Any?> {
return mapOf(
kSecClass to kSecClassGenericPassword,
kSecAttrService to SERVICE_NAME,
kSecAttrAccount to account,
) + extra
}
private companion object {
private const val SERVICE_NAME = "de.tt_tagebuch.app"
private const val KEY_TOKEN = "token"
private const val KEY_USERNAME = "username"
}
}