chore: update .gitignore and enhance backend and mobile app functionality
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 44s
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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\"")
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.tt_tagebuch.shared.api.http
|
||||
|
||||
class ApiException(
|
||||
val statusCode: Int,
|
||||
message: String,
|
||||
) : RuntimeException(message)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.tt_tagebuch.shared.api.http
|
||||
|
||||
import io.ktor.client.engine.HttpClientEngine
|
||||
|
||||
interface HttpClientEngineFactory {
|
||||
fun create(): HttpClientEngine
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package de.tt_tagebuch.shared.api.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class LoginResponse(
|
||||
val token: String,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.tt_tagebuch.shared.api.models
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ParticipantStatusRequest(
|
||||
val attendanceStatus: String,
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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 = "",
|
||||
)
|
||||
@@ -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()),
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
```
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.tt_tagebuch.shared.state
|
||||
|
||||
data class AuthTokens(
|
||||
val token: String,
|
||||
val username: String,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.tt_tagebuch.shared.state
|
||||
|
||||
interface ClubStorage {
|
||||
suspend fun loadCurrentClubId(): Int?
|
||||
suspend fun saveCurrentClubId(clubId: Int?)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package de.tt_tagebuch.shared.state
|
||||
|
||||
interface LanguageStorage {
|
||||
suspend fun loadLanguageCode(): String?
|
||||
suspend fun saveLanguageCode(languageCode: String)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package de.tt_tagebuch.shared.state
|
||||
|
||||
class MutableTokenProvider : TokenProvider {
|
||||
override var token: String? = null
|
||||
override var username: String? = null
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package de.tt_tagebuch.shared.state
|
||||
|
||||
interface TokenProvider {
|
||||
val token: String?
|
||||
val username: String?
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.tt_tagebuch.shared.state
|
||||
|
||||
interface TokenStorage {
|
||||
suspend fun load(): AuthTokens?
|
||||
suspend fun save(tokens: AuthTokens)
|
||||
suspend fun clear()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user